From 6a932405f8d3c5b1cd2aeb115cd39529822de3e2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 15:39:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20MiniPlayer=20=E2=86=92=20NowPl?= =?UTF-8?q?aying=20cover=20Hero=20transition=20(audit=20v2=20#17,=20slice?= =?UTF-8?q?=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the NavHost in a SharedTransitionLayout and applies Modifier.sharedElement to both the MiniPlayer cover and the NowPlayingCover, keyed on a single HERO_KEY_NOW_PLAYING_COVER. Tapping the MiniPlayer cover now morphs into the full NowPlaying cover instead of cross-fading. Plumbing: * HeroScopes.kt — staticCompositionLocalOf holders for both the SharedTransitionScope (set once at NavHost root) and the AnimatedContentScope (re-set per composable<>, since each route has its own). * MinstrelNavGraph.kt — private WithAnimatedScope helper wraps each composable<> lambda so its AnimatedContentScope reaches the nested cover. * MiniPlayer.kt MiniCover + NowPlayingScreen.kt NowPlayingCover each read both scopes and prepend Modifier.sharedElement when both are present; degrade gracefully (no hero, still renders) outside the layout for previews / tests. Cover preload still pending — that's slice 3 if you want it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fabledsword/minstrel/nav/HeroScopes.kt | 27 ++++ .../minstrel/nav/MinstrelNavGraph.kt | 131 +++++++++++++----- .../minstrel/player/ui/MiniPlayer.kt | 21 ++- .../minstrel/player/ui/NowPlayingScreen.kt | 21 ++- 4 files changed, 161 insertions(+), 39 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/nav/HeroScopes.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/HeroScopes.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/HeroScopes.kt new file mode 100644 index 00000000..db2b310f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/HeroScopes.kt @@ -0,0 +1,27 @@ +package com.fabledsword.minstrel.nav + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Scope hand-off for shared-element (Hero) transitions between + * MiniPlayer and NowPlaying covers. + * + * [LocalSharedTransitionScope] is provided once by the + * `SharedTransitionLayout` wrapping the NavHost. + * [LocalAnimatedContentScope] is re-provided per `composable` + * destination since each route has its own AnimatedContentScope. + * + * Both are nullable so widgets degrade gracefully (no hero, but + * still render) outside a SharedTransitionLayout — useful in + * previews and tests. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalSharedTransitionScope = staticCompositionLocalOf { null } + +val LocalAnimatedContentScope = staticCompositionLocalOf { null } + +/** Shared-element key for the MiniPlayer → NowPlaying cover hero. */ +const val HERO_KEY_NOW_PLAYING_COVER = "now-playing-cover" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index 5baafa16..3fe330d4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -1,9 +1,13 @@ package com.fabledsword.minstrel.nav +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController @@ -37,6 +41,7 @@ import com.fabledsword.minstrel.shared.widgets.ShellScaffold * Per-route transition: only NowPlaying gets the modal slide-up; the * rest use the default fade. */ +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun MinstrelNavGraph( navController: NavHostController, @@ -44,14 +49,18 @@ fun MinstrelNavGraph( modifier: Modifier = Modifier, ) { val expandPlayer = { navController.navigate(NowPlaying) } - NavHost( - navController = navController, - startDestination = startDestination, - modifier = modifier.fillMaxSize(), - ) { - inShellTopLevel(navController, expandPlayer) - inShellDetail(navController, expandPlayer) - outsideShell(navController) + SharedTransitionLayout(modifier = modifier.fillMaxSize()) { + CompositionLocalProvider(LocalSharedTransitionScope provides this) { + NavHost( + navController = navController, + startDestination = startDestination, + modifier = Modifier.fillMaxSize(), + ) { + inShellTopLevel(navController, expandPlayer) + inShellDetail(navController, expandPlayer) + outsideShell(navController) + } + } } } @@ -60,79 +69,125 @@ private fun NavGraphBuilder.inShellTopLevel( expandPlayer: () -> Unit, ) { composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - HomeScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + HomeScreen(navController = navController) + } } } composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - LibraryScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + LibraryScreen(navController = navController) + } } } composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - SearchScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + SearchScreen(navController = navController) + } } } composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - DiscoverScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + DiscoverScreen(navController = navController) + } } } composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - PlaylistsListScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + PlaylistsListScreen(navController = navController) + } } } composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - SettingsScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + SettingsScreen(navController = navController) + } } } composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - AdminLandingScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + AdminLandingScreen(navController = navController) + } } } composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - RequestsScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + RequestsScreen(navController = navController) + } } } } +/** + * Wraps content with [LocalAnimatedContentScope] sourced from the + * enclosing `composable<>` lambda's [AnimatedContentScope] receiver + * so the MiniPlayer / NowPlaying covers can apply + * `Modifier.sharedElement(...)` for the Hero transition. + * + * Must be invoked from inside a `composable<>` lambda — its `this` + * is the [AnimatedContentScope]. + */ +@Composable +private fun AnimatedContentScope.WithAnimatedScope( + content: @Composable () -> Unit, +) { + CompositionLocalProvider(LocalAnimatedContentScope provides this) { + content() + } +} + private fun NavGraphBuilder.inShellDetail( navController: NavHostController, expandPlayer: () -> Unit, ) { composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - AlbumDetailScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + AlbumDetailScreen(navController = navController) + } } } composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - ArtistDetailScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + ArtistDetailScreen(navController = navController) + } } } composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - PlaylistDetailScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + PlaylistDetailScreen(navController = navController) + } } } composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - AdminRequestsScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + AdminRequestsScreen(navController = navController) + } } } composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - AdminQuarantineScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + AdminQuarantineScreen(navController = navController) + } } } composable { - ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { - AdminUsersScreen(navController = navController) + WithAnimatedScope { + ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { + AdminUsersScreen(navController = navController) + } } } } @@ -146,7 +201,9 @@ private fun NavGraphBuilder.outsideShell(navController: NavHostController) { popEnterTransition = { slideInVertically(initialOffsetY = { it }) }, popExitTransition = { slideOutVertically(targetOffsetY = { it }) }, ) { - NowPlayingScreen(navController = navController) + WithAnimatedScope { + NowPlayingScreen(navController = navController) + } } composable { QueueScreen(navController = navController) } composable { ServerUrlScreen(navController = navController) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt index 4f524a94..e5733ff3 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt @@ -1,5 +1,6 @@ package com.fabledsword.minstrel.player.ui +import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -37,6 +38,9 @@ import com.composables.icons.lucide.Play import com.composables.icons.lucide.SkipBack import com.composables.icons.lucide.SkipForward import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER +import com.fabledsword.minstrel.nav.LocalAnimatedContentScope +import com.fabledsword.minstrel.nav.LocalSharedTransitionScope import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel @@ -47,10 +51,25 @@ private const val MINI_BAR_TONAL_ELEVATION_DP = 4 private const val COVER_SIZE_DP = 48 private const val SCRUBBER_ROW_HEIGHT_DP = 4 +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun MiniCover(coverUrl: String, contentDescription: String) { + val sharedScope = LocalSharedTransitionScope.current + val animatedScope = LocalAnimatedContentScope.current + val heroModifier = if (sharedScope != null && animatedScope != null) { + with(sharedScope) { + Modifier.sharedElement( + sharedContentState = rememberSharedContentState( + key = HERO_KEY_NOW_PLAYING_COVER, + ), + animatedVisibilityScope = animatedScope, + ) + } + } else { + Modifier + } Box( - modifier = Modifier + modifier = heroModifier .size(COVER_SIZE_DP.dp) .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)) .background(MaterialTheme.colorScheme.surfaceVariant), diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 13a05c3a..960cac89 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -2,6 +2,7 @@ package com.fabledsword.minstrel.player.ui +import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -61,6 +62,9 @@ import com.composables.icons.lucide.SkipForward import com.fabledsword.minstrel.player.RepeatMode import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER +import com.fabledsword.minstrel.nav.LocalAnimatedContentScope +import com.fabledsword.minstrel.nav.LocalSharedTransitionScope import com.fabledsword.minstrel.nav.Queue import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel @@ -299,10 +303,25 @@ private fun ViewQueueButton(onClick: () -> Unit) { } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun NowPlayingCover(coverUrl: String, contentDescription: String) { + val sharedScope = LocalSharedTransitionScope.current + val animatedScope = LocalAnimatedContentScope.current + val heroModifier = if (sharedScope != null && animatedScope != null) { + with(sharedScope) { + Modifier.sharedElement( + sharedContentState = rememberSharedContentState( + key = HERO_KEY_NOW_PLAYING_COVER, + ), + animatedVisibilityScope = animatedScope, + ) + } + } else { + Modifier + } Box( - modifier = Modifier + modifier = heroModifier .widthIn(max = COVER_MAX_WIDTH_DP.dp) .fillMaxWidth() .aspectRatio(1f)