feat(android): LiveEventsDispatcher — cross-screen SSE invalidations

Subscribes to EventsStream and maps like-family events to
LikesRepository.refreshIds(). Cross-device likes (web flips a heart,
phone reflects it) now propagate without a manual refresh.

ProcessLifecycleOwner foreground hook re-runs the same refresh as
defensive cold-start cleanup — matches Flutter's resume-handler.

Screen-scoped events (playlist.deleted for a specific id, single-
request status_changed) intentionally NOT in the dispatcher; those
ride EventsStream.events directly from per-screen ViewModels in the
next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 14:58:57 -04:00
parent 29cfbd61b1
commit 3ce30bf19c
2 changed files with 80 additions and 0 deletions
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.cache.mutations.MutationReplayer
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.ResumeController
import dagger.hilt.android.HiltAndroidApp
@@ -78,6 +79,13 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var eventsStream: EventsStream
/**
* Same construct-the-singleton trick — LiveEventsDispatcher's
* init block subscribes to EventsStream.events + the process
* lifecycle and routes cross-screen state refreshes.
*/
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
override fun onCreate() {
@@ -0,0 +1,72 @@
package com.fabledsword.minstrel.events
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Mirrors
* `flutter_client/lib/shared/live_events_dispatcher.dart`. Activated
* by force-@Inject in MinstrelApplication.
*
* Scope is deliberately narrow: this dispatcher only touches state
* that's reachable from /shared (i.e. that has a Repository-level
* refresh method). Screen-scoped state (PlaylistDetail for a
* specific id, a single Request's status_changed) is opted into by
* the screen's ViewModel subscribing to [EventsStream.events]
* directly.
*
* Includes a [ProcessLifecycleOwner] foreground hook that
* defensively re-runs the same refreshes — SSE catches up on its
* own, but a long-backgrounded app can hit `onStart` before the
* stream reconnects and the re-refresh avoids stale data flashing.
*/
@Singleton
class LiveEventsDispatcher @Inject constructor(
private val eventsStream: EventsStream,
private val likes: LikesRepository,
@ApplicationScope private val scope: CoroutineScope,
) : DefaultLifecycleObserver {
init {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
scope.launch {
eventsStream.events.collect { e -> handle(e) }
}
}
private fun handle(event: LiveEvent) {
when (event.kind) {
"track.liked",
"track.unliked",
"album.liked",
"album.unliked",
"artist.liked",
"artist.unliked",
-> refreshLikes()
}
// Other kinds (playlist.*, quarantine.*, request.status_changed,
// scan.*) reach screen-scoped subscribers via EventsStream
// directly. Add cross-screen cases here only when a new
// Repository-level refresh becomes available.
}
override fun onStart(owner: LifecycleOwner) {
// App returned to the foreground. SSE will catch up but might
// not have reconnected yet; flush the cross-screen refreshes
// defensively.
refreshLikes()
}
private fun refreshLikes() {
scope.launch {
runCatching { likes.refreshIds() }
}
}
}