diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index f1f610c1..f8dcb773 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -81,6 +81,21 @@ class MinstrelForwardingPlayer( // CONFLATED so repeated trySend's between polls don't queue up. private val pollTrigger = Channel(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() + + @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) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index a6af30d3..0305255b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -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()