- Feed and profile cards show a progress bar with elapsed/total time, only when the source reports a real position (Plex, Jellyfin, the Android reader). It ticks locally between updates. Apple Music's guessed position is no longer reported. - The Android reader advances MediaSession's stale position to now, stamps when it was read (the server corrects for upload delay), and reports seeks. The server pushes an update to watchers when a position jumps. - When a polled source says where in the song someone is, the next poll is scheduled just after the song ends (never sooner than 5s), instead of waiting out the full interval. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
+20
-3
@@ -9,6 +9,7 @@ import android.media.session.MediaSessionManager
|
||||
import android.media.session.PlaybackState
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.service.notification.NotificationListenerService
|
||||
|
||||
/**
|
||||
@@ -112,7 +113,7 @@ class MediaListenerService : NotificationListenerService() {
|
||||
Playback.stopped(lastReported!!.packageName, lastReported!!.appName)
|
||||
else -> return
|
||||
}
|
||||
if (next.identity == lastReported?.identity) return
|
||||
if (next.identity == lastReported?.identity && !next.jumpedFrom(lastReported!!)) return
|
||||
lastReported = next
|
||||
if (prefs.isConfigured) Reporter.report(this, next)
|
||||
}
|
||||
@@ -133,6 +134,7 @@ class MediaListenerService : NotificationListenerService() {
|
||||
else -> PlayState.STOPPED
|
||||
}
|
||||
if (title.isNullOrBlank() && state != PlayState.STOPPED) return null // between tracks / ads with no metadata
|
||||
val duration = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.takeIf { it > 0 }
|
||||
return Playback(
|
||||
packageName = controller.packageName,
|
||||
appName = appLabel(controller.packageName),
|
||||
@@ -140,11 +142,26 @@ class MediaListenerService : NotificationListenerService() {
|
||||
title = title,
|
||||
artist = artist,
|
||||
album = metadata?.text(MediaMetadata.METADATA_KEY_ALBUM),
|
||||
durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.takeIf { it > 0 },
|
||||
positionMs = playbackState?.position?.takeIf { it >= 0 },
|
||||
durationMs = duration,
|
||||
positionMs = playbackState?.let { currentPosition(it, duration) },
|
||||
capturedAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PlaybackState.position is where playback was at lastPositionUpdateTime, which
|
||||
* apps only refresh on play/pause/seek. Advance it to now while playing.
|
||||
*/
|
||||
private fun currentPosition(ps: PlaybackState, durationMs: Long?): Long? {
|
||||
val base = ps.position.takeIf { it >= 0 } ?: return null
|
||||
val advanced = if (ps.state == PlaybackState.STATE_PLAYING && ps.lastPositionUpdateTime > 0) {
|
||||
base + ((SystemClock.elapsedRealtime() - ps.lastPositionUpdateTime) * ps.playbackSpeed).toLong()
|
||||
} else {
|
||||
base
|
||||
}
|
||||
return durationMs?.let { advanced.coerceIn(0, it) } ?: advanced.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
private fun MediaMetadata.text(key: String): String? = getString(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun appLabel(packageName: String): String? = try {
|
||||
|
||||
+14
@@ -14,10 +14,21 @@ internal data class Playback(
|
||||
val album: String?,
|
||||
val durationMs: Long?,
|
||||
val positionMs: Long?,
|
||||
/** Wall-clock time positionMs was read, so the server can advance it past upload delays. */
|
||||
val capturedAt: Long? = null,
|
||||
) {
|
||||
/** Fields that matter for "did anything change": not position, which ticks constantly. */
|
||||
val identity: String get() = listOf(packageName, state.wire, title, artist).joinToString("\u0000")
|
||||
|
||||
/** Same song, but the position moved more than playing would explain: a seek, so report it. */
|
||||
fun jumpedFrom(previous: Playback): Boolean {
|
||||
if (state != PlayState.PLAYING || previous.state != PlayState.PLAYING) return false
|
||||
val now = positionMs ?: return false
|
||||
val then = previous.positionMs ?: return false
|
||||
val elapsed = (capturedAt ?: return false) - (previous.capturedAt ?: return false)
|
||||
return kotlin.math.abs(now - (then + elapsed)) > SEEK_THRESHOLD_MS
|
||||
}
|
||||
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("app", packageName)
|
||||
appName?.let { put("appName", it) }
|
||||
@@ -31,6 +42,7 @@ internal data class Playback(
|
||||
})
|
||||
}
|
||||
positionMs?.takeIf { it >= 0 }?.let { put("positionMs", it) }
|
||||
capturedAt?.let { put("positionAt", it) }
|
||||
}
|
||||
|
||||
fun toMap(): Map<String, Any?> = mapOf(
|
||||
@@ -44,6 +56,8 @@ internal data class Playback(
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val SEEK_THRESHOLD_MS = 5_000L
|
||||
|
||||
fun stopped(packageName: String, appName: String?) =
|
||||
Playback(packageName, appName, PlayState.STOPPED, null, null, null, null, null)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user