feat(android): detail-screen seed extras (audit v2 #11)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 13:33:11 -04:00
parent c816490061
commit 208a7d056b
10 changed files with 147 additions and 37 deletions
@@ -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(),
)
}
}
}
}
@@ -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
@@ -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>(AlbumDetailUiState.Loading)
private val internal =
MutableStateFlow<AlbumDetailUiState>(AlbumDetailUiState.Loading())
val uiState: StateFlow<AlbumDetailUiState> = internal.asStateFlow()
val albumLiked: StateFlow<Boolean> =
@@ -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)
@@ -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
@@ -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>(ArtistDetailUiState.Loading)
private val internal =
MutableStateFlow<ArtistDetailUiState>(ArtistDetailUiState.Loading())
val uiState: StateFlow<ArtistDetailUiState> = internal.asStateFlow()
val artistLiked: StateFlow<Boolean> =
@@ -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)
@@ -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)) {
@@ -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(
@@ -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<AlbumRef>(MAX_ENTRIES)
private val artists = lru<ArtistRef>(MAX_ENTRIES)
private val playlists = lru<PlaylistRef>(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 <V> lru(max: Int): MutableMap<String, V> =
object : LinkedHashMap<String, V>(max, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, V>) = 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<DetailSeedCache> {
error("DetailSeedCache not provided — wrap content in CompositionLocalProvider")
}
@@ -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>(PlaylistDetailUiState.Loading)
private val internal =
MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading())
val uiState: StateFlow<PlaylistDetailUiState> = 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 ────────────────────────────────────────────────
@@ -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)) {