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