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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user