diff --git a/Jetchat/app/proguard-rules.pro b/Jetchat/app/proguard-rules.pro index 9e6e059b3b..13135c4c88 100644 --- a/Jetchat/app/proguard-rules.pro +++ b/Jetchat/app/proguard-rules.pro @@ -36,3 +36,8 @@ -keep class androidx.compose.ui.platform.AndroidCompositionLocals_androidKt { *; } + +# Keep RoomDatabase implementations and their parameterless constructor for R8 Full Mode +-keep class * extends androidx.room.RoomDatabase { + (); +} diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt index 617a4bd295..d0eaf1aff7 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt @@ -31,8 +31,10 @@ import com.example.compose.jetchat.conversation.ConversationContent import com.example.compose.jetchat.conversation.ConversationTestTag import com.example.compose.jetchat.conversation.ConversationUiState import com.example.compose.jetchat.data.exampleUiState +import com.example.compose.jetchat.data.resetFakeData import com.example.compose.jetchat.theme.JetchatTheme import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -49,11 +51,13 @@ class ConversationTest { @Before fun setUp() { + resetFakeData() + val uiState = createConversationTestUiState() // Launch the conversation screen composeTestRule.setContent { JetchatTheme(isDarkTheme = themeIsDark.collectAsStateWithLifecycle(false).value) { ConversationContent( - uiState = conversationTestUiState, + uiState = uiState, navigateToProfile = { }, onNavIconPressed = { }, ) @@ -61,6 +65,11 @@ class ConversationTest { } } + @After + fun tearDown() { + resetFakeData() + } + @Test fun app_launches() { // Check that the conversation screen is visible on launch @@ -71,13 +80,7 @@ class ConversationTest { fun userScrollsUp_jumpToBottomAppears() { // Check list is snapped to bottom and swipe up findJumpToBottom().assertDoesNotExist() - composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput { - this.swipe( - start = this.center, - end = Offset(this.center.x, this.center.y + 500), - durationMillis = 200, - ) - } + scrollAwayFromBottom() // Check that the jump to bottom button is shown findJumpToBottom().assertIsDisplayed() } @@ -85,13 +88,7 @@ class ConversationTest { @Test fun jumpToBottom_snapsToBottomAndDisappears() { // When the scroll is not snapped to the bottom - composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput { - this.swipe( - start = this.center, - end = Offset(this.center.x, this.center.y + 500), - durationMillis = 200, - ) - } + scrollAwayFromBottom() // Snap scroll to the bottom findJumpToBottom().performClick() @@ -102,16 +99,7 @@ class ConversationTest { @Test fun jumpToBottom_snapsToBottomAfterUserInteracted() { // First swipe - composeTestRule.onNodeWithTag( - testTag = ConversationTestTag, - useUnmergedTree = true, // https://issuetracker.google.com/issues/184825850 - ).performTouchInput { - this.swipe( - start = this.center, - end = Offset(this.center.x, this.center.y + 500), - durationMillis = 200, - ) - } + scrollAwayFromBottom() // Second, snap to bottom findJumpToBottom().performClick() @@ -125,13 +113,7 @@ class ConversationTest { @Test fun changeTheme_scrollIsPersisted() { // Swipe to show the jump to bottom button - composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput { - this.swipe( - start = this.center, - end = Offset(this.center.x, this.center.y + 500), - durationMillis = 200, - ) - } + scrollAwayFromBottom() // Check that the jump to bottom button is shown findJumpToBottom().assertIsDisplayed() @@ -143,6 +125,27 @@ class ConversationTest { findJumpToBottom().assertIsDisplayed() } + /** + * Scrolls the conversation far enough away from the bottom (more than 1.5x the viewport + * height) for the jump to bottom button to appear. Start from the center so the touch down + * does not land on the overlaid top app bar. + */ + private fun scrollAwayFromBottom() { + repeat(5) { + composeTestRule.onNodeWithTag( + testTag = ConversationTestTag, + useUnmergedTree = true, // https://issuetracker.google.com/issues/184825850 + ).performTouchInput { + this.swipe( + start = this.center, + end = Offset(this.center.x, this.bottom - 10f), + durationMillis = 200, + ) + } + composeTestRule.waitForIdle() + } + } + private fun findJumpToBottom() = composeTestRule.onNodeWithText( composeTestRule.activity.getString(R.string.jumpBottom), useUnmergedTree = true, @@ -159,8 +162,9 @@ class ConversationTest { /** * Make the list of messages longer so the test makes sense on tablets. */ -private val conversationTestUiState = ConversationUiState( - initialMessages = (exampleUiState.messages.plus(exampleUiState.messages)), +private fun createConversationTestUiState() = ConversationUiState( + initialMessages = exampleUiState.messages + + exampleUiState.messages.map { it.copy(id = "${it.id}-copy") }, channelName = "#composers", channelMembers = 42, ) diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt index 31a39477bb..0eb97c5b5c 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt @@ -32,6 +32,8 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeRight import androidx.lifecycle.Lifecycle import androidx.test.espresso.Espresso +import com.example.compose.jetchat.data.resetFakeData +import org.junit.After import org.junit.Rule import org.junit.Test @@ -43,6 +45,13 @@ class NavigationTest { @get:Rule val composeTestRule = createAndroidComposeRule() + @After + fun tearDown() { + // NavActivity launches inside composeTestRule before @Before runs, so clear cached channel + // state after each test so the next test's Activity starts fresh. + resetFakeData() + } + @Test fun app_launches() { // Check app launches at the correct destination diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt index 3d376d7621..64d9cb38bc 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt @@ -40,7 +40,9 @@ import androidx.test.espresso.Espresso import com.example.compose.jetchat.conversation.ConversationContent import com.example.compose.jetchat.conversation.KeyboardShownKey import com.example.compose.jetchat.data.exampleUiState +import com.example.compose.jetchat.data.resetFakeData import com.example.compose.jetchat.theme.JetchatTheme +import org.junit.After import org.junit.Before import org.junit.Ignore import org.junit.Rule @@ -58,11 +60,13 @@ class UserInputTest { @Before fun setUp() { + resetFakeData() // Launch the conversation screen + val uiState = exampleUiState composeTestRule.setContent { JetchatTheme { ConversationContent( - uiState = exampleUiState, + uiState = uiState, navigateToProfile = { }, onNavIconPressed = { }, ) @@ -70,6 +74,11 @@ class UserInputTest { } } + @After + fun tearDown() { + resetFakeData() + } + @Test @Ignore("Issue with keyboard sync https://issuetracker.google.com/169235317") fun emojiSelector_isClosedWithBack() { diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt index 68c687a8d8..e845559974 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/JetchatApp.kt @@ -17,29 +17,39 @@ package com.example.compose.jetchat import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.material3.DrawerValue.Closed import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole import androidx.compose.material3.adaptive.layout.PaneAdaptedValue +import androidx.compose.material3.adaptive.layout.PaneScaffoldDirective import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.material3.adaptive.navigation3.LocalListDetailSceneScope import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.paneTitle @@ -47,23 +57,30 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntryDecorator import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.navigation3.ui.NavDisplay import com.example.compose.jetchat.components.JetchatDrawer import com.example.compose.jetchat.components.JetchatDrawerContent import com.example.compose.jetchat.conversation.ConversationContent import com.example.compose.jetchat.conversation.ConversationViewModel +import com.example.compose.jetchat.data.findMessage import com.example.compose.jetchat.navigation.ChatList import com.example.compose.jetchat.navigation.Conversation import com.example.compose.jetchat.navigation.Profile +import com.example.compose.jetchat.navigation.VideoPlayer import com.example.compose.jetchat.navigation.openConversation import com.example.compose.jetchat.navigation.openProfile import com.example.compose.jetchat.profile.ProfileError import com.example.compose.jetchat.profile.ProfileScreen import com.example.compose.jetchat.profile.ProfileViewModel +import com.example.compose.jetchat.video.FullScreenVideoPlayer +import com.example.compose.jetchat.video.LocalAnimatedVisibilityScope +import com.example.compose.jetchat.video.LocalSharedTransitionScope import kotlinx.coroutines.launch /** @@ -80,14 +97,36 @@ fun JetchatApp() { val drawerState = rememberDrawerState(initialValue = Closed) val scope = rememberCoroutineScope() - // Override the defaults so that there isn't a horizontal space between the panes + // Override the defaults so that there isn't a horizontal space between the panes, and don't + // auto-focus the current pane (which would focus the conversation text field). + // PaneScaffoldDirective.copy() does not expose shouldAutoFocusCurrentDestination, so construct + // the directive explicitly. val windowAdaptiveInfo = currentWindowAdaptiveInfoV2() val directive = remember(windowAdaptiveInfo) { - calculatePaneScaffoldDirective(windowAdaptiveInfo) - .copy(horizontalPartitionSpacerSize = 0.dp) + with(calculatePaneScaffoldDirective(windowAdaptiveInfo)) { + PaneScaffoldDirective( + maxHorizontalPartitions = maxHorizontalPartitions, + horizontalPartitionSpacerSize = 0.dp, + maxVerticalPartitions = maxVerticalPartitions, + verticalPartitionSpacerSize = verticalPartitionSpacerSize, + defaultPanePreferredWidth = defaultPanePreferredWidth, + defaultPanePreferredHeight = defaultPanePreferredHeight, + excludedBounds = excludedBounds, + shouldAutoFocusCurrentDestination = false, + ) + } } val isMultiPane = directive.maxHorizontalPartitions > 1 - val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive) + + val selectedKey = backStack.lastOrNull { it !is VideoPlayer } ?: backStack.last() + val listDetailStrategy = rememberListDetailSceneStrategy( + backNavigationBehavior = if (selectedKey is Profile) { + BackNavigationBehavior.PopLatest + } else { + BackNavigationBehavior.PopUntilScaffoldValueChange + }, + directive = directive, + ) LaunchedEffect(isMultiPane) { if (isMultiPane && drawerState.isOpen) { @@ -95,11 +134,23 @@ fun JetchatApp() { } } - val selectedKey = backStack.last() + var isPoppingVideoPlayer by remember { mutableStateOf(false) } + val onNavigate: (NavKey) -> Unit = { key -> when (key) { - is Conversation -> backStack.openConversation(key.channel) - is Profile -> backStack.openProfile(key.userId) + is Conversation -> { + isPoppingVideoPlayer = false + backStack.openConversation(key.channel) + } + + is Profile -> { + isPoppingVideoPlayer = false + backStack.openProfile(key.userId, key.isExtraPane) + } + + is VideoPlayer -> Unit + + ChatList -> Unit } scope.launch { drawerState.close() @@ -108,11 +159,15 @@ fun JetchatApp() { // On single-pane windows the chat list is shown in the drawer instead of a screen. The // conversation is never popped, so the displayed back stack is never empty. - val displayBackStack = if (isMultiPane) { - backStack - } else { - backStack.filter { it !is ChatList }.also { - check(it.isNotEmpty()) { "A conversation must always be on the back stack" } + val displayBackStack by remember(isMultiPane) { + derivedStateOf { + if (isMultiPane) { + backStack + } else { + backStack.filter { it !is ChatList }.also { + check(it.isNotEmpty()) { "A conversation must always be on the back stack" } + } + } } } @@ -121,8 +176,8 @@ fun JetchatApp() { selectedKey = selectedKey, onNavigate = onNavigate, drawerState = drawerState, - // The list pane replaces the drawer on wide windows - gesturesEnabled = !isMultiPane, + // The list pane replaces the drawer on wide windows, and disable drawer gestures during video playback + gesturesEnabled = !isMultiPane && (backStack.lastOrNull() !is VideoPlayer), ) { BackHandler(enabled = drawerState.isOpen) { scope.launch { @@ -130,117 +185,191 @@ fun JetchatApp() { } } - NavDisplay( - backStack = displayBackStack, - onBack = { backStack.removeLastOrNull() }, - sceneStrategies = listOf(listDetailStrategy), - // Scope the ViewModels to their entry. The ViewModelStoreNavEntryDecorator - // requires the default SaveableStateHolderNavEntryDecorator, which saves the - // state of each entry. - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = entryProvider { - entry( - metadata = ListDetailSceneStrategy.listPane( - detailPlaceholder = { ChatPlaceholder() }, + SharedTransitionLayout { + val popBackStack: () -> Unit = { + isPoppingVideoPlayer = backStack.lastOrNull() is VideoPlayer + backStack.removeLastOrNull() + } + + CompositionLocalProvider(LocalSharedTransitionScope provides this) { + NavDisplay( + backStack = displayBackStack, + onBack = popBackStack, + sharedTransitionScope = this, + sceneStrategies = listOf(listDetailStrategy), + // Scope the ViewModels to their entry. The ViewModelStoreNavEntryDecorator + // requires the default SaveableStateHolderNavEntryDecorator, which saves the + // state of each entry. + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + rememberAnimatedVisibilityScopeNavEntryDecorator(), ), - ) { - val chatListTitle = stringResource(R.string.chat_list) - Surface( - modifier = Modifier - .fillMaxSize() - .semantics { paneTitle = chatListTitle }, - color = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - ) { - JetchatDrawerContent( - selectedKey = selectedKey, - onNavigate = onNavigate, - ) - } - } - entry( - metadata = ListDetailSceneStrategy.detailPane(), - ) { key -> - // The channel comes from the key, so pass it when the ViewModel of this - // entry is created - val viewModel = viewModel { ConversationViewModel(channel = key.channel) } - // The channel name shows the chat list: it opens the drawer on single-pane - // windows, where there is no list-detail scene, and pops the profile that - // hides the list pane on wide windows. Whether the list pane is shown comes - // from the scaffold, as it depends on the number of panes: on 3-pane windows - // it stays next to the conversation while a profile is shown. - val sceneScope = LocalListDetailSceneScope.current - val isListPaneShown = sceneScope?.scaffoldTransitionScope - ?.scaffoldStateTransition?.targetState - ?.get(ListDetailPaneScaffoldRole.List) == PaneAdaptedValue.Expanded - ConversationContent( - uiState = viewModel.uiState, - navigateToProfile = { userId -> backStack.openProfile(userId) }, - onNavIconPressed = when { - sceneScope == null -> { - { + entryProvider = entryProvider { + entry( + metadata = ListDetailSceneStrategy.listPane(), + ) { + val chatListTitle = stringResource(R.string.chat_list) + Surface( + modifier = Modifier + .fillMaxSize() + .semantics { paneTitle = chatListTitle }, + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + ) { + JetchatDrawerContent( + selectedKey = selectedKey, + onNavigate = onNavigate, + ) + } + } + entry( + metadata = ListDetailSceneStrategy.detailPane(), + ) { key -> + ConversationEntry( + channel = key.channel, + isVideoPlayerOnTop = backStack.lastOrNull() is VideoPlayer, + isPoppingVideoPlayer = isPoppingVideoPlayer, + onClearPoppingVideoPlayer = { isPoppingVideoPlayer = false }, + navigateToProfile = { userId -> + backStack.openProfile(userId, isExtraPane = true) + }, + onOpenDrawer = { scope.launch { drawerState.open() } + }, + onShowChatList = { backStack.openConversation(key.channel) }, + onVideoClick = { videoUri, messageId -> + isPoppingVideoPlayer = false + backStack.add(VideoPlayer(videoUri = videoUri, messageId = messageId)) + }, + ) + } + entry( + metadata = { key -> + if (key.isExtraPane) { + ListDetailSceneStrategy.extraPane() + } else { + ListDetailSceneStrategy.detailPane() } + }, + ) { key -> + // The user id comes from the key, so set it when the ViewModel of this + // entry is created + val viewModel = viewModel { + ProfileViewModel().apply { setUserId(key.userId) } } + val userData by viewModel.userData.observeAsState() - isListPaneShown -> null - - else -> { - { backStack.removeLastOrNull() } - } - }, - navIconContentDescription = stringResource( - if (sceneScope == null) { - R.string.navigation_drawer_open + if (userData == null) { + ProfileError() } else { - R.string.show_chat_list + ProfileScreen( + userData = userData!!, + ) + } + } + entry( + metadata = NavDisplay.transitionSpec { + fadeIn(animationSpec = tween(300)) togetherWith + ExitTransition.KeepUntilTransitionsFinished + } + NavDisplay.popTransitionSpec { + EnterTransition.None togetherWith + fadeOut(animationSpec = tween(300)) + } + NavDisplay.predictivePopTransitionSpec { + EnterTransition.None togetherWith fadeOut() }, - ), - onMessageLikeToggled = viewModel::toggleLike, - ) - } - entry( - metadata = ListDetailSceneStrategy.extraPane(), - ) { key -> - // The user id comes from the key, so set it when the ViewModel of this - // entry is created - val viewModel = viewModel { - ProfileViewModel().apply { setUserId(key.userId) } - } - val userData by viewModel.userData.observeAsState() - - if (userData == null) { - ProfileError() - } else { - ProfileScreen( - userData = userData!!, - ) - } - } - }, - ) + ) { key -> + val message = key.messageId?.let(::findMessage) + FullScreenVideoPlayer( + videoUri = key.videoUri, + messageText = message?.content, + messageId = key.messageId, + isUserMe = message == null || message.author == stringResource(R.string.author_me), + onDismiss = popBackStack, + enableBackHandler = false, + ) + } + }, + ) + } + } } } -/** Shown in the detail pane when no conversation is selected. */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable -private fun ChatPlaceholder() { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = stringResource(R.string.select_a_chat), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(24.dp), - ) +private fun ConversationEntry( + channel: String, + isVideoPlayerOnTop: Boolean, + isPoppingVideoPlayer: Boolean, + onClearPoppingVideoPlayer: () -> Unit, + navigateToProfile: (String) -> Unit, + onOpenDrawer: () -> Unit, + onShowChatList: () -> Unit, + onVideoClick: (String, String) -> Unit, +) { + // The channel comes from the key, so pass it when the ViewModel of this entry is created + val viewModel = viewModel { ConversationViewModel(channel = channel) } + // The channel name shows the chat list: it opens the drawer on single-pane windows, where + // there is no list-detail scene, and pops the profile that hides the list pane on wide + // windows. Whether the list pane is shown comes from the scaffold, as it depends on the + // number of panes: on 3-pane windows it stays next to the conversation while a profile is + // shown. + val sceneScope = LocalListDetailSceneScope.current + val isListPaneShown = sceneScope?.scaffoldTransitionScope + ?.scaffoldStateTransition?.targetState + ?.get(ListDetailPaneScaffoldRole.List) == PaneAdaptedValue.Expanded + + val navTransition = LocalNavAnimatedContentScope.current.transition + val isTransitionActive = LocalSharedTransitionScope.current?.isTransitionActive == true + val isSettledOnConversation = + navTransition.currentState == EnterExitState.Visible && + navTransition.targetState == EnterExitState.Visible + LaunchedEffect(isPoppingVideoPlayer, isSettledOnConversation, isTransitionActive) { + if (isPoppingVideoPlayer && isSettledOnConversation && !isTransitionActive) { + onClearPoppingVideoPlayer() + } + } + val isPredictiveBackFromVideoPlayer = + isVideoPlayerOnTop && + navTransition.currentState == EnterExitState.PreEnter && + navTransition.targetState == EnterExitState.Visible + + ConversationContent( + uiState = viewModel.uiState, + navigateToProfile = navigateToProfile, + onNavIconPressed = when { + sceneScope == null -> onOpenDrawer + isListPaneShown -> null + else -> onShowChatList + }, + navIconContentDescription = stringResource( + if (sceneScope == null) { + R.string.navigation_drawer_open + } else { + R.string.show_chat_list + }, + ), + onMessageLikeToggled = viewModel::toggleLike, + onVideoClick = onVideoClick, + isReturningFromVideoPlayer = isPoppingVideoPlayer || isPredictiveBackFromVideoPlayer, + ) +} + +/** + * Exposes Navigation 3's [LocalNavAnimatedContentScope] to every entry as the nullable + * [LocalAnimatedVisibilityScope] used by the video shared element transition, so it does not have + * to be provided by each entry individually. + */ +@Composable +private fun rememberAnimatedVisibilityScopeNavEntryDecorator(): NavEntryDecorator = remember { + NavEntryDecorator { entry -> + CompositionLocalProvider( + LocalAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current, + ) { + entry.Content() } } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt index 793b1bc9ca..990374faa6 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt @@ -20,8 +20,10 @@ package com.example.compose.jetchat.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsIgnoringVisibility import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -66,6 +68,7 @@ fun JetchatAppBar( scrolledContainerColor = Color.Transparent, ), navigationIcon = navigationIcon, + windowInsets = WindowInsets.statusBarsIgnoringVisibility, ) } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt index 404d66f73c..32741779d1 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt @@ -31,12 +31,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.captionBar import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsIgnoringVisibility +import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider @@ -73,9 +74,13 @@ import com.example.compose.jetchat.widget.WidgetReceiver @Composable fun JetchatDrawerContent(selectedKey: NavKey, onNavigate: (NavKey) -> Unit) { // Use windowInsetsTopHeight() to add a spacer which pushes the drawer content - // below the status bar (y-axis) + // below the status bar and caption bar (y-axis) Column { - Spacer(Modifier.windowInsetsTopHeight(WindowInsets.statusBars)) + Spacer( + Modifier.windowInsetsTopHeight( + WindowInsets.statusBarsIgnoringVisibility.union(WindowInsets.captionBar), + ), + ) DrawerHeader() DividerItem() DrawerItemHeader("Chats") diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index bf56c490a9..768e836236 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -14,12 +14,13 @@ * limitations under the License. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package com.example.compose.jetchat.conversation import android.content.ClipDescription +import android.text.format.DateFormat import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring @@ -33,8 +34,8 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -57,25 +58,23 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults @@ -91,7 +90,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -100,17 +98,19 @@ import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.mimeTypes import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.paint import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.blur.BlurRadiusSpec import androidx.compose.ui.graphics.blur.BlurStop +import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -121,6 +121,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastSumBy import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R import com.example.compose.jetchat.blur.backdropBlur @@ -128,8 +129,12 @@ import com.example.compose.jetchat.components.JetchatAppBar import com.example.compose.jetchat.components.rememberHeartReactionMeshGradientPainter import com.example.compose.jetchat.data.exampleUiState import com.example.compose.jetchat.theme.JetchatTheme -import com.example.compose.jetchat.video.FullScreenVideoPlayer +import com.example.compose.jetchat.video.LocalAnimatedVisibilityScope +import com.example.compose.jetchat.video.LocalSharedTransitionScope import com.example.compose.jetchat.video.VideoThumbnail +import com.example.compose.jetchat.video.videoSharedElementKey +import java.util.Date +import java.util.UUID import kotlin.math.PI import kotlin.math.sin import kotlinx.coroutines.launch @@ -144,8 +149,10 @@ import kotlinx.coroutines.launch * when this is not null * @param navIconContentDescription Accessibility description of what [onNavIconPressed] does * @param onMessageLikeToggled Sends an event up when the user double taps a message to like it + * @param onVideoClick Called when a video attachment in a message is clicked + * @param isReturningFromVideoPlayer Whether the conversation is currently returning from the + * full-screen video player */ -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ConversationContent( uiState: ConversationUiState, @@ -154,9 +161,12 @@ fun ConversationContent( onNavIconPressed: (() -> Unit)? = { }, navIconContentDescription: String = stringResource(R.string.navigation_drawer_open), onMessageLikeToggled: (messageId: String) -> Unit = { }, + onVideoClick: (videoUri: String, messageId: String) -> Unit = { _, _ -> }, + isReturningFromVideoPlayer: Boolean = false, ) { + val context = LocalContext.current val authorMe = stringResource(R.string.author_me) - val timeNow = stringResource(id = R.string.now) + fun formatCurrentTime(): String = DateFormat.getTimeFormat(context).format(Date()) val scrollState = rememberLazyListState() val topBarState = rememberTopAppBarState() @@ -171,7 +181,7 @@ fun ConversationContent( mutableStateOf(Color.Transparent) } - val dragAndDropCallback = remember { + val dragAndDropCallback = remember(uiState, context, authorMe) { object : DragAndDropTarget { override fun onDrop(event: DragAndDropEvent): Boolean { val clipData = event.toAndroidDragEvent().clipData @@ -181,7 +191,12 @@ fun ConversationContent( } uiState.addMessage( - Message(authorMe, clipData.getItemAt(0).text.toString(), timeNow), + Message( + author = authorMe, + content = clipData.getItemAt(0).text.toString(), + timestamp = formatCurrentTime(), + id = UUID.randomUUID().toString(), + ), ) return true @@ -210,9 +225,6 @@ fun ConversationContent( } } - var activeVideoUri by rememberSaveable { mutableStateOf(null) } - var activeVideoText by rememberSaveable { mutableStateOf(null) } - Box(modifier = modifier.fillMaxSize()) { Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, @@ -232,7 +244,8 @@ fun ConversationContent( Message( author = authorMe, content = annotatedContent.text, - timestamp = timeNow, + timestamp = formatCurrentTime(), + id = UUID.randomUUID().toString(), annotatedContent = annotatedContent.takeIf { it.spanStyles.isNotEmpty() }, ), ) @@ -242,8 +255,9 @@ fun ConversationContent( Message( author = authorMe, content = caption.text, - timestamp = timeNow, + timestamp = formatCurrentTime(), videoUri = videoUri, + id = UUID.randomUUID().toString(), annotatedContent = caption.takeIf { it.spanStyles.isNotEmpty() }, ), ) @@ -257,6 +271,7 @@ fun ConversationContent( // navigation bar modifier = Modifier .imePadding() + .userInputReturnFromVideoTransition(isReturningFromVideoPlayer) .backdropBlur( fallbackColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), elevation = 0.dp, @@ -267,13 +282,13 @@ fun ConversationContent( ), ), ) - .navigationBarsPadding(), + .windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility), ) }, // Exclude ime and navigation bar padding so this can be added by the UserInput composable contentWindowInsets = ScaffoldDefaults .contentWindowInsets - .exclude(WindowInsets.navigationBars) + .exclude(WindowInsets.navigationBarsIgnoringVisibility) .exclude(WindowInsets.ime), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> @@ -314,36 +329,15 @@ fun ConversationContent( modifier = Modifier.weight(1f), scrollState = scrollState, contentPadding = paddingValues, - onVideoClick = { videoUri, messageText -> - activeVideoUri = videoUri - activeVideoText = messageText - }, + onVideoClick = onVideoClick, onMessageLikeToggled = onMessageLikeToggled, ) } } } - - AnimatedVisibility( - visible = activeVideoUri != null, - enter = fadeIn(animationSpec = tween(200)), - exit = fadeOut(animationSpec = tween(200)), - ) { - activeVideoUri?.let { uri -> - FullScreenVideoPlayer( - videoUri = uri, - messageText = activeVideoText, - onDismiss = { - activeVideoUri = null - activeVideoText = null - }, - ) - } - } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChannelNameBar( channelName: String, @@ -475,7 +469,7 @@ fun Messages( scrollState: LazyListState, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), - onVideoClick: (videoUri: String, messageText: String) -> Unit = { _, _ -> }, + onVideoClick: (videoUri: String, messageId: String) -> Unit = { _, _ -> }, onMessageLikeToggled: (messageId: String) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -521,18 +515,23 @@ fun Messages( } } } - // Jump to bottom button shows up when user scrolls past a threshold. - // Convert to pixels: - val jumpThreshold = with(LocalDensity.current) { - JumpToBottomThreshold.toPx() - } - - // Show the button if the first visible item is not the first one or if the offset is - // greater than the threshold. - val jumpToBottomButtonEnabled by remember { + // Jump to bottom button only shows up once the user has scrolled further away from the + // bottom than a multiple of the viewport height. Items below the first visible one are + // not measured, so their size is estimated from the average size of the visible items. + val jumpToBottomButtonEnabled by remember(scrollState) { derivedStateOf { - scrollState.firstVisibleItemIndex != 0 || - scrollState.firstVisibleItemScrollOffset > jumpThreshold + val layoutInfo = scrollState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val viewportHeight = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset + if (visibleItems.isEmpty() || viewportHeight <= 0) { + false + } else { + val averageItemSize = visibleItems.fastSumBy { it.size } / visibleItems.size + val estimatedDistanceFromBottom = + scrollState.firstVisibleItemIndex * averageItemSize + + scrollState.firstVisibleItemScrollOffset + estimatedDistanceFromBottom > viewportHeight * JumpToBottomViewportThreshold + } } } @@ -558,7 +557,7 @@ fun Message( isUserMe: Boolean, isFirstMessageByAuthor: Boolean, isLastMessageByAuthor: Boolean, - onVideoClick: (videoUri: String, messageText: String) -> Unit = { _, _ -> }, + onVideoClick: (videoUri: String, messageId: String) -> Unit = { _, _ -> }, onLikeToggled: (messageId: String) -> Unit = {}, ) { val borderColor = if (isUserMe) { @@ -645,7 +644,7 @@ fun AuthorAndTextMessage( isLastMessageByAuthor: Boolean, authorClicked: (String) -> Unit, modifier: Modifier = Modifier, - onVideoClick: (videoUri: String, messageText: String) -> Unit = { _, _ -> }, + onVideoClick: (videoUri: String, messageId: String) -> Unit = { _, _ -> }, onLikeToggled: (messageId: String) -> Unit = {}, ) { Column( @@ -738,7 +737,7 @@ fun ChatItemBubble( message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit, - onVideoClick: (videoUri: String, messageText: String) -> Unit = { _, _ -> }, + onVideoClick: (videoUri: String, messageId: String) -> Unit = { _, _ -> }, onLikeToggled: (messageId: String) -> Unit = {}, ) { val isLiked = message.isLiked @@ -887,14 +886,15 @@ fun ChatItemBubble( ) { VideoThumbnail( videoUri = videoUri, - onClick = { onVideoClick(videoUri, message.content) }, + onClick = { onVideoClick(videoUri, message.id) }, shape = bubbleShape, + sharedElementKey = videoSharedElementKey(message.id, videoUri), + isUserMe = isUserMe, modifier = Modifier .padding(if (isLiked) 8.dp else 0.dp) .widthIn(max = 260.dp) .fillMaxWidth() - .height(200.dp) - .clip(bubbleShape), + .height(200.dp), ) } } @@ -973,4 +973,58 @@ fun DayHeaderPrev() { DayHeader("Aug 6") } -private val JumpToBottomThreshold = 56.dp +/** + * Animates [UserInput] back in when the conversation returns from the full-screen video player: + * it is rendered in the shared transition overlay above the shrinking video frame and slides up + * from the bottom. Outside of a shared transition this only clips the drop shadow to the pane. + */ +@Composable +private fun Modifier.userInputReturnFromVideoTransition(isReturningFromVideoPlayer: Boolean): Modifier { + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + val transitionModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay( + renderInOverlay = { isReturningFromVideoPlayer }, + zIndexInOverlay = 1f, + ) + .animateEnterExit( + enter = if (isReturningFromVideoPlayer) { + slideInVertically( + animationSpec = tween(durationMillis = 250, delayMillis = 50), + initialOffsetY = { fullHeight -> fullHeight }, + ) + } else { + EnterTransition.None + }, + exit = ExitTransition.None, + ) + } + } + } else { + Modifier + } + return this + .then(transitionModifier) + .drawWithContent { + // Clip horizontally to the conversation pane bounds so the shadow does not bleed onto + // ChatList while rendered in the shared transition overlay, while leaving headroom + // above for the top drop shadow. + clipRect( + left = 0f, + top = -UserInputTopShadowHeadroom.toPx(), + right = size.width, + bottom = size.height, + ) { + this@drawWithContent.drawContent() + } + } +} + +/** + * The jump to bottom button is shown once the list is scrolled further than this many viewport + * heights away from the bottom. + */ +private const val JumpToBottomViewportThreshold = 1.5f diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 58103f8561..288ffc1930 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -148,6 +148,17 @@ fun UserInputPreview() { UserInput(onMessageSent = {}) } +private val UserInputIdleShadowRadius = 24.dp +private val UserInputFocusedShadowRadius = 10.dp +private val UserInputIdleShadowOffsetY = 4.dp +private val UserInputFocusedShadowOffsetY = 6.dp + +/** + * Extra vertical headroom above [UserInput] needed so its idle drop shadow ([UserInputIdleShadowRadius]) + * is not clipped when clipping horizontally to the conversation pane bounds. + */ +internal val UserInputTopShadowHeadroom = UserInputIdleShadowRadius + 8.dp + @OptIn(ExperimentalFoundationApi::class) @Composable fun UserInput( @@ -297,10 +308,18 @@ fun UserInput( } else { Modifier.dropShadow(cardShape) { val p = focusProgress.value - radius = lerp(24.dp.toPx(), 10.dp.toPx(), p) + radius = lerp( + UserInputIdleShadowRadius.toPx(), + UserInputFocusedShadowRadius.toPx(), + p, + ) offset = Offset( x = lerp(0f, -4.dp.toPx(), p), - y = lerp(4.dp.toPx(), 6.dp.toPx(), p), + y = lerp( + UserInputIdleShadowOffsetY.toPx(), + UserInputFocusedShadowOffsetY.toPx(), + p, + ), ) color = shadowColor alpha = lerp(0.25f, 0.6f, p) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt index d0c02f7933..c816958110 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt @@ -16,6 +16,7 @@ package com.example.compose.jetchat.data +import androidx.annotation.VisibleForTesting import com.example.compose.jetchat.R import com.example.compose.jetchat.conversation.ConversationUiState import com.example.compose.jetchat.conversation.Message @@ -247,20 +248,40 @@ private val channels = mapOf( "droidcon-nyc" to FakeChannel(members = 7, messages = emptyList()), ) +private val channelStates = mutableMapOf() + /** - * Creates the initial [ConversationUiState] of [channel] from the fake data. Unknown channels are - * empty. + * Returns the [ConversationUiState] of [channel] from the fake data, creating it on first access. + * Unknown channels are empty. */ -fun conversationUiState(channel: String): ConversationUiState { +fun conversationUiState(channel: String): ConversationUiState = channelStates.getOrPut(channel) { val fakeChannel = channels[channel] ?: FakeChannel(members = 0, messages = emptyList()) - return ConversationUiState( + ConversationUiState( channelName = "#$channel", channelMembers = fakeChannel.members, initialMessages = fakeChannel.messages, ) } -val exampleUiState = conversationUiState("composers") +/** + * Finds a [Message] by its [messageId] across all conversations. + */ +fun findMessage(messageId: String): Message? = channelStates.values.firstNotNullOfOrNull { state -> + state.messages.find { it.id == messageId } +} ?: channels.values.firstNotNullOfOrNull { channel -> + channel.messages.find { it.id == messageId } +} + +/** + * Clears cached [ConversationUiState] instances so tests start with fresh channel data. + */ +@VisibleForTesting +internal fun resetFakeData() { + channelStates.clear() +} + +val exampleUiState: ConversationUiState + get() = conversationUiState("composers") /** * Example colleague profile diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt index 3c1fe9a434..fe2bb4e547 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/navigation/JetchatNavKeys.kt @@ -35,7 +35,11 @@ data class Conversation(val channel: String) : NavKey /** The profile screen of the user identified by [userId]. */ @Serializable -data class Profile(val userId: String) : NavKey +data class Profile(val userId: String, val isExtraPane: Boolean = false) : NavKey + +/** The full-screen video player for [videoUri], optionally associated with [messageId]. */ +@Serializable +data class VideoPlayer(val videoUri: String, val messageId: String? = null) : NavKey /** * Opens the conversation of [channel]. Only keeps [ChatList] and the selected conversation so @@ -53,8 +57,8 @@ fun NavBackStack.openConversation(channel: String) { * Opens the profile of the user identified by [userId] on top of the current screen, replacing * any profile that is already shown instead of stacking profiles. */ -fun NavBackStack.openProfile(userId: String) { - val profile = Profile(userId) +fun NavBackStack.openProfile(userId: String, isExtraPane: Boolean = false) { + val profile = Profile(userId, isExtraPane) when (lastOrNull()) { profile -> Unit is Profile -> this[lastIndex] = profile diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt index 712f0b92f0..68dba2b9d8 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt @@ -21,15 +21,16 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -42,7 +43,6 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur @@ -91,7 +91,7 @@ fun ProfileScreen(userData: ProfileScreenState) { @Composable private fun UserInfoFields(userData: ProfileScreenState) { - Column(modifier = Modifier.navigationBarsPadding()) { + Column(modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility)) { Spacer(modifier = Modifier.height(40.dp)) NameAndPosition(userData) @@ -202,7 +202,7 @@ fun ProfileFab(extended: Boolean, userIsMe: Boolean, modifier: Modifier = Modifi onClick = onFabClicked, modifier = modifier .padding(16.dp) - .navigationBarsPadding() + .windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility) .height(48.dp) .widthIn(min = 48.dp), containerColor = MaterialTheme.colorScheme.tertiaryContainer, diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayer.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayer.kt index c95fc8e021..e80f91e69d 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayer.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayer.kt @@ -16,15 +16,28 @@ package com.example.compose.jetchat.video +import android.content.Context import android.graphics.Bitmap import android.graphics.RectF import android.media.MediaMetadataRetriever import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy import android.view.SurfaceHolder import android.view.SurfaceView import androidx.activity.compose.BackHandler -import androidx.annotation.OptIn +import androidx.activity.compose.LocalActivity +import androidx.annotation.OptIn as AndroidxOptIn import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image @@ -41,30 +54,48 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.retain.RetainObserver +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.toRect import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onLayoutRectChanged +import androidx.compose.ui.layout.onVisibilityChanged +import androidx.compose.ui.node.Ref import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.graphics.createBitmap +import androidx.core.graphics.scale import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect @@ -80,52 +111,346 @@ import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval import com.example.compose.jetchat.R import com.example.compose.jetchat.conversation.BlurRegionSpec import com.example.compose.jetchat.conversation.SurfaceViewBlurHelper +import kotlin.coroutines.resume +import kotlin.math.roundToInt import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull const val DEFAULT_VIDEO_URL = "asset:///delightful_tips.mp4" +// Provided alongside NavDisplay(sharedTransitionScope = ...); can be dropped once Navigation 3 +// exposes a CompositionLocal for its SharedTransitionScope. +val LocalSharedTransitionScope = compositionLocalOf { null } +val LocalAnimatedVisibilityScope = compositionLocalOf { null } + +fun videoSharedElementKey(messageId: String?, videoUri: String): String = "video-${messageId ?: videoUri}" + /** - * Lightweight video thumbnail displayed inside scrollable lists (LazyColumn). - * Shows an extracted video frame or styled gradient placeholder with a centered play icon. - * Clicking triggers [onClick] to navigate to the full-screen video player screen. + * Shared bounds modifier helper that configures shared transition bounds and overlay clipping + * for video elements transitioning between conversation bubbles and the full-screen player. + */ +@Composable +private fun Modifier.videoSharedBounds( + sharedContentState: SharedTransitionScope.SharedContentState, + shape: Shape, + sharedTransitionScope: SharedTransitionScope? = LocalSharedTransitionScope.current, + animatedVisibilityScope: AnimatedVisibilityScope? = LocalAnimatedVisibilityScope.current, +): Modifier { + return if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + this@videoSharedBounds.sharedBounds( + sharedContentState = sharedContentState, + animatedVisibilityScope = animatedVisibilityScope, + enter = EnterTransition.None, + exit = ExitTransition.None, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + clipInOverlayDuringTransition = OverlayClip(shape), + ) + } + } else { + this + } +} + +/** + * Animates the video container corners between sharp fullscreen corners (0.dp) and the chat + * bubble's rounded corners so both ends of the shared element transition match at every frame. */ @Composable -fun VideoThumbnail(videoUri: String, onClick: () -> Unit, modifier: Modifier = Modifier, shape: Shape = RoundedCornerShape(16.dp)) { +private fun rememberAnimatedVideoShape( + isUserMe: Boolean, + isFullscreenDestination: Boolean, + animatedVisibilityScope: AnimatedVisibilityScope? = LocalAnimatedVisibilityScope.current, + fallbackShape: Shape = RoundedCornerShape(16.dp), +): Shape { + if (animatedVisibilityScope == null) return fallbackShape + val fractionState = animatedVisibilityScope.transition.animateFloat( + label = "videoBubbleCornerFraction", + ) { state -> + if ((state == EnterExitState.Visible) == isFullscreenDestination) 0f else 1f + } + return remember(isUserMe, fractionState) { + object : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline = with(density) { + val fraction = fractionState.value + val major = lerp(0.dp, 20.dp, fraction).toPx() + val minor = lerp(0.dp, 4.dp, fraction).toPx() + val topStart = if (isUserMe) major else minor + val topEnd = if (isUserMe) minor else major + val isLtr = layoutDirection == LayoutDirection.Ltr + Outline.Rounded( + RoundRect( + rect = size.toRect(), + topLeft = CornerRadius(if (isLtr) topStart else topEnd), + topRight = CornerRadius(if (isLtr) topEnd else topStart), + bottomRight = CornerRadius(major), + bottomLeft = CornerRadius(major), + ), + ) + } + } + } +} + +private const val MAX_INITIAL_THUMBNAILS = 20 + +/** + * Reactive in-memory frame cache shared between [VideoThumbnail] and [FullScreenVideoPlayer] + * so the shrinking player surface and target chat card seamlessly display the same video frame + * during transitions, then revert to the original video thumbnail once the transition finishes. + */ +private object VideoFrameCache { + private val initialThumbnails = mutableStateMapOf() + private val initialThumbnailKeys = ArrayDeque() + private val playbackSnapshots = mutableStateMapOf() + private val activePlayerSnapshotReady = mutableStateMapOf() + + operator fun get(cacheKey: String, uriKey: String): Bitmap? = playbackSnapshots[cacheKey] ?: initialThumbnails[uriKey] + + operator fun set(cacheKey: String, bitmap: Bitmap) { + playbackSnapshots[cacheKey] = bitmap + activePlayerSnapshotReady[cacheKey] = true + } + + fun isSnapshotReady(cacheKey: String): Boolean = activePlayerSnapshotReady[cacheKey] ?: true + + /** + * True while a [FullScreenVideoPlayer] for [cacheKey] is alive (it registers a snapshot + * ready/stale entry after its first frame and clears it on dispose). Unlike the shared + * element match state, this is already true in the first composition of the conversation + * during a predictive back gesture. + */ + fun hasActivePlayer(cacheKey: String): Boolean = activePlayerSnapshotReady.containsKey(cacheKey) + + fun markSnapshotStale(cacheKey: String) { + activePlayerSnapshotReady[cacheKey] = false + } + + fun markSnapshotReady(cacheKey: String) { + activePlayerSnapshotReady[cacheKey] = true + } + + fun getInitialThumbnail(uriKey: String): Bitmap? = initialThumbnails[uriKey] + + fun setInitialThumbnail(uriKey: String, bitmap: Bitmap) { + if (initialThumbnails[uriKey] == null) { + if (initialThumbnailKeys.size >= MAX_INITIAL_THUMBNAILS) { + val oldestKey = initialThumbnailKeys.removeFirstOrNull() + if (oldestKey != null) { + initialThumbnails.remove(oldestKey) + } + } + initialThumbnailKeys.addLast(uriKey) + initialThumbnails[uriKey] = bitmap + } + } + + fun clearPlaybackSnapshot(cacheKey: String) { + playbackSnapshots.remove(cacheKey) + activePlayerSnapshotReady.remove(cacheKey) + } +} + +/** + * Remembers and loads a cached bitmap from [VideoFrameCache] or extracts the initial frame + * asynchronously using [extractInitialVideoFrame]. + */ +@Composable +private fun rememberCachedVideoBitmap(resolvedUri: Uri, cacheKey: String): Bitmap? { val context = LocalContext.current - val resolvedUri = remember(videoUri) { resolveVideoUri(videoUri) } + val uriKey = resolvedUri.toString() + val bitmap = VideoFrameCache[cacheKey, uriKey] + LaunchedEffect(resolvedUri) { + if (VideoFrameCache.getInitialThumbnail(uriKey) == null) { + extractInitialVideoFrame(context, resolvedUri)?.let { bmp -> + VideoFrameCache.setInitialThumbnail(uriKey, bmp) + } + } + } + return bitmap +} + +private const val MAX_SNAPSHOT_DIMENSION_PX = 720f + +private fun downscaleBitmapIfNeeded(bitmap: Bitmap): Bitmap { + val maxSrc = maxOf(bitmap.width, bitmap.height) + if (maxSrc <= MAX_SNAPSHOT_DIMENSION_PX) return bitmap + val scale = MAX_SNAPSHOT_DIMENSION_PX / maxSrc + val dstW = (bitmap.width * scale).roundToInt().coerceAtLeast(1) + val dstH = (bitmap.height * scale).roundToInt().coerceAtLeast(1) + return bitmap.scale(dstW, dstH) +} - val thumbnailBitmap by produceState(initialValue = null, resolvedUri) { - withContext(Dispatchers.IO) { - val retriever = MediaMetadataRetriever() +private fun Bitmap.toSizeOrNull(): Size? = if (width > 0 && height > 0) Size(width.toFloat(), height.toFloat()) else null + +private suspend fun extractInitialVideoFrame(context: Context, resolvedUri: Uri): Bitmap? = withContext(Dispatchers.IO) { + val retriever = MediaMetadataRetriever() + try { + val uriString = resolvedUri.toString() + if (uriString.startsWith("asset:///") || uriString.startsWith("file:///android_asset/")) { + val assetPath = uriString + .removePrefix("asset:///") + .removePrefix("file:///android_asset/") + context.assets.openFd(assetPath).use { afd -> + retriever.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) + } + } else { + retriever.setDataSource(context, resolvedUri) + } + val frame = retriever.getFrameAtTime(1_000_000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) + ?: retriever.frameAtTime + frame?.let(::downscaleBitmapIfNeeded) + } catch (_: Throwable) { + null + } finally { + retriever.release() + } +} + +private suspend fun captureSurfaceFrame(surfaceView: SurfaceView): Bitmap? { + if (!surfaceView.isAttachedToWindow || !surfaceView.holder.surface.isValid) return null + val srcW = surfaceView.width + val srcH = surfaceView.height + if (srcW <= 0 || srcH <= 0) return null + + val scale = (MAX_SNAPSHOT_DIMENSION_PX / maxOf(srcW, srcH)).coerceAtMost(1f) + val dstW = (srcW * scale).roundToInt().coerceAtLeast(1) + val dstH = (srcH * scale).roundToInt().coerceAtLeast(1) + + val bitmap = createBitmap(dstW, dstH, Bitmap.Config.ARGB_8888) + return withTimeoutOrNull(45.milliseconds) { + suspendCancellableCoroutine { cont -> try { - val uriString = resolvedUri.toString() - if (uriString.startsWith("asset:///") || uriString.startsWith("file:///android_asset/")) { - val assetPath = uriString - .removePrefix("asset:///") - .removePrefix("file:///android_asset/") - context.assets.openFd(assetPath).use { afd -> - retriever.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) - } - } else { - retriever.setDataSource(context, resolvedUri) + PixelCopy.request( + surfaceView, + bitmap, + { copyResult -> + if (cont.isActive) { + cont.resume(if (copyResult == PixelCopy.SUCCESS) bitmap else null) + } + }, + Handler(Looper.getMainLooper()), + ) + } catch (_: Throwable) { + if (cont.isActive) { + cont.resume(null) } - val bitmap = retriever.getFrameAtTime(1_000_000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) - ?: retriever.frameAtTime - value = bitmap - } catch (e: Throwable) { - // Fallback to stylized dark gradient placeholder on codec error - } finally { - retriever.release() } } } +} + +/** + * Sizes a video container to fit [sourceSize] within incoming constraints during normal/lookahead + * layout, while honoring exact fixed constraints from [SharedTransitionScope.ResizeMode.RemeasureToBounds] + * during shared element approach measurement. + */ +private fun Modifier.fitVideoBounds(sourceSize: Size?): Modifier = layout { measurable, constraints -> + val maxW = constraints.maxWidth + val maxH = constraints.maxHeight + var targetW = maxW + var targetH = maxH + if ( + !(constraints.hasFixedWidth && constraints.hasFixedHeight) && + sourceSize != null && sourceSize.width > 0f && sourceSize.height > 0f && + maxW > 0 && maxH > 0 + ) { + val scale = ContentScale.Fit.computeScaleFactor( + sourceSize, + Size(maxW.toFloat(), maxH.toFloat()), + ) + targetW = (sourceSize.width * scale.scaleX).roundToInt().coerceIn(constraints.minWidth, maxW) + targetH = (sourceSize.height * scale.scaleY).roundToInt().coerceIn(constraints.minHeight, maxH) + } + val placeable = measurable.measure(Constraints.fixed(targetW, targetH)) + layout(targetW, targetH) { + placeable.place(0, 0) + } +} + +/** + * Holds [player] via [retain] so playback state and the decoded surface survive configuration + * changes and shared-element pop transitions without releasing and re-preparing the player. + */ +private class RetainedExoPlayer(val player: ExoPlayer) : RetainObserver { + override fun onRetired() = player.release() + override fun onUnused() = player.release() + override fun onRetained() {} + override fun onEnteredComposition() {} + override fun onExitedComposition() {} +} + +/** + * Lightweight video thumbnail displayed inside scrollable lists (LazyColumn). + * Shows an extracted video frame or styled gradient placeholder with a centered play icon. + * Clicking triggers [onClick] to navigate to the full-screen video player screen. + */ +@Composable +fun VideoThumbnail( + videoUri: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(16.dp), + sharedElementKey: String? = null, + isUserMe: Boolean = true, +) { + val resolvedUri = remember(videoUri) { resolveVideoUri(videoUri) } + val cacheKey = sharedElementKey ?: resolvedUri.toString() + val thumbnailBitmap = rememberCachedVideoBitmap(resolvedUri, cacheKey) + val thumbnailImageBitmap = remember(thumbnailBitmap) { thumbnailBitmap?.asImageBitmap() } + val sharedTransitionScope = LocalSharedTransitionScope.current + val sharedContentState = if (sharedElementKey != null && sharedTransitionScope != null) { + with(sharedTransitionScope) { rememberSharedContentState(key = sharedElementKey) } + } else { + null + } + val effectiveShape = if (sharedContentState != null) { + rememberAnimatedVideoShape( + isUserMe = isUserMe, + isFullscreenDestination = false, + fallbackShape = shape, + ) + } else { + shape + } + // A fullscreen player is alive for this video (predictive back in progress, enter transition, + // or a cancelled gesture). The shared element match is only established after the first + // frame the conversation is composed, so this is the signal that is correct on frame one. + val hasActivePlayer = sharedElementKey != null && VideoFrameCache.hasActivePlayer(cacheKey) + val isThisElementTransitioning = + sharedTransitionScope?.isTransitionActive == true && sharedContentState?.isMatchFound == true + val isHandingOffToPlayer = isThisElementTransitioning || hasActivePlayer + var isVisibleInViewport by remember { mutableStateOf(hasActivePlayer) } + val sharedBoundsModifier = if (sharedContentState != null && (isVisibleInViewport || isHandingOffToPlayer)) { + Modifier.videoSharedBounds(sharedContentState = sharedContentState, shape = effectiveShape) + } else { + Modifier + } + val hideUntilSnapshotReady = + isHandingOffToPlayer && !VideoFrameCache.isSnapshotReady(cacheKey) + // Hide the play overlay the instant the player hands off to this thumbnail so it never flashes + // over the transitioning video frame; only animate it back in once the player is gone. + // The gate is applied directly (not via the animated value) so there is no one-frame delay. + val animatedPlayOverlayAlpha = animateFloatAsState( + targetValue = if (isHandingOffToPlayer) 0f else 1f, + label = "playOverlayAlpha", + ) Box( modifier = modifier - .clip(shape) + .onVisibilityChanged(minFractionVisible = 0.001f) { isVisible -> + isVisibleInViewport = isVisible + } + .then(sharedBoundsModifier) + .clip(effectiveShape) + .graphicsLayer { + alpha = if (hideUntilSnapshotReady) 0f else 1f + } .background( brush = Brush.verticalGradient( colors = listOf( @@ -141,41 +466,52 @@ fun VideoThumbnail(videoUri: String, onClick: () -> Unit, modifier: Modifier = M ), contentAlignment = Alignment.Center, ) { - if (thumbnailBitmap != null) { + if (thumbnailImageBitmap != null) { Image( - bitmap = thumbnailBitmap!!.asImageBitmap(), + bitmap = thumbnailImageBitmap, contentDescription = stringResource(R.string.play_video), contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.25f)), - ) } - // Circular frosted play button + // Dim scrim and circular frosted play button, faded together while a player is active Box( contentAlignment = Alignment.Center, modifier = Modifier - .size(56.dp) - .background( - color = Color.Black.copy(alpha = 0.55f), - shape = CircleShape, - ) - .border( - width = 1.5.dp, - color = Color.White.copy(alpha = 0.6f), - shape = CircleShape, - ), + .fillMaxSize() + .graphicsLayer { + alpha = if (isHandingOffToPlayer) 0f else animatedPlayOverlayAlpha.value + }, ) { - Icon( - painter = painterResource(id = R.drawable.ic_play_arrow), - contentDescription = stringResource(R.string.play_video), - tint = Color.White, - modifier = Modifier.size(32.dp), - ) + if (thumbnailImageBitmap != null) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.25f)), + ) + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(56.dp) + .background( + color = Color.Black.copy(alpha = 0.55f), + shape = CircleShape, + ) + .border( + width = 1.5.dp, + color = Color.White.copy(alpha = 0.6f), + shape = CircleShape, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_play_arrow), + contentDescription = stringResource(R.string.play_video), + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } } } } @@ -184,38 +520,78 @@ fun VideoThumbnail(videoUri: String, onClick: () -> Unit, modifier: Modifier = M * Fullscreen video player screen where the SurfaceView extends across the whole surface of the screen, * and controls are free to extend across the whole screen. */ -@OptIn(UnstableApi::class) +@AndroidxOptIn(UnstableApi::class) @Composable -fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Modifier = Modifier, messageText: String? = null) { +fun FullScreenVideoPlayer( + videoUri: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + messageText: String? = null, + messageId: String? = null, + isUserMe: Boolean = true, + enableBackHandler: Boolean = true, +) { val context = LocalContext.current + val scope = rememberCoroutineScope() val resolvedUri = remember(videoUri) { resolveVideoUri(videoUri) } - - val exoPlayer = remember(context, resolvedUri) { - ExoPlayer.Builder(context).build().apply { - setMediaItem(MediaItem.fromUri(resolvedUri)) - repeatMode = Player.REPEAT_MODE_ALL - playWhenReady = true - prepare() + val cacheKey = videoSharedElementKey(messageId, videoUri) + + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + + // Walk up to the root SeekableTransitionState-backed Transition from NavDisplay: + // when a predictive back gesture is cancelled and snapTo() is called, the child + // EnterExitState transition's updateTarget(Visible) shifts its previous targetState + // (PostExit) into currentState while the root transition is already settled. + val rootTransition = remember(animatedVisibilityScope) { + var root: Transition<*>? = animatedVisibilityScope?.transition + while (root?.parentTransition != null) { + root = root.parentTransition } + root } + + var isDismissing by remember { mutableStateOf(false) } + val inTransition = isDismissing || + (sharedTransitionScope?.isTransitionActive == true) || + (rootTransition != null && rootTransition.currentState != rootTransition.targetState) + + val cachedBitmap = rememberCachedVideoBitmap(resolvedUri, cacheKey) + val cachedImageBitmap = remember(cachedBitmap) { cachedBitmap?.asImageBitmap() } + + val applicationContext = context.applicationContext + val exoPlayer = retain(resolvedUri) { + RetainedExoPlayer( + ExoPlayer.Builder(applicationContext).build().apply { + setMediaItem(MediaItem.fromUri(resolvedUri)) + repeatMode = Player.REPEAT_MODE_ALL + playWhenReady = true + prepare() + }, + ) + }.player + + var hasRenderedFrameOnSurface by remember { mutableStateOf(false) } + DisposableEffect(exoPlayer) { + val listener = object : Player.Listener { + override fun onRenderedFirstFrame() { + hasRenderedFrameOnSurface = true + } + } + exoPlayer.addListener(listener) onDispose { - exoPlayer.release() + exoPlayer.removeListener(listener) } } - LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { - exoPlayer.pause() - } + val activity = LocalActivity.current LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - exoPlayer.pause() + if (activity?.isChangingConfigurations != true) { + exoPlayer.pause() + } } - BackHandler(onBack = onDismiss) - - // Hide system status and navigation bars for immersive edge-to-edge playback - ImmersiveSystemBarsEffect() - val playPauseButtonState = rememberPlayPauseButtonState(exoPlayer) val presentationState = rememberPresentationState(exoPlayer) val muteButtonState = rememberMuteButtonState(exoPlayer) @@ -225,31 +601,55 @@ fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Mod var userInteractedTime by remember { mutableLongStateOf(System.currentTimeMillis()) } var surfaceViewRef by remember { mutableStateOf(null) } - var surfaceCoordinates by remember { mutableStateOf(null) } + val surfaceBoundsRef = remember { Ref() } val blurRegionSpecs = remember { mutableMapOf() } + suspend fun snapshotCurrentFrame() { + val sv = surfaceViewRef?.takeIf { !presentationState.coverSurface && hasRenderedFrameOnSurface } + val bmp = sv?.let { captureSurfaceFrame(it) } + if (bmp != null) { + VideoFrameCache[cacheKey] = bmp + } else { + VideoFrameCache.markSnapshotReady(cacheKey) + } + } + + val handleDismiss: () -> Unit = { + if (!isDismissing) { + isDismissing = true + exoPlayer.pause() + scope.launch { + snapshotCurrentFrame() + onDismiss() + } + } + } + + if (enableBackHandler) { + BackHandler(onBack = handleDismiss) + } + + // Hide system status and navigation bars for immersive edge-to-edge playback + ImmersiveSystemBarsEffect() + fun updateBlurRegions() { val sv = surfaceViewRef ?: return - if (!controlsVisible) { + if (!controlsVisible || inTransition) { SurfaceViewBlurHelper.clearBlurRegions(sv) return } - val coords = surfaceCoordinates - if (coords == null || !coords.isAttached) { - return - } - val surfacePos = coords.positionInWindow() - val surfaceW = coords.size.width.toFloat() - val surfaceH = coords.size.height.toFloat() + val surfaceBounds = surfaceBoundsRef.value ?: return + val surfaceW = surfaceBounds.width.toFloat() + val surfaceH = surfaceBounds.height.toFloat() if (surfaceW <= 0f || surfaceH <= 0f) return val resolvedRegions = blurRegionSpecs.values.mapNotNull { spec -> val box = spec.boundsInWindow if (box.width > 0 && box.height > 0) { - val boxLeft = box.left.toFloat() - surfacePos.x - val boxTop = box.top.toFloat() - surfacePos.y - val boxRight = box.right.toFloat() - surfacePos.x - val boxBottom = box.bottom.toFloat() - surfacePos.y + val boxLeft = box.left.toFloat() - surfaceBounds.left.toFloat() + val boxTop = box.top.toFloat() - surfaceBounds.top.toFloat() + val boxRight = box.right.toFloat() - surfaceBounds.left.toFloat() + val boxBottom = box.bottom.toFloat() - surfaceBounds.top.toFloat() val left = boxLeft.coerceIn(0f, surfaceW) val top = boxTop.coerceIn(0f, surfaceH) @@ -293,22 +693,60 @@ fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Mod SurfaceViewBlurHelper.applyBlurRegions(sv, resolvedRegions) } - DisposableEffect(Unit) { + // The SurfaceView itself is detached from the player in AndroidView's onRelease. + DisposableEffect(cacheKey) { onDispose { - surfaceViewRef?.let { sv -> - exoPlayer.clearVideoSurface() - SurfaceViewBlurHelper.clearBlurRegions(sv) + if (activity?.isChangingConfigurations != true) { + VideoFrameCache.clearPlaybackSnapshot(cacheKey) } - surfaceViewRef = null } } - LaunchedEffect(controlsVisible) { - updateBlurRegions() + // Pause playback as soon as a predictive back gesture (DeferredTransition) or exit transition begins, + // and resume playback if the predictive back gesture is cancelled. + var shouldResumeAfterGesture by remember { mutableStateOf(false) } + val isEnterComplete = animatedVisibilityScope?.transition?.currentState == EnterExitState.Visible + val isExitingOrPredictiveBack = isDismissing || + animatedVisibilityScope?.transition?.targetState == EnterExitState.PostExit || + (isEnterComplete && inTransition) + + val isSnapshotReady = VideoFrameCache.isSnapshotReady(cacheKey) + val waitingForExitSnapshot = + isExitingOrPredictiveBack && hasRenderedFrameOnSurface && !isSnapshotReady + val showSnapshotOverlay = + (inTransition && !waitingForExitSnapshot) || + presentationState.coverSurface || + !hasRenderedFrameOnSurface + + LaunchedEffect(isExitingOrPredictiveBack) { + if (isExitingOrPredictiveBack) { + if (exoPlayer.playWhenReady) { + shouldResumeAfterGesture = true + exoPlayer.pause() + } + // Re-read rather than capturing isSnapshotReady: the composition-time value may be + // stale by the time this effect runs. + if (!isDismissing && !VideoFrameCache.isSnapshotReady(cacheKey)) { + snapshotCurrentFrame() + } + } else if (shouldResumeAfterGesture && !isDismissing) { + shouldResumeAfterGesture = false + VideoFrameCache.markSnapshotStale(cacheKey) + exoPlayer.play() + } } - // Auto-hide controls after playback inactivity val isPlaying = !playPauseButtonState.showPlay + LaunchedEffect(isPlaying, hasRenderedFrameOnSurface, presentationState.coverSurface, inTransition) { + if (inTransition || presentationState.coverSurface || !hasRenderedFrameOnSurface) return@LaunchedEffect + if (isPlaying) { + VideoFrameCache.markSnapshotStale(cacheKey) + } else { + snapshotCurrentFrame() + } + } + + // Auto-hide controls after playback inactivity LaunchedEffect(controlsVisible, isPlaying, userInteractedTime) { if (controlsVisible && isPlaying) { delay(5000.milliseconds) @@ -316,6 +754,21 @@ fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Mod } } + val effectiveVideoSize = presentationState.videoSizeDp ?: cachedBitmap?.toSizeOrNull() + + val animatedVideoShape = rememberAnimatedVideoShape( + isUserMe = isUserMe, + isFullscreenDestination = true, + fallbackShape = RoundedCornerShape(0.dp), + ) + + val sharedSurfaceModifier = sharedTransitionScope?.let { transitionScope -> + Modifier.videoSharedBounds( + sharedContentState = with(transitionScope) { rememberSharedContentState(key = cacheKey) }, + shape = animatedVideoShape, + ) + } ?: Modifier + Box( modifier = modifier .fillMaxSize() @@ -329,11 +782,13 @@ fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Mod }, contentAlignment = Alignment.Center, ) { - // SurfaceView sized to fit video aspect ratio using Media3's resizeWithContentScale + // Keep SurfaceView outside sharedBounds so SharedTransitionLayout never resizes or detaches + // the underlying hardware surface during transitions or cancelled predictive back gestures. AndroidView( factory = { ctx -> SurfaceView(ctx).apply { surfaceViewRef = this + alpha = if (showSnapshotOverlay) 0f else 1f exoPlayer.setVideoSurfaceView(this) holder.addCallback( object : SurfaceHolder.Callback { @@ -346,6 +801,7 @@ fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Mod } override fun surfaceDestroyed(holder: SurfaceHolder) { + hasRenderedFrameOnSurface = false SurfaceViewBlurHelper.clearBlurRegions(this@apply) } }, @@ -353,13 +809,13 @@ fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Mod } }, modifier = Modifier - .resizeWithContentScale(ContentScale.Fit, presentationState.videoSizeDp) - .onGloballyPositioned { coords -> - surfaceCoordinates = coords + .resizeWithContentScale(ContentScale.Fit, effectiveVideoSize) + .onLayoutRectChanged(throttleMillis = 0, debounceMillis = 0) { bounds -> + surfaceBoundsRef.value = bounds.boundsInWindow updateBlurRegions() }, update = { sv -> - surfaceViewRef = sv + sv.alpha = if (showSnapshotOverlay) 0f else 1f updateBlurRegions() }, onRelease = { sv -> @@ -369,18 +825,39 @@ fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Mod }, ) - // Shutter while video is loading - if (presentationState.coverSurface) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black), - ) + // Shared element container that smoothly morphs the captured video frame between the + // fullscreen video bounds and the chat bubble card during enter/exit/predictive back. + // Always retain children and control visibility via graphicsLayer alpha so Lookahead and + // idle fullscreen bounds always match the exact fitted video dimensions (never 0x0). + Box( + modifier = Modifier + .then(sharedSurfaceModifier) + .fitVideoBounds(effectiveVideoSize) + .clip(animatedVideoShape) + .graphicsLayer { + alpha = if (showSnapshotOverlay) 1f else 0f + }, + contentAlignment = Alignment.Center, + ) { + if (cachedImageBitmap != null) { + Image( + bitmap = cachedImageBitmap, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + ) + } } // Overlay controls allowed to extend to the whole surface of the screen AnimatedVisibility( - visible = controlsVisible, + visible = controlsVisible && !inTransition, enter = fadeIn(), exit = fadeOut(), modifier = Modifier.fillMaxSize(), @@ -397,10 +874,11 @@ fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Mod onSeekTo = { progressFraction -> val duration = progressState.durationMs if (duration > 0) { + VideoFrameCache.markSnapshotStale(cacheKey) exoPlayer.seekTo((progressFraction * duration).toLong()) } }, - onToggleFullscreen = onDismiss, + onToggleFullscreen = handleDismiss, onUpdateBlurRegions = { updateBlurRegions() }, messageText = messageText, modifier = Modifier.fillMaxSize(), diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayerControls.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayerControls.kt index 69cc42f603..befe52337e 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayerControls.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayerControls.kt @@ -85,7 +85,13 @@ fun VideoPlayerOverlayControls( messageText: String? = null, ) { Box( - modifier = modifier.fillMaxSize(), + modifier = modifier + .fillMaxSize() + .then( + if (isFullscreen) + Modifier.windowInsetsPadding(WindowInsets.safeContent) + else Modifier, + ), ) { // Top-End Exit Fullscreen Button (fullscreen only) if (isFullscreen) { @@ -134,13 +140,7 @@ fun VideoPlayerOverlayControls( onUpdateBlurRegions() } }, - modifier = Modifier - .align(Alignment.CenterEnd) - .then( - if (isFullscreen) - Modifier.windowInsetsPadding(WindowInsets.safeContent) - else Modifier, - ), + modifier = Modifier.align(Alignment.CenterEnd), ) } @@ -161,11 +161,7 @@ fun VideoPlayerOverlayControls( blurRegionSpecs.remove(id) onUpdateBlurRegions() }, - modifier = Modifier.align(Alignment.BottomCenter).then( - if (isFullscreen) - Modifier.windowInsetsPadding(WindowInsets.safeContent) - else Modifier, - ), + modifier = Modifier.align(Alignment.BottomCenter), ) } } diff --git a/Jetchat/app/src/main/res/values/strings.xml b/Jetchat/app/src/main/res/values/strings.xml index 4fbd0aa1f9..a6502be0ad 100644 --- a/Jetchat/app/src/main/res/values/strings.xml +++ b/Jetchat/app/src/main/res/values/strings.xml @@ -30,13 +30,11 @@ Conversations Chat list Show chat list - Select a chat to start messaging No messages yet Profile Jump to bottom Send me - 8:30 PM %d members Send a message Emojis