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
This commit is contained in:
Ray 2026-01-09 16:59:37 -05:00 committed by GitHub
parent 2cd34692e9
commit ee73e7d723
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 167 additions and 43 deletions

View file

@ -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)
}
}

View file

@ -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 -> {

View file

@ -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
}

View file

@ -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,
)
}
}