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.OutlinedButton
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.TopAppBar
import androidx.compose.runtime.Composable
@@ -139,6 +143,15 @@ class PlaylistDetailViewModel @Inject constructor(
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
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>> =
likes.observeLikedTrackIds()
.stateIn(
@@ -163,6 +176,12 @@ class PlaylistDetailViewModel @Inject constructor(
* ignored — the playlists-list screen handles those.
*/
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
if (eventPlaylistId != playlistId) return
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) {
val desired = trackId !in likedTrackIds.value
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). */
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
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(
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(currentTitle(state)) },