From ee73e7d7231fe78fc65f7f9bc1d3e051d6692ea9 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:59:37 -0500 Subject: [PATCH] Fix duplicate commands from key presses/MediaSession (#663) ## Description Fixes duplicate play/pause commands from a remote button press and `MediaSession`. This ended up being a bit complicated because Wholphin overrides the default play/pause behavior for the skip back setting and to show the controller on pause. This means using a forwarding player which meant implementing more functionality into `MpvPlayer`. ### Related issues Fixes #653 --- .../ui/playback/MediaSessionPlayer.kt | 27 ++++ .../ui/playback/PlaybackKeyHandler.kt | 23 +-- .../wholphin/ui/playback/PlaybackViewModel.kt | 22 ++- .../wholphin/util/mpv/MpvPlayer.kt | 138 ++++++++++++++++-- 4 files changed, 167 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt new file mode 100644 index 00000000..94a41aa1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.media3.common.ForwardingSimpleBasePlayer +import androidx.media3.common.Player +import com.github.damontecres.wholphin.preferences.PlaybackPreferences +import com.github.damontecres.wholphin.preferences.skipBackOnResume +import com.github.damontecres.wholphin.ui.seekBack +import com.google.common.util.concurrent.ListenableFuture +import timber.log.Timber + +class MediaSessionPlayer( + player: Player, + private val controllerViewState: ControllerViewState, + private val playbackPreferences: PlaybackPreferences, +) : ForwardingSimpleBasePlayer(player) { + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady") + if (!playWhenReady && player.isPlaying) { + controllerViewState.showControls() + } else if (playWhenReady) { + playbackPreferences.skipBackOnResume?.let { + player.seekBack(it) + } + } + return super.handleSetPlayWhenReady(playWhenReady) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index 303de20c..c95b0896 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -61,27 +61,8 @@ class PlaybackKeyHandler( } } else if (isMedia(it)) { when (it.key) { - Key.MediaPlay -> { - Util.handlePlayButtonAction(player) - skipBackOnResume?.let { - player.seekBack(it) - } - } - - Key.MediaPause -> { - Util.handlePauseButtonAction(player) - controllerViewState.showControls() - } - - Key.MediaPlayPause -> { - Util.handlePlayPauseButtonAction(player) - if (!player.isPlaying) { - controllerViewState.showControls() - } else { - skipBackOnResume?.let { - player.seekBack(it) - } - } + Key.MediaPlay, Key.MediaPause, Key.MediaPlayPause -> { + // no-op, MediaSession will handle } Key.MediaFastForward, Key.MediaSkipForward -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index d8190c30..a9792bb8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -135,7 +135,7 @@ class PlaybackViewModel val player by lazy { playerFactory.createVideoPlayer() } - private val mediaSession: MediaSession + private var mediaSession: MediaSession? = null internal val mutex = Mutex() val controllerViewState = @@ -179,15 +179,11 @@ class PlaybackViewModel } jobs.forEach { it.cancel() } player.release() - mediaSession.release() + mediaSession?.release() } viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } player.addListener(this) (player as? ExoPlayer)?.addAnalyticsListener(this) - mediaSession = - MediaSession - .Builder(context, player) - .build() jobs.add(subscribe()) jobs.add(listenForTranscodeReason()) } @@ -284,6 +280,18 @@ class PlaybackViewModel } else { throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } + + val sessionPlayer = + MediaSessionPlayer( + player, + controllerViewState, + preferences.appPreferences.playbackPreferences, + ) + mediaSession = + MediaSession + .Builder(context, sessionPlayer) + .build() + val item = BaseItem.from(base, api) val played = @@ -1058,7 +1066,7 @@ class PlaybackViewModel Timber.v("release") activityListener?.release() player.release() - mediaSession.release() + mediaSession?.release() activityListener = null } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt index f46b94cf..58c976c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt @@ -53,6 +53,7 @@ import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.update import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** @@ -231,11 +232,6 @@ class MpvPlayer( override fun prepare() { if (DEBUG) Timber.v("prepare") - playbackState.update { - it.copy( - state = Player.STATE_READY, - ) - } } override fun getPlaybackState(): Int { @@ -395,8 +391,7 @@ class MpvPlayer( override fun getCurrentTimeline(): Timeline { if (DEBUG) Timber.v("getCurrentTimeline") - // TODO - return Timeline.EMPTY + return playbackState.load().timeline } override fun getCurrentPeriodIndex(): Int { @@ -528,7 +523,7 @@ class MpvPlayer( return playbackState.load().videoSize } - override fun getSurfaceSize(): Size = throw UnsupportedOperationException() + override fun getSurfaceSize(): Size = surfaceHolder?.surfaceFrame?.let { Size(it.width(), it.height()) } ?: Size.UNKNOWN override fun getCurrentCues(): CueGroup = CueGroup.EMPTY_TIME_ZERO @@ -655,12 +650,15 @@ class MpvPlayer( } MPV_EVENT_FILE_LOADED -> { - playbackState.update { - it.copy(isLoadingFile = false) - } - notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } Timber.d("event: MPV_EVENT_FILE_LOADED") - internalHandler.post(updatePlaybackState) + playbackState.update { + it.copy( + isLoadingFile = false, + ) + } + updatePlaybackState.run() + notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } + playbackState.load().media?.mediaItem?.let { media -> media.localConfiguration?.subtitleConfigurations?.forEach { val url = it.uri.toString() @@ -767,10 +765,63 @@ class MpvPlayer( private fun loadFile(media: MediaAndPosition) { Timber.v("loadFile: media=$media") + val timeline = + object : Timeline() { + override fun getWindowCount(): Int = 1 + + override fun getWindow( + windowIndex: Int, + window: Window, + defaultPositionProjectionUs: Long, + ): Window = + window.set( + media.mediaItem.mediaId, + media.mediaItem, + null, + C.TIME_UNSET, + C.TIME_UNSET, + C.TIME_UNSET, + true, + true, + media.mediaItem.liveConfiguration, + 0L, + C.TIME_UNSET, + 0, + 0, + 0, + ) + + override fun getPeriodCount(): Int = 1 + + override fun getPeriod( + periodIndex: Int, + period: Period, + setIds: Boolean, + ): Period = + period.set( + media.mediaItem.mediaId, + media.mediaItem.mediaId, + 0, + C.TIME_UNSET, + 0, + ) + + override fun getIndexOfPeriod(uid: Any): Int = 0 + + override fun getUidOfPeriod(periodIndex: Int) = media.mediaItem.mediaId + } playbackState.update { it.copy( isLoadingFile = true, + state = STATE_READY, media = media, + timeline = timeline, + ) + } + notifyListeners(EVENT_TIMELINE_CHANGED) { + onTimelineChanged( + timeline, + TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, ) } notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) } @@ -838,7 +889,8 @@ class MpvPlayer( private val updatePlaybackState: Runnable = Runnable { - if (playbackState.load().media == null) { + val state = playbackState.load() + if (state.media == null) { return@Runnable } val positionMs = @@ -861,6 +913,53 @@ class MpvPlayer( VideoSize.UNKNOWN } + val mediaItem = state.media.mediaItem + val timeline = + object : Timeline() { + override fun getWindowCount(): Int = 1 + + override fun getWindow( + windowIndex: Int, + window: Window, + defaultPositionProjectionUs: Long, + ): Window = + window.set( + mediaItem.mediaId, + mediaItem, + null, + C.TIME_UNSET, + C.TIME_UNSET, + C.TIME_UNSET, + true, + false, + mediaItem.liveConfiguration, + 0L, + if (durationMs != C.TIME_UNSET) durationMs.milliseconds.inWholeMicroseconds else C.TIME_UNSET, + 0, + 0, + 0, + ) + + override fun getPeriodCount(): Int = 1 + + override fun getPeriod( + periodIndex: Int, + period: Period, + setIds: Boolean, + ): Period = + period.set( + mediaItem.mediaId, + mediaItem.mediaId, + 0, + state.durationMs.milliseconds.inWholeMicroseconds, + 0, + ) + + override fun getIndexOfPeriod(uid: Any): Int = 0 + + override fun getUidOfPeriod(periodIndex: Int) = mediaItem.mediaId + } + playbackState.update { it.copy( timestamp = System.currentTimeMillis(), @@ -870,6 +969,13 @@ class MpvPlayer( speed = speed, isPaused = paused, videoSize = videoSize, + timeline = timeline, + ) + } + notifyListeners(EVENT_TIMELINE_CHANGED) { + onTimelineChanged( + timeline, + TIMELINE_CHANGE_REASON_SOURCE_UPDATE, ) } } @@ -1080,12 +1186,13 @@ private data class PlaybackState( val videoSize: VideoSize, @param:Player.State val state: Int, val tracks: Tracks, + val timeline: Timeline, ) { companion object { val EMPTY = PlaybackState( timestamp = C.TIME_UNSET, - isLoadingFile = true, + isLoadingFile = false, media = null, positionMs = C.TIME_UNSET, durationMs = C.TIME_UNSET, @@ -1096,6 +1203,7 @@ private data class PlaybackState( videoSize = VideoSize.UNKNOWN, state = Player.STATE_IDLE, subtitleDelay = 0.0, + timeline = Timeline.EMPTY, ) } }