feat(android): MediaSession picks up UPnP state via direct listener invocation
android / Build + lint + test (push) Has been cancelled
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:
+77
-2
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user