Song progress bar, and poll right after a track ends
CI / check (push) Canceled after 0s

- 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:
2026-10-06 01:24:20 -04:00
co-authored by Claude Opus 5.5
parent 1f0cdb68f5
commit be0a00a2c1
18 changed files with 325 additions and 7 deletions
@@ -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,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)
}