diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt index 1987776e..21bd45ba 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt @@ -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.Loading) @@ -28,6 +31,11 @@ class AdminQuarantineViewModel @Inject constructor( init { refresh() + viewModelScope.launch { + eventsStream.events + .filter { it.kind.startsWith("quarantine.") } + .collect { refresh() } + } } fun refresh() { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt index b860d440..7afdc04d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt @@ -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.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() { 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 675cc607..01426acb 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 @@ -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.Loading) val uiState: StateFlow = 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(Channel.BUFFERED) + val deleted: Flow = deletedChannel.receiveAsFlow() + val likedTrackIds: StateFlow> = 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 = { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt index 962b6d2c..39252838 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt @@ -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 = diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt index 22a35370..1f21b558 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt @@ -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.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() {