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
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<Home> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
HomeScreen(navController = navController)
WithAnimatedScope {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
HomeScreen(navController = navController)
}
}
}
composable<Library> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
LibraryScreen(navController = navController)
WithAnimatedScope {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
LibraryScreen(navController = navController)
}
}
}
composable<Search> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
SearchScreen(navController = navController)
WithAnimatedScope {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
SearchScreen(navController = navController)
}
}
}
composable<Discover> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
DiscoverScreen(navController = navController)
WithAnimatedScope {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
DiscoverScreen(navController = navController)
}
}
}
composable<Playlists> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
PlaylistsListScreen(navController = navController)
WithAnimatedScope {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
PlaylistsListScreen(navController = navController)
}
}
}
composable<Settings> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
SettingsScreen(navController = navController)
WithAnimatedScope {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
SettingsScreen(navController = navController)
}
}
}
composable<Admin> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
AdminLandingScreen(navController = navController)
WithAnimatedScope {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
AdminLandingScreen(navController = navController)
}
}
}
composable<Requests> {
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<AlbumDetail> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
AlbumDetailScreen(navController = navController)
WithAnimatedScope {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
AlbumDetailScreen(navController = navController)
}
}
}
composable<ArtistDetail> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
ArtistDetailScreen(navController = navController)
WithAnimatedScope {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
ArtistDetailScreen(navController = navController)
}
}
}
composable<PlaylistDetail> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
PlaylistDetailScreen(navController = navController)
WithAnimatedScope {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
PlaylistDetailScreen(navController = navController)
}
}
}
composable<AdminRequests> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
AdminRequestsScreen(navController = navController)
WithAnimatedScope {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
AdminRequestsScreen(navController = navController)
}
}
}
composable<AdminQuarantine> {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
AdminQuarantineScreen(navController = navController)
WithAnimatedScope {
ShellScaffold(navController = navController, onExpandPlayer = expandPlayer) {
AdminQuarantineScreen(navController = navController)
}
}
}
composable<AdminUsers> {
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<Queue> { QueueScreen(navController = navController) }
composable<ServerUrl> { ServerUrlScreen(navController = navController) }
@@ -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),
@@ -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)