feat(android/playlists): stale-view snackbar + Refresh on open system playlist after rebuild
android / Build + lint + test (push) Successful in 3m38s

#980, parity with web e932ab43. When playlist.system_rebuilt arrives (SSE)
while a system-playlist detail screen is open, the ViewModel marks it stale and
the screen shows an indefinite "This mix was refreshed · Refresh" snackbar.
Refresh re-resolves the rotated variant via PlaylistsRepository.systemShuffle
and reuses the existing regenerated navigate-replace flow to land on the fresh
playlist id — without triggering another server rebuild (unlike the manual
regenerate button). Dismiss clears the flag. Functional behaviors were already
correct; this closes the cosmetic stale-list gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 19:52:13 -04:00
parent e932ab438c
commit f4f4df7708
@@ -28,6 +28,10 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -139,6 +143,15 @@ class PlaylistDetailViewModel @Inject constructor(
private val regeneratedChannel = Channel<String>(Channel.BUFFERED) private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow() val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
/**
* #980: a system-playlist rebuild landed (SSE) while this screen is open —
* its list is now stale and the uuid has rotated. Drives a "this mix was
* refreshed → Refresh" snackbar rather than yanking the user; Refresh
* re-resolves the variant via [reloadRebuilt].
*/
private val staleInternal = MutableStateFlow(false)
val stale: StateFlow<Boolean> = staleInternal.asStateFlow()
val likedTrackIds: StateFlow<Set<String>> = val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTrackIds() likes.observeLikedTrackIds()
.stateIn( .stateIn(
@@ -163,6 +176,12 @@ class PlaylistDetailViewModel @Inject constructor(
* ignored — the playlists-list screen handles those. * ignored — the playlists-list screen handles those.
*/ */
private fun handlePlaylistEvent(event: LiveEvent) { private fun handlePlaylistEvent(event: LiveEvent) {
// #980: a system rebuild carries no playlist_id (it rebuilds all of the
// user's system mixes at once) — handle it before the per-id filter.
if (event.kind == "playlist.system_rebuilt") {
markStaleIfSystem()
return
}
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
if (eventPlaylistId != playlistId) return if (eventPlaylistId != playlistId) return
when (event.kind) { when (event.kind) {
@@ -171,6 +190,14 @@ class PlaylistDetailViewModel @Inject constructor(
} }
} }
private fun markStaleIfSystem() {
val playlist = (internal.value as? PlaylistDetailUiState.Success)
?.detail?.playlist ?: return
if (playlist.systemVariant?.takeIf { playlist.refreshable } != null) {
staleInternal.value = true
}
}
fun toggleLikeTrack(trackId: String) { fun toggleLikeTrack(trackId: String) {
val desired = trackId !in likedTrackIds.value val desired = trackId !in likedTrackIds.value
viewModelScope.launch { viewModelScope.launch {
@@ -220,6 +247,30 @@ class PlaylistDetailViewModel @Inject constructor(
} }
} }
/**
* #980: user tapped Refresh on the stale-view snackbar. The rebuild
* already ran server-side (uuid rotated) — re-resolve the variant to the
* fresh playlist id and hand it to [regenerated] for navigate-replace.
* Unlike [regenerate] this does NOT trigger another rebuild.
*/
fun reloadRebuilt() {
val playlist = (internal.value as? PlaylistDetailUiState.Success)
?.detail?.playlist ?: return
val variant = playlist.systemVariant?.takeIf { playlist.refreshable } ?: return
staleInternal.value = false
viewModelScope.launch {
runCatching { repository.systemShuffle(variant).playlist.id }
.onSuccess { newId ->
if (newId.isNotEmpty()) regeneratedChannel.trySend(newId)
}
}
}
/** #980: user dismissed the stale-view snackbar without refreshing. */
fun dismissStale() {
staleInternal.value = false
}
/** Play the available tracks starting at [startTrackId] (or first available). */ /** Play the available tracks starting at [startTrackId] (or first available). */
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) { fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
val refs = tracks.toPlayableTrackRefs() val refs = tracks.toPlayableTrackRefs()
@@ -261,8 +312,25 @@ fun PlaylistDetailScreen(
} }
} }
} }
// #980: a rebuild landed while this system-playlist screen is open. Offer a
// non-intrusive "refreshed → Refresh" snackbar; Refresh re-resolves the
// rotated uuid (via the regenerated navigate-replace flow).
val snackbarHostState = remember { SnackbarHostState() }
val stale by viewModel.stale.collectAsState()
LaunchedEffect(stale) {
if (stale) {
val result = snackbarHostState.showSnackbar(
message = "This mix was refreshed",
actionLabel = "Refresh",
duration = SnackbarDuration.Indefinite,
)
if (result == SnackbarResult.ActionPerformed) viewModel.reloadRebuilt()
else viewModel.dismissStale()
}
}
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(currentTitle(state)) }, title = { Text(currentTitle(state)) },