diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index e8f362f2..cea22ff1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -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() { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt b/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt new file mode 100644 index 00000000..9913508d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt @@ -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() } + } + } +}