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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.AdminQuarantineItemRef import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -21,6 +23,7 @@ sealed interface AdminQuarantineUiState {
@HiltViewModel @HiltViewModel
class AdminQuarantineViewModel @Inject constructor( class AdminQuarantineViewModel @Inject constructor(
private val repository: AdminQuarantineRepository, private val repository: AdminQuarantineRepository,
private val eventsStream: EventsStream,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading) private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
@@ -28,6 +31,11 @@ class AdminQuarantineViewModel @Inject constructor(
init { init {
refresh() refresh()
viewModelScope.launch {
eventsStream.events
.filter { it.kind.startsWith("quarantine.") }
.collect { refresh() }
}
} }
fun refresh() { fun refresh() {
@@ -21,6 +21,7 @@ sealed interface AdminRequestsUiState {
@HiltViewModel @HiltViewModel
class AdminRequestsViewModel @Inject constructor( class AdminRequestsViewModel @Inject constructor(
private val repository: AdminRequestsRepository, private val repository: AdminRequestsRepository,
private val eventsStream: EventsStream,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading) private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
@@ -28,6 +29,11 @@ class AdminRequestsViewModel @Inject constructor(
init { init {
refresh() refresh()
viewModelScope.launch {
eventsStream.events
.filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() }
}
} }
fun refresh() { fun refresh() {
@@ -55,6 +55,8 @@ import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Shuffle 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.PlaylistRef
import com.fabledsword.minstrel.models.PlaylistTrackRef import com.fabledsword.minstrel.models.PlaylistTrackRef
import com.fabledsword.minstrel.models.TrackRef 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.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
import dagger.hilt.android.lifecycle.HiltViewModel 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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@@ -97,6 +105,7 @@ class PlaylistDetailViewModel @Inject constructor(
private val repository: PlaylistsRepository, private val repository: PlaylistsRepository,
private val likes: LikesRepository, private val likes: LikesRepository,
private val player: PlayerController, private val player: PlayerController,
private val eventsStream: EventsStream,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
@@ -106,6 +115,14 @@ class PlaylistDetailViewModel @Inject constructor(
private val internal = MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading) private val internal = MutableStateFlow<PlaylistDetailUiState>(PlaylistDetailUiState.Loading)
val uiState: StateFlow<PlaylistDetailUiState> = internal.asStateFlow() 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>> = val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTracks() likes.observeLikedTracks()
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } } .map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
@@ -117,6 +134,26 @@ class PlaylistDetailViewModel @Inject constructor(
init { init {
refresh() 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) { fun toggleLikeTrack(trackId: String) {
@@ -168,6 +205,9 @@ fun PlaylistDetailScreen(
snackbarHostState.showSnackbar(msg) snackbarHostState.showSnackbar(msg)
} }
} }
LaunchedEffect(Unit) {
viewModel.deleted.collect { navController.popBackStack() }
}
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
@@ -26,6 +26,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.nav.Playlists 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.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -58,6 +60,7 @@ sealed interface PlaylistsListUiState {
@HiltViewModel @HiltViewModel
class PlaylistsListViewModel @Inject constructor( class PlaylistsListViewModel @Inject constructor(
private val repository: PlaylistsRepository, private val repository: PlaylistsRepository,
private val eventsStream: EventsStream,
) : ViewModel() { ) : ViewModel() {
init { init {
@@ -66,6 +69,15 @@ class PlaylistsListViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
runCatching { repository.refreshList() } 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> = val uiState: StateFlow<PlaylistsListUiState> =
@@ -2,16 +2,20 @@ package com.fabledsword.minstrel.requests.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.requests.data.RequestsRepository import com.fabledsword.minstrel.requests.data.RequestsRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
sealed interface RequestsUiState { sealed interface RequestsUiState {
data object Loading : RequestsUiState data object Loading : RequestsUiState
data object Empty : RequestsUiState data object Empty : RequestsUiState
@@ -22,6 +26,7 @@ sealed interface RequestsUiState {
@HiltViewModel @HiltViewModel
class RequestsViewModel @Inject constructor( class RequestsViewModel @Inject constructor(
private val repository: RequestsRepository, private val repository: RequestsRepository,
private val eventsStream: EventsStream,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<RequestsUiState>(RequestsUiState.Loading) private val internal = MutableStateFlow<RequestsUiState>(RequestsUiState.Loading)
@@ -29,6 +34,11 @@ class RequestsViewModel @Inject constructor(
init { init {
refresh() refresh()
viewModelScope.launch {
eventsStream.events
.filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() }
}
} }
fun refresh() { fun refresh() {