From 208a7d056b7f0182cc188280caa5550bd861d194 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 13:33:11 -0400 Subject: [PATCH] feat(android): detail-screen seed extras (audit v2 #11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppBar headers on AlbumDetail / ArtistDetail / PlaylistDetail now render the real title during the Loading state instead of the "Album" / "Artist" / "Playlist" placeholder. Mirrors Flutter's go_router extra: seed pattern. Implementation: * DetailSeedCache — process-singleton with three LRU buckets (albums / artists / playlists, 32 entries each). Hilt-injected into MainActivity and exposed via LocalDetailSeedCache so any composable can stash before navigating. * AlbumCard / ArtistCard / PlaylistCard stash their Ref on click; every existing nav call site automatically benefits — no callback shape change needed. * Each detail VM peeks the cache in refresh() and emits Loading(seed) so the AppBar reads from the carried seed. State carries across pull-to-refresh too (refresh keeps the seed of the previous Success / Loading rather than blanking to "Album"). * AlbumDetailUiState.Loading, ArtistDetailUiState.Loading, and PlaylistDetailUiState.Loading evolved from data object to data class Loading(val seed: T?) — backwards-compatible pattern-matching with is checks. Track-level "Go to album" / "Go to artist" call sites (TrackRow and NowPlaying TrackActions) only have an id, no Ref, so the AppBar still shows the placeholder for those — matches Flutter. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/fabledsword/minstrel/MainActivity.kt | 43 +++++++++------- .../minstrel/library/ui/AlbumDetailScreen.kt | 5 +- .../library/ui/AlbumDetailViewModel.kt | 15 ++++-- .../minstrel/library/ui/ArtistDetailScreen.kt | 5 +- .../library/ui/ArtistDetailViewModel.kt | 15 ++++-- .../minstrel/library/widgets/AlbumCard.kt | 11 +++- .../minstrel/library/widgets/ArtistCard.kt | 11 +++- .../minstrel/nav/DetailSeedCache.kt | 51 +++++++++++++++++++ .../playlists/ui/PlaylistDetailScreen.kt | 21 ++++++-- .../playlists/widgets/PlaylistCard.kt | 7 ++- 10 files changed, 147 insertions(+), 37 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/nav/DetailSeedCache.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt index 9e5e6555..a84bc1d8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt @@ -10,6 +10,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -17,43 +18,51 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.rememberNavController import com.fabledsword.minstrel.auth.ui.AuthGateViewModel +import com.fabledsword.minstrel.nav.DetailSeedCache +import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.MinstrelNavGraph import com.fabledsword.minstrel.theme.MinstrelTheme import com.fabledsword.minstrel.theme.ThemePreferenceViewModel import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject lateinit var seedCache: DetailSeedCache + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContent { App() } + setContent { App(seedCache = seedCache) } } } @Composable private fun App( + seedCache: DetailSeedCache, themeVm: ThemePreferenceViewModel = hiltViewModel(), gate: AuthGateViewModel = hiltViewModel(), ) { val theme by themeVm.themeMode.collectAsStateWithLifecycle() MinstrelTheme(darkOverride = theme.toDarkOverride()) { - val startDestination by gate.startDestination.collectAsStateWithLifecycle() - val resolved = startDestination - if (resolved == null) { - BootSplash() - } else { - // No bottom nav, no drawer — navigation is per-screen via - // the AppBar actions row + kebab (`MainAppBarActions`). The - // MiniPlayer is included by each in-shell route's - // `ShellScaffold` wrap; full-screen routes (NowPlaying / - // Queue / unauthenticated) bypass the shell entirely. - val navController = rememberNavController() - MinstrelNavGraph( - navController = navController, - startDestination = resolved, - modifier = Modifier.fillMaxSize(), - ) + CompositionLocalProvider(LocalDetailSeedCache provides seedCache) { + val startDestination by gate.startDestination.collectAsStateWithLifecycle() + val resolved = startDestination + if (resolved == null) { + BootSplash() + } else { + // No bottom nav, no drawer — navigation is per-screen via + // the AppBar actions row + kebab (`MainAppBarActions`). The + // MiniPlayer is included by each in-shell route's + // `ShellScaffold` wrap; full-screen routes (NowPlaying / + // Queue / unauthenticated) bypass the shell entirely. + val navController = rememberNavController() + MinstrelNavGraph( + navController = navController, + startDestination = resolved, + modifier = Modifier.fillMaxSize(), + ) + } } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt index 0eb18fad..a15b6929 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt @@ -95,7 +95,7 @@ fun AlbumDetailScreen( modifier = Modifier.fillMaxSize().padding(inner), ) { when (val s = state) { - AlbumDetailUiState.Loading -> LoadingCentered() + is AlbumDetailUiState.Loading -> LoadingCentered() is AlbumDetailUiState.Error -> EmptyState( title = "Couldn't load album", body = s.message, @@ -125,7 +125,8 @@ fun AlbumDetailScreen( private fun titleFor(state: AlbumDetailUiState): String = when (state) { is AlbumDetailUiState.Success -> state.detail.album.title - else -> "Album" + is AlbumDetailUiState.Loading -> state.seed?.title ?: "Album" + is AlbumDetailUiState.Error -> "Album" } @Composable diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt index 9cd0d52f..c7daaf43 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt @@ -7,7 +7,9 @@ import androidx.navigation.toRoute import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.models.AlbumDetailRef +import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.DetailSeedCache import com.fabledsword.minstrel.player.PlayerController import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -23,7 +25,7 @@ import javax.inject.Inject private const val SHARE_STOP_TIMEOUT_MS = 5_000L sealed interface AlbumDetailUiState { - data object Loading : AlbumDetailUiState + data class Loading(val seed: AlbumRef? = null) : AlbumDetailUiState data class Success(val detail: AlbumDetailRef) : AlbumDetailUiState data class Error(val message: String) : AlbumDetailUiState } @@ -33,13 +35,15 @@ class AlbumDetailViewModel @Inject constructor( private val repository: LibraryRepository, private val likes: LikesRepository, private val player: PlayerController, + private val seedCache: DetailSeedCache, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val route: AlbumDetail = savedStateHandle.toRoute() val albumId: String = route.id - private val internal = MutableStateFlow(AlbumDetailUiState.Loading) + private val internal = + MutableStateFlow(AlbumDetailUiState.Loading()) val uiState: StateFlow = internal.asStateFlow() val albumLiked: StateFlow = @@ -64,7 +68,12 @@ class AlbumDetailViewModel @Inject constructor( } fun refresh(): Job = viewModelScope.launch { - internal.value = AlbumDetailUiState.Loading + val carried = when (val cur = internal.value) { + is AlbumDetailUiState.Loading -> cur.seed + is AlbumDetailUiState.Success -> cur.detail.album + is AlbumDetailUiState.Error -> null + } + internal.value = AlbumDetailUiState.Loading(carried ?: seedCache.peekAlbum(albumId)) try { val detail = repository.refreshAlbumDetail(albumId) internal.value = AlbumDetailUiState.Success(detail) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt index e2b02ad1..b1c78e3e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt @@ -76,7 +76,7 @@ fun ArtistDetailScreen( modifier = Modifier.fillMaxSize().padding(inner), ) { when (val s = state) { - ArtistDetailUiState.Loading -> LoadingCentered() + is ArtistDetailUiState.Loading -> LoadingCentered() is ArtistDetailUiState.Error -> EmptyState( title = "Couldn't load artist", body = s.message, @@ -98,7 +98,8 @@ fun ArtistDetailScreen( private fun titleFor(state: ArtistDetailUiState): String = when (state) { is ArtistDetailUiState.Success -> state.detail.artist.name - else -> "Artist" + is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist" + is ArtistDetailUiState.Error -> "Artist" } @Composable diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt index 86363455..ef1a41aa 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt @@ -7,7 +7,9 @@ import androidx.navigation.toRoute import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.models.ArtistDetailRef +import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.nav.DetailSeedCache import com.fabledsword.minstrel.player.PlayerController import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -22,7 +24,7 @@ import javax.inject.Inject private const val SHARE_STOP_TIMEOUT_MS = 5_000L sealed interface ArtistDetailUiState { - data object Loading : ArtistDetailUiState + data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState data class Error(val message: String) : ArtistDetailUiState } @@ -32,13 +34,15 @@ class ArtistDetailViewModel @Inject constructor( private val repository: LibraryRepository, private val likes: LikesRepository, private val player: PlayerController, + private val seedCache: DetailSeedCache, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val route: ArtistDetail = savedStateHandle.toRoute() val artistId: String = route.id - private val internal = MutableStateFlow(ArtistDetailUiState.Loading) + private val internal = + MutableStateFlow(ArtistDetailUiState.Loading()) val uiState: StateFlow = internal.asStateFlow() val artistLiked: StateFlow = @@ -61,7 +65,12 @@ class ArtistDetailViewModel @Inject constructor( } fun refresh(): Job = viewModelScope.launch { - internal.value = ArtistDetailUiState.Loading + val carried = when (val cur = internal.value) { + is ArtistDetailUiState.Loading -> cur.seed + is ArtistDetailUiState.Success -> cur.detail.artist + is ArtistDetailUiState.Error -> null + } + internal.value = ArtistDetailUiState.Loading(carried ?: seedCache.peekArtist(artistId)) try { val detail = repository.refreshArtistDetail(artistId) internal.value = ArtistDetailUiState.Success(detail) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt index 475921e4..0eeffa79 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt @@ -27,12 +27,15 @@ import com.composables.icons.lucide.Disc3 import com.composables.icons.lucide.Lucide import androidx.compose.material3.Icon import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.theme.FabledSwordFlatTokens /** * One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp * square cover). Tap fires `onClick` — wired to `AlbumDetail` nav in - * Phase 5.5. + * Phase 5.5. The tile stashes its `album` in [LocalDetailSeedCache] + * before firing onClick so the detail screen's AppBar renders title / + * cover / counts immediately without waiting for the fetch. */ @Composable fun AlbumCard( @@ -40,10 +43,14 @@ fun AlbumCard( onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val seedCache = LocalDetailSeedCache.current Surface( modifier = modifier .width(176.dp) - .clickable(onClick = onClick), + .clickable { + seedCache.stashAlbum(album) + onClick() + }, color = Color.Transparent, ) { Column(modifier = Modifier.padding(horizontal = 8.dp)) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt index 4aa69fa3..e6b5a69b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt @@ -27,10 +27,13 @@ import coil3.compose.AsyncImage import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.User import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.nav.LocalDetailSeedCache /** * One artist tile. Circular cover; name centered beneath. Tap fires - * `onClick` — wired to `ArtistDetail` nav in Phase 5.5. + * `onClick` — wired to `ArtistDetail` nav in Phase 5.5. Stashes the + * `artist` in [LocalDetailSeedCache] on click so the ArtistDetail + * AppBar renders the name before the fetch lands. */ @Composable fun ArtistCard( @@ -38,10 +41,14 @@ fun ArtistCard( onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val seedCache = LocalDetailSeedCache.current Surface( modifier = modifier .width(144.dp) - .clickable(onClick = onClick), + .clickable { + seedCache.stashArtist(artist) + onClick() + }, color = Color.Transparent, ) { Column( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/DetailSeedCache.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/DetailSeedCache.kt new file mode 100644 index 00000000..f01fc464 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/DetailSeedCache.kt @@ -0,0 +1,51 @@ +package com.fabledsword.minstrel.nav + +import androidx.compose.runtime.staticCompositionLocalOf +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.PlaylistRef +import javax.inject.Inject +import javax.inject.Singleton + +/** + * In-memory hand-off for detail-screen "seed" data — the lightweight + * Ref the caller already has when navigating to AlbumDetail / + * ArtistDetail / PlaylistDetail. The detail VM peeks the seed on + * init so the AppBar header (title / cover / counts) renders + * immediately, before the network round-trip completes. + * + * Mirrors Flutter's go_router `extra:` parameter pattern. Bounded + * per-bucket via insertion-order LRU so back/forward navigation + * doesn't grow unbounded. + */ +@Singleton +class DetailSeedCache @Inject constructor() { + private val albums = lru(MAX_ENTRIES) + private val artists = lru(MAX_ENTRIES) + private val playlists = lru(MAX_ENTRIES) + + @Synchronized fun stashAlbum(ref: AlbumRef) { albums[ref.id] = ref } + @Synchronized fun stashArtist(ref: ArtistRef) { artists[ref.id] = ref } + @Synchronized fun stashPlaylist(ref: PlaylistRef) { playlists[ref.id] = ref } + + @Synchronized fun peekAlbum(id: String): AlbumRef? = albums[id] + @Synchronized fun peekArtist(id: String): ArtistRef? = artists[id] + @Synchronized fun peekPlaylist(id: String): PlaylistRef? = playlists[id] + + private companion object { + private const val MAX_ENTRIES = 32 + private fun lru(max: Int): MutableMap = + object : LinkedHashMap(max, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry) = size > max + } + } +} + +/** + * CompositionLocal carrying the app's [DetailSeedCache]. Provided + * at the [com.fabledsword.minstrel.MainActivity] root so any screen + * can stash a seed before calling `navController.navigate(...)`. + */ +val LocalDetailSeedCache = staticCompositionLocalOf { + error("DetailSeedCache not provided — wrap content in CompositionLocalProvider") +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt index 8fe272f4..ff052941 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -65,6 +65,7 @@ import com.fabledsword.minstrel.models.PlaylistTrackRef import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.nav.DetailSeedCache import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.likes.data.LikesRepository @@ -98,7 +99,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L // ─── State ─────────────────────────────────────────────────────────── sealed interface PlaylistDetailUiState { - data object Loading : PlaylistDetailUiState + data class Loading(val seed: PlaylistRef? = null) : PlaylistDetailUiState data class Success(val detail: PlaylistDetailRef) : PlaylistDetailUiState data class Error(val message: String) : PlaylistDetailUiState } @@ -111,13 +112,15 @@ class PlaylistDetailViewModel @Inject constructor( private val likes: LikesRepository, private val player: PlayerController, private val eventsStream: EventsStream, + private val seedCache: DetailSeedCache, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val route: PlaylistDetail = savedStateHandle.toRoute() val playlistId: String = route.id - private val internal = MutableStateFlow(PlaylistDetailUiState.Loading) + private val internal = + MutableStateFlow(PlaylistDetailUiState.Loading()) val uiState: StateFlow = internal.asStateFlow() /** @@ -178,7 +181,14 @@ class PlaylistDetailViewModel @Inject constructor( fun refresh(): Job = viewModelScope.launch { try { - internal.value = PlaylistDetailUiState.Loading + val carried = when (val cur = internal.value) { + is PlaylistDetailUiState.Loading -> cur.seed + is PlaylistDetailUiState.Success -> cur.detail.playlist + is PlaylistDetailUiState.Error -> null + } + internal.value = PlaylistDetailUiState.Loading( + carried ?: seedCache.peekPlaylist(playlistId), + ) val detail = repository.refreshDetail(playlistId) internal.value = PlaylistDetailUiState.Success(detail) } catch ( @@ -287,7 +297,7 @@ private fun PlaylistDetailContent( navController: NavHostController, ) { when (state) { - PlaylistDetailUiState.Loading -> LoadingCentered() + is PlaylistDetailUiState.Loading -> LoadingCentered() is PlaylistDetailUiState.Error -> ErrorBlock(state.message, viewModel::refresh) is PlaylistDetailUiState.Success -> { val likedTrackIds by viewModel.likedTrackIds.collectAsState() @@ -314,7 +324,8 @@ private fun PlaylistDetailContent( private fun currentTitle(state: PlaylistDetailUiState): String = when (state) { is PlaylistDetailUiState.Success -> state.detail.playlist.name - else -> "Playlist" + is PlaylistDetailUiState.Loading -> state.seed?.name ?: "Playlist" + is PlaylistDetailUiState.Error -> "Playlist" } // ─── Body composables ──────────────────────────────────────────────── diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt index 34132da0..28ceadad 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt @@ -26,6 +26,7 @@ import coil3.compose.AsyncImage import com.composables.icons.lucide.ListMusic import com.composables.icons.lucide.Lucide import com.fabledsword.minstrel.models.PlaylistRef +import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.theme.FabledSwordFlatTokens /** @@ -43,10 +44,14 @@ fun PlaylistCard( onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val seedCache = LocalDetailSeedCache.current Surface( modifier = modifier .width(176.dp) - .clickable(onClick = onClick), + .clickable { + seedCache.stashPlaylist(playlist) + onClick() + }, color = Color.Transparent, ) { Column(modifier = Modifier.padding(horizontal = 8.dp)) {