feat(android): per-screen SSE subscribers — playlists / requests / admin

Five ViewModels gain EventsStream collectors:

- PlaylistsListViewModel — playlist.* → repo.refreshList()
- PlaylistDetailViewModel — filter on this screen's playlistId:
  - playlist.updated / playlist.tracks_changed → refresh()
  - playlist.deleted → emit on a Channel<Unit>; screen collects and
    pops back so the user isn't stranded on a 404 detail.
- RequestsViewModel — request.status_changed → refresh()
- AdminRequestsViewModel — request.status_changed → refresh()
- AdminQuarantineViewModel — quarantine.* → refresh()

Combined with the central LiveEventsDispatcher (like-family events),
audit #4 cross-device reactivity is now wired across every screen
that has stale-from-other-device exposure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 15:02:42 -04:00
parent 3ce30bf19c
commit 525873fffc
5 changed files with 76 additions and 0 deletions
@@ -3,11 +3,13 @@ package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -21,6 +23,7 @@ sealed interface AdminQuarantineUiState {
@HiltViewModel
class AdminQuarantineViewModel @Inject constructor(
private val repository: AdminQuarantineRepository,
private val eventsStream: EventsStream,
) : ViewModel() {
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
@@ -28,6 +31,11 @@ class AdminQuarantineViewModel @Inject constructor(
init {
refresh()
viewModelScope.launch {
eventsStream.events
.filter { it.kind.startsWith("quarantine.") }
.collect { refresh() }
}
}
fun refresh() {
@@ -21,6 +21,7 @@ sealed interface AdminRequestsUiState {
@HiltViewModel
class AdminRequestsViewModel @Inject constructor(
private val repository: AdminRequestsRepository,
private val eventsStream: EventsStream,
) : ViewModel() {
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
@@ -28,6 +29,11 @@ class AdminRequestsViewModel @Inject constructor(
init {
refresh()
viewModelScope.launch {
eventsStream.events
.filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() }
}
}
fun refresh() {
@@ -55,6 +55,8 @@ import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEvent
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.PlaylistTrackRef
import com.fabledsword.minstrel.models.TrackRef
@@ -71,13 +73,19 @@ import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@@ -97,6 +105,7 @@ class PlaylistDetailViewModel @Inject constructor(
private val repository: PlaylistsRepository,
private val likes: LikesRepository,
private val player: PlayerController,
private val eventsStream: EventsStream,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
@@ -106,6 +115,14 @@ class PlaylistDetailViewModel @Inject constructor(
private val internal = MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading)
val uiState: StateFlow<PlaylistDetailUiState> = internal.asStateFlow()
/**
* One-shot signal: this playlist was deleted (server-side or from
* another client). The screen collects this and pops back so the
* user doesn't sit on a now-404 detail.
*/
private val deletedChannel = Channel<Unit>(Channel.BUFFERED)
val deleted: Flow<Unit> = deletedChannel.receiveAsFlow()
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTracks()
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
@@ -117,6 +134,26 @@ class PlaylistDetailViewModel @Inject constructor(
init {
refresh()
viewModelScope.launch {
eventsStream.events
.filter { it.kind.startsWith("playlist.") }
.collect { handlePlaylistEvent(it) }
}
}
/**
* Filters playlist.* events to those touching this screen's id.
* delete → pop the screen; updated / tracks_changed → re-fetch.
* Other kinds (e.g. playlist.created for a different list) are
* ignored — the playlists-list screen handles those.
*/
private fun handlePlaylistEvent(event: LiveEvent) {
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
if (eventPlaylistId != playlistId) return
when (event.kind) {
"playlist.deleted" -> deletedChannel.trySend(Unit)
"playlist.updated", "playlist.tracks_changed" -> refresh()
}
}
fun toggleLikeTrack(trackId: String) {
@@ -168,6 +205,9 @@ fun PlaylistDetailScreen(
snackbarHostState.showSnackbar(msg)
}
}
LaunchedEffect(Unit) {
viewModel.deleted.collect { navController.popBackStack() }
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -26,6 +26,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.nav.Playlists
@@ -37,6 +38,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -58,6 +60,7 @@ sealed interface PlaylistsListUiState {
@HiltViewModel
class PlaylistsListViewModel @Inject constructor(
private val repository: PlaylistsRepository,
private val eventsStream: EventsStream,
) : ViewModel() {
init {
@@ -66,6 +69,15 @@ class PlaylistsListViewModel @Inject constructor(
viewModelScope.launch {
runCatching { repository.refreshList() }
}
// Live updates: a playlist created/updated/deleted from another
// client should reflect here without a manual refresh.
viewModelScope.launch {
eventsStream.events
.filter { it.kind.startsWith("playlist.") }
.collect {
runCatching { repository.refreshList() }
}
}
}
val uiState: StateFlow<PlaylistsListUiState> =
@@ -2,16 +2,20 @@ package com.fabledsword.minstrel.requests.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.requests.data.RequestsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
sealed interface RequestsUiState {
data object Loading : RequestsUiState
data object Empty : RequestsUiState
@@ -22,6 +26,7 @@ sealed interface RequestsUiState {
@HiltViewModel
class RequestsViewModel @Inject constructor(
private val repository: RequestsRepository,
private val eventsStream: EventsStream,
) : ViewModel() {
private val internal = MutableStateFlow<RequestsUiState>(RequestsUiState.Loading)
@@ -29,6 +34,11 @@ class RequestsViewModel @Inject constructor(
init {
refresh()
viewModelScope.launch {
eventsStream.events
.filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() }
}
}
fun refresh() {