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:
+8
@@ -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() {
|
||||
|
||||
+40
@@ -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 = {
|
||||
|
||||
+12
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user