feat(android): MediaSession picks up UPnP state via direct listener invocation
android / Build + lint + test (push) Has been cancelled

Closes Scribe #606. Two pieces: MediaMetadata gets durationMs (lock-screen scrubber gets a known total even when wrapped ExoPlayer is paused under UPnP); MinstrelForwardingPlayer keeps an externalListeners registry that mirrors super.addListener so we can directly invoke onIsPlayingChanged / onPlaybackStateChanged / onMediaItemTransition when remoteState mutates. Fires from pollOnce + play()/pause() onSuccess. dedup via lastNotified guards so we don't spam events at 1Hz when nothing changed.
This commit is contained in:
2026-06-04 11:02:51 -04:00
parent 28300e19fd
commit 6184c62721
2 changed files with 81 additions and 2 deletions
@@ -81,6 +81,21 @@ class MinstrelForwardingPlayer(
// CONFLATED so repeated trySend's between polls don't queue up.
private val pollTrigger = Channel<Unit>(Channel.CONFLATED)
// External Player.Listener registry (separate from super.addListener which
// forwards to the wrapped ExoPlayer). The wrapped player is paused with
// no audio loaded while UPnP is active, so it never fires events for our
// synthesized remote state -- the MediaSession's notification card and
// lock-screen scrubber stay frozen on whatever state was last captured
// before UPnP took over. We dual-register: super.addListener keeps the
// listener attached to the delegate (so local-playback events still
// reach it), AND we hold a ref here so we can directly invoke listener
// callbacks on remote-state changes. The listener's read of isPlaying /
// duration / position then routes through our overrides to remoteState.
private val externalListeners = mutableListOf<Player.Listener>()
@Volatile private var lastNotifiedIsPlaying: Boolean = false
@Volatile private var lastNotifiedTrackIdx: Int = -1
private val lifecycleObserver = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
pollTrigger.trySend(Unit)
@@ -121,7 +136,10 @@ class MinstrelForwardingPlayer(
} else {
scope.launch {
runCatching { active.avTransport.play() }
.onSuccess { remoteState.applyTransportPlaying() }
.onSuccess {
remoteState.applyTransportPlaying()
notifyRemoteStateChanged()
}
.onFailure { handleSoapFailure(active, it) }
}
}
@@ -139,7 +157,10 @@ class MinstrelForwardingPlayer(
} else {
scope.launch {
runCatching { active.avTransport.pause() }
.onSuccess { remoteState.applyTransportPaused() }
.onSuccess {
remoteState.applyTransportPaused()
notifyRemoteStateChanged()
}
.onFailure { handleSoapFailure(active, it) }
}
}
@@ -269,6 +290,54 @@ class MinstrelForwardingPlayer(
override fun getPlayWhenReady(): Boolean =
if (isRemote()) remoteState.isPlaying else super.getPlayWhenReady()
override fun addListener(listener: Player.Listener) {
super.addListener(listener)
synchronized(externalListeners) { externalListeners.add(listener) }
}
override fun removeListener(listener: Player.Listener) {
super.removeListener(listener)
synchronized(externalListeners) { externalListeners.remove(listener) }
}
/**
* Direct-invoke the externally-registered Player.Listeners so the
* MediaSession's PlaybackState publisher (notification card, lock-screen
* scrubber, BT/AVRCP, Auto, Wear OS tile) re-reads our overridden state.
* The listeners then query isPlaying / getDuration / getCurrentPosition,
* all of which route through to remoteState while UPnP is active.
*
* Posted to the player's application looper because Player.Listener
* callbacks contract on the application thread.
*/
private fun notifyRemoteStateChanged() {
if (!isRemote()) return
val playing = remoteState.isPlaying
val trackIdx = (remoteState.trackNumber - 1).coerceAtLeast(0)
val isPlayingChanged = playing != lastNotifiedIsPlaying
val trackChanged = trackIdx != lastNotifiedTrackIdx
if (!isPlayingChanged && !trackChanged) return
lastNotifiedIsPlaying = playing
lastNotifiedTrackIdx = trackIdx
val snapshot = synchronized(externalListeners) { externalListeners.toList() }
handler.post {
for (l in snapshot) {
if (isPlayingChanged) {
l.onIsPlayingChanged(playing)
l.onPlaybackStateChanged(Player.STATE_READY)
}
if (trackChanged) {
val item = if (trackIdx < delegate.mediaItemCount) {
delegate.getMediaItemAt(trackIdx)
} else {
null
}
l.onMediaItemTransition(item, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO)
}
}
}
}
override fun release() {
pollJob?.cancel()
scope.cancel()
@@ -288,6 +357,11 @@ class MinstrelForwardingPlayer(
private fun onActiveChanged(active: ActiveUpnp?) {
pollJob?.cancel()
nonPlayingPollStreak = 0
// Reset notify cache so the first poll after a route flip republishes
// playing/track state to the MediaSession even if it happens to match
// the prior session's values numerically.
lastNotifiedIsPlaying = false
lastNotifiedTrackIdx = -1
if (active != null) {
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
// Pause the wrapped ExoPlayer so we are not playing local audio
@@ -373,6 +447,7 @@ class MinstrelForwardingPlayer(
}
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
}
notifyRemoteStateChanged()
}
private fun maybeSyncLocalCursor(sonosTrack: Int) {
@@ -666,6 +666,10 @@ class PlayerController @Inject constructor(
.setArtist(artistName)
.setAlbumTitle(albumTitle)
.apply {
// Server-known duration -- gives the lock-screen / notification
// scrubber a real total even when the wrapped ExoPlayer is
// paused under UPnP (it never probes a duration in that state).
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
if (source != null) setExtras(sourceExtras(source))
}
.build()