v2026.05.19.3 — playback stall resilience + legacy home cleanup #56

Merged
bvandeusen merged 262 commits from dev into main 2026-05-19 15:48:08 -04:00
4 changed files with 161 additions and 39 deletions
Showing only changes of commit 6a932405f8 - Show all commits
@@ -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)