feat(android): PlayEventsReporter — wire mobile plays to server

Observes PlayerController.uiState and runs the (current track,
playing) state machine. Fires play_started on track-begin, then
on close emits play_ended (within 3s of duration) or play_skipped
through the live EventsApi. Failures and offline-start plays fall
through to the MutationQueue PLAY_OFFLINE kind for durable replay.
App-background (ProcessLifecycleOwner onStop) closes the current
play via the offline path so a process kill mid-listen still
records a play.

Wires into MinstrelApplication via @Inject so the singleton
constructs at app start (same pattern as MutationReplayer /
SyncController / ResumeController). client_id is a stable
device-install UUID resolved through AuthStore (added in 415200d).

Adds androidx.lifecycle:lifecycle-process dependency for the
ProcessLifecycleOwner background-event hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 14:04:23 -04:00
parent ddc4472e29
commit 4580f950e3
4 changed files with 259 additions and 0 deletions
+1
View File
@@ -119,6 +119,7 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.nav.compose)
implementation(libs.androidx.hilt.nav.compose)
@@ -9,6 +9,7 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.ResumeController
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
@@ -58,6 +59,15 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer
/**
* Same construct-the-singleton trick — PlayEventsReporter's init
* block subscribes to PlayerController.uiState and reports the
* play-event lifecycle (started/ended/skipped/offline) to the
* server. Without this @Inject the singleton would never
* instantiate and Android plays would never reach the server.
*/
@Suppress("unused") @Inject lateinit var playEventsReporter: PlayEventsReporter
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
override fun onCreate() {
@@ -0,0 +1,247 @@
package com.fabledsword.minstrel.player
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.api.endpoints.EventsApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
import com.fabledsword.minstrel.models.wire.PlaySkippedRequest
import com.fabledsword.minstrel.models.wire.PlayStartedRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import retrofit2.Retrofit
import retrofit2.create
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private const val COMPLETION_TOLERANCE_MS = 3_000L
/**
* Mobile play-event lifecycle reporter. Closes the parity gap where
* Android-played tracks never reached the server — without this,
* History stayed empty for mobile listening, recommendation scoring
* was biased toward web/desktop, ListenBrainz scrobbles never fired,
* and system-playlist rotation didn't advance on mobile activity.
*
* State machine over (current track id, playing) observed against
* [PlayerController.uiState]. Mirrors Flutter's
* `play_events_reporter.dart`. The skip-vs-ended classification uses
* a 3-second tolerance to avoid marking naturally-finished in-queue
* tracks as skipped (which would dilute the recommendation
* skip-ratio).
*
* Live calls go through [EventsApi]; failures fall through to the
* offline [MutationQueue] so a flaky connection never loses a play.
* App-background / detached states close the current play durably
* via the offline queue (a fire-and-forget POST during teardown is
* unreliable; the queue survives a process kill).
*/
@Singleton
class PlayEventsReporter @Inject constructor(
private val playerController: PlayerController,
private val authStore: AuthStore,
private val mutationQueue: MutationQueue,
@ApplicationScope private val scope: CoroutineScope,
retrofit: Retrofit,
) : DefaultLifecycleObserver {
private val api: EventsApi = retrofit.create()
private var curTrackId: String? = null
private var curStartedAt: Instant? = null
private var curSource: String? = null
private var curLastPositionMs: Long = 0
private var curDurationMs: Long = 0
private var curReachedEnd: Boolean = false
// Server play_event_id when the live play_started succeeded;
// null if start failed/offline — then the close is captured into
// the offline mutation queue instead of a live ended/skipped.
private var openPlayEventId: String? = null
private var prevTrackId: String? = null
init {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
scope.launch {
playerController.uiState.collect { state ->
evaluate(
trackId = state.currentTrack?.id,
playing = state.isPlaying,
positionMs = state.positionMs,
durationMs = state.durationMs,
source = state.currentSource,
)
}
}
}
private fun evaluate(
trackId: String?,
playing: Boolean,
positionMs: Long,
durationMs: Long,
source: String?,
) {
// Track-change → close the prior tracked play.
if (trackId != prevTrackId && curTrackId != null) {
closeCurrent(viaOffline = false)
}
// Entered playing for a new track → begin tracking it.
if (trackId != null && playing && curTrackId != trackId) {
beginTrack(trackId = trackId, source = source, durationMs = durationMs)
}
// Advance the tracked play's progress. The gate `trackId ==
// curTrackId` keeps a finishing track's last-known position
// intact across the moment the listener fires for a track
// change (where currentTrack is briefly the new one).
if (curTrackId != null && trackId == curTrackId) {
curLastPositionMs = positionMs
if (durationMs > 0) {
curDurationMs = durationMs
if (positionMs >= curDurationMs - COMPLETION_TOLERANCE_MS) {
curReachedEnd = true
}
}
}
prevTrackId = trackId
}
private fun beginTrack(trackId: String, source: String?, durationMs: Long) {
curTrackId = trackId
curStartedAt = Clock.System.now()
curSource = source
curLastPositionMs = 0
curDurationMs = durationMs
curReachedEnd = false
openPlayEventId = null
startLive(trackId = trackId, source = source)
}
private fun startLive(trackId: String, source: String?) {
val cid = resolveClientId() ?: return
scope.launch {
try {
val response = api.playStarted(
PlayStartedRequest(
trackId = trackId,
clientId = cid,
source = source.takeIf { !it.isNullOrEmpty() },
),
)
if (curTrackId == trackId) {
openPlayEventId = response.playEventId
}
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Offline / flaky — openPlayEventId stays null; close
// path will enqueue the completed play for replay.
}
}
}
private fun closeCurrent(viaOffline: Boolean) {
val trackId = curTrackId
val startedAt = curStartedAt
if (trackId == null || startedAt == null) {
resetCurrent()
return
}
val reached = curReachedEnd
val lastPos = curLastPositionMs
val durationMs = if (reached && curDurationMs > 0) curDurationMs else lastPos
val source = curSource
val id = openPlayEventId
if (!viaOffline && id != null) {
scope.launch {
try {
if (reached) {
api.playEnded(
PlayEndedRequest(
playEventId = id,
durationPlayedMs = durationMs,
),
)
} else {
api.playSkipped(
PlaySkippedRequest(
playEventId = id,
positionMs = lastPos,
),
)
}
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
enqueueOffline(trackId, startedAt, source, durationMs)
}
}
} else {
enqueueOffline(trackId, startedAt, source, durationMs)
}
resetCurrent()
}
private fun resetCurrent() {
curTrackId = null
curStartedAt = null
curSource = null
curLastPositionMs = 0
curDurationMs = 0
curReachedEnd = false
openPlayEventId = null
}
private fun enqueueOffline(
trackId: String,
startedAt: Instant,
source: String?,
durationPlayedMs: Long,
) {
val cid = resolveClientId() ?: return
scope.launch {
mutationQueue.enqueuePlayOffline(
PlayOfflinePayload(
trackId = trackId,
clientId = cid,
atIso = startedAt.toString(),
durationPlayedMs = durationPlayedMs,
source = source.takeIf { !it.isNullOrEmpty() },
),
)
}
}
/**
* Returns the persisted client id, generating + persisting one on
* first read. The setter is fire-and-forget on the app scope so
* this stays synchronous; the in-memory StateFlow update is
* immediate so the next caller observes the new value.
*/
private fun resolveClientId(): String? {
authStore.clientId.value?.let { return it }
val fresh = UUID.randomUUID().toString()
authStore.setClientId(fresh)
return fresh
}
// ── Lifecycle: durable-close on app background / detach ────────
override fun onStop(owner: LifecycleOwner) {
if (curTrackId != null) {
closeCurrent(viaOffline = true)
}
}
}
+1
View File
@@ -37,6 +37,7 @@ detekt = "2.0.0-alpha.3"
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.13.1" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.9.3" }
androidx-nav-compose = { module = "androidx.navigation:navigation-compose", version.ref = "nav-compose" }
androidx-hilt-nav-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-androidx" }