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:
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
|||||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
|
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||||
import com.fabledsword.minstrel.player.PlayEventsReporter
|
import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||||
import com.fabledsword.minstrel.player.ResumeController
|
import com.fabledsword.minstrel.player.ResumeController
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
@@ -78,6 +79,13 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var eventsStream: EventsStream
|
@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
|
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
|
||||||
|
|
||||||
override fun onCreate() {
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user