From f4f4df77089a5df2ac93a6150e23dc5740f0f85d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 20 Jun 2026 19:52:13 -0400 Subject: [PATCH] feat(android/playlists): stale-view snackbar + Refresh on open system playlist after rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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) --- .../playlists/ui/PlaylistDetailScreen.kt | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) 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 1df819ef..1aa2ddbe 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 @@ -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(Channel.BUFFERED) val regenerated: Flow = 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 = staleInternal.asStateFlow() + val likedTrackIds: StateFlow> = 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, 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)) },