feat(android): MiniPlayer → NowPlaying cover Hero transition (audit v2 #17, slice 2)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 15:39:11 -04:00
parent 01fdd2f380
commit 6a932405f8
4 changed files with 161 additions and 39 deletions
@@ -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<X>`
* 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<SharedTransitionScope?> { null }
val LocalAnimatedContentScope = staticCompositionLocalOf<AnimatedContentScope?> { null }
/** Shared-element key for the MiniPlayer → NowPlaying cover hero. */
const val HERO_KEY_NOW_PLAYING_COVER = "now-playing-cover"
@@ -1,9 +1,13 @@
package com.fabledsword.minstrel.nav 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.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController 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 * Per-route transition: only NowPlaying gets the modal slide-up; the
* rest use the default fade. * rest use the default fade.
*/ */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun MinstrelNavGraph( fun MinstrelNavGraph(
navController: NavHostController, navController: NavHostController,
@@ -44,14 +49,18 @@ fun MinstrelNavGraph(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val expandPlayer = { navController.navigate(NowPlaying) } val expandPlayer = { navController.navigate(NowPlaying) }
NavHost( SharedTransitionLayout(modifier = modifier.fillMaxSize()) {
navController = navController, CompositionLocalProvider(LocalSharedTransitionScope provides this) {
startDestination = startDestination, NavHost(
modifier = modifier.fillMaxSize(), navController = navController,
) { startDestination = startDestination,
inShellTopLevel(navController, expandPlayer) modifier = Modifier.fillMaxSize(),
inShellDetail(navController, expandPlayer) ) {
outsideShell(navController) inShellTopLevel(navController, expandPlayer)
inShellDetail(navController, expandPlayer)
outsideShell(navController)
}
}
} }
} }
@@ -60,79 +69,125 @@ private fun NavGraphBuilder.inShellTopLevel(
expandPlayer: () -> Unit, expandPlayer: () -> Unit,
) { ) {
composable<Home> { composable<Home> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
HomeScreen(navController = navController) ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
HomeScreen(navController = navController)
}
} }
} }
composable<Library> { composable<Library> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
LibraryScreen(navController = navController) ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
LibraryScreen(navController = navController)
}
} }
} }
composable<Search> { composable<Search> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
SearchScreen(navController = navController) ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
SearchScreen(navController = navController)
}
} }
} }
composable<Discover> { composable<Discover> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
DiscoverScreen(navController = navController) ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
DiscoverScreen(navController = navController)
}
} }
} }
composable<Playlists> { composable<Playlists> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
PlaylistsListScreen(navController = navController) ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
PlaylistsListScreen(navController = navController)
}
} }
} }
composable<Settings> { composable<Settings> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
SettingsScreen(navController = navController) ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
SettingsScreen(navController = navController)
}
} }
} }
composable<Admin> { composable<Admin> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
AdminLandingScreen(navController = navController) ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
AdminLandingScreen(navController = navController)
}
} }
} }
composable<Requests> { composable<Requests> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
RequestsScreen(navController = navController) 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( private fun NavGraphBuilder.inShellDetail(
navController: NavHostController, navController: NavHostController,
expandPlayer: () -> Unit, expandPlayer: () -> Unit,
) { ) {
composable<AlbumDetail> { composable<AlbumDetail> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
AlbumDetailScreen(navController = navController) ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
AlbumDetailScreen(navController = navController)
}
} }
} }
composable<ArtistDetail> { composable<ArtistDetail> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
ArtistDetailScreen(navController = navController) ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
ArtistDetailScreen(navController = navController)
}
} }
} }
composable<PlaylistDetail> { composable<PlaylistDetail> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
PlaylistDetailScreen(navController = navController) ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
PlaylistDetailScreen(navController = navController)
}
} }
} }
composable<AdminRequests> { composable<AdminRequests> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
AdminRequestsScreen(navController = navController) ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
AdminRequestsScreen(navController = navController)
}
} }
} }
composable<AdminQuarantine> { composable<AdminQuarantine> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
AdminQuarantineScreen(navController = navController) ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
AdminQuarantineScreen(navController = navController)
}
} }
} }
composable<AdminUsers> { composable<AdminUsers> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) { WithAnimatedScope {
AdminUsersScreen(navController = navController) ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
AdminUsersScreen(navController = navController)
}
} }
} }
} }
@@ -146,7 +201,9 @@ private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
popEnterTransition = { slideInVertically(initialOffsetY = { it }) }, popEnterTransition = { slideInVertically(initialOffsetY = { it }) },
popExitTransition = { slideOutVertically(targetOffsetY = { it }) }, popExitTransition = { slideOutVertically(targetOffsetY = { it }) },
) { ) {
NowPlayingScreen(navController = navController) WithAnimatedScope {
NowPlayingScreen(navController = navController)
}
} }
composable<Queue> { QueueScreen(navController = navController) } composable<Queue> { QueueScreen(navController = navController) }
composable<ServerUrl> { ServerUrlScreen(navController = navController) } composable<ServerUrl> { ServerUrlScreen(navController = navController) }
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.player.ui package com.fabledsword.minstrel.player.ui
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box 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.SkipBack
import com.composables.icons.lucide.SkipForward import com.composables.icons.lucide.SkipForward
import com.fabledsword.minstrel.models.TrackRef 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.LikeButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel 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 COVER_SIZE_DP = 48
private const val SCRUBBER_ROW_HEIGHT_DP = 4 private const val SCRUBBER_ROW_HEIGHT_DP = 4
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun MiniCover(coverUrl: String, contentDescription: String) { 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( Box(
modifier = Modifier modifier = heroModifier
.size(COVER_SIZE_DP.dp) .size(COVER_SIZE_DP.dp)
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)) .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm))
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant),
@@ -2,6 +2,7 @@
package com.fabledsword.minstrel.player.ui package com.fabledsword.minstrel.player.ui
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.player.RepeatMode
import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail 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.nav.Queue
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
@@ -299,10 +303,25 @@ private fun ViewQueueButton(onClick: () -> Unit) {
} }
} }
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun NowPlayingCover(coverUrl: String, contentDescription: String) { 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( Box(
modifier = Modifier modifier = heroModifier
.widthIn(max = COVER_MAX_WIDTH_DP.dp) .widthIn(max = COVER_MAX_WIDTH_DP.dp)
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f) .aspectRatio(1f)