feat(android/playlists): stale-view snackbar + Refresh on open system playlist after rebuild
android / Build + lint + test (push) Successful in 3m38s
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:
+68
@@ -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)) },
|
||||||
|
|||||||
Reference in New Issue
Block a user