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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user