Update MPV playback threading model (#481)

There's no user facing changes in this PR

This PR updates `MpvPlayer` to use a threading model more similar to
[ExoPlayer's
model](https://developer.android.com/reference/androidx/media3/exoplayer/ExoPlayer#threading-model).

There's now a dedicated thread looper message queue between the UI
interactions on the main thread and calls to `libmpv`. This is necessary
because previously, the main thread would make the calls into `libmpv`.
`libmpv` uses internal mutex locking for some operations which meant
that the main thread would be blocked momentarily.

Now, the UI issues a command (eg seek to X position) and the command is
added to the thread looper queue and processed on that separate thread.
This uses the standard Android Handler/Looper mechanisms.

Related to #235
This commit is contained in:
Ray 2025-12-17 18:24:25 -05:00 committed by GitHub
parent 7320c12f0c
commit 8b40e89b8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 411 additions and 182 deletions

View file

@ -187,4 +187,14 @@ suspend fun upgradeApp(
} }
} }
} }
if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) {
if (Build.MODEL.equals("shield android tv", ignoreCase = true)) {
appPreferences.updateData {
it.updateMpvOptions {
useGpuNext = false
}
}
}
}
} }

View file

@ -0,0 +1,21 @@
package com.github.damontecres.wholphin.util.mpv
import timber.log.Timber
class MpvLogger : MPVLib.LogObserver {
override fun logMessage(
prefix: String,
level: Int,
text: String,
) {
// https://github.com/mpv-player/mpv/blob/122abdfec3124bfc92a2918a70ca8150eee68338/include/mpv/client.h#L1423
when {
level <= 10 -> Timber.wtf("%s, %s", prefix, text)
level <= 20 -> Timber.e("%s, %s", prefix, text)
level <= 30 -> Timber.w("%s, %s", prefix, text)
level <= 40 -> Timber.i("%s, %s", prefix, text)
level <= 50 -> Timber.d("%s, %s", prefix, text)
else -> Timber.v("%s, %s", prefix, text)
}
}
}

View file

@ -3,7 +3,10 @@ package com.github.damontecres.wholphin.util.mpv
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.HandlerThread
import android.os.Looper import android.os.Looper
import android.os.Message
import android.os.Process
import android.view.Surface import android.view.Surface
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
@ -35,6 +38,7 @@ import androidx.media3.common.util.Util
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.TrackSelector import androidx.media3.exoplayer.trackselection.TrackSelector
import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_EOF import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_EOF
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_ERROR import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_ERROR
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_STOP import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEndFileReason.MPV_END_FILE_REASON_STOP
@ -42,9 +46,12 @@ import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_AUDIO_
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_END_FILE import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_END_FILE
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_FILE_LOADED import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_FILE_LOADED
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_PLAYBACK_RESTART
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_START_FILE
import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_VIDEO_RECONFIG import com.github.damontecres.wholphin.util.mpv.MPVLib.MpvEvent.MPV_EVENT_VIDEO_RECONFIG
import timber.log.Timber import timber.log.Timber
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.update
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -57,20 +64,22 @@ import kotlin.time.Duration.Companion.seconds
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class MpvPlayer( class MpvPlayer(
private val context: Context, private val context: Context,
enableHardwareDecoding: Boolean, private val enableHardwareDecoding: Boolean,
useGpuNext: Boolean, private val useGpuNext: Boolean,
) : BasePlayer(), ) : BasePlayer(),
MPVLib.EventObserver, MPVLib.EventObserver,
TrackSelector.InvalidationListener { TrackSelector.InvalidationListener,
Handler.Callback {
companion object { companion object {
private const val DEBUG = false private const val DEBUG = false
} }
private var isPaused: Boolean = true
private var surface: Surface? = null private var surface: Surface? = null
private val playbackState = AtomicReference<PlaybackState>(PlaybackState.EMPTY)
// This looper will sent events to the main thread
private val looper = Util.getCurrentOrMainLooper() private val looper = Util.getCurrentOrMainLooper()
private val handler = Handler(looper) private val mainHandler = Handler(looper)
private val listeners = private val listeners =
ListenerSet<Player.Listener>( ListenerSet<Player.Listener>(
looper, looper,
@ -81,19 +90,16 @@ class MpvPlayer(
private val availableCommands: Player.Commands private val availableCommands: Player.Commands
private val trackSelector = DefaultTrackSelector(context) private val trackSelector = DefaultTrackSelector(context)
private var mediaItem: MediaItem? = null // This thread/looper will receive commands from the main thread to execute
private var startPositionMs: Long = 0L private val thread: HandlerThread =
private var durationMs: Long = 0L HandlerThread("MpvPlayer:Playback", Process.THREAD_PRIORITY_AUDIO)
private var positionMs: Long = -1L .also { it.start() }
private var playbackState: Int = STATE_READY private val internalHandler: Handler = Handler(thread.looper, this)
@Volatile @Volatile
var isReleased = false var isReleased = false
private set private set
@Volatile
private var isLoadingFile = false
init { init {
Timber.v("config-dir=${context.filesDir.path}") Timber.v("config-dir=${context.filesDir.path}")
MPVLib.create(context) MPVLib.create(context)
@ -120,8 +126,11 @@ class MpvPlayer(
MPVLib.setOptionString("idle", "yes") MPVLib.setOptionString("idle", "yes")
// MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath) // MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath)
MPVLib.addObserver(this) internalHandler.post {
MPVProperty.observedProperties.forEach(MPVLib::observeProperty) MPVLib.addObserver(this@MpvPlayer)
MPVProperty.observedProperties.forEach(MPVLib::observeProperty)
MPVLib.addLogObserver(MpvLogger())
}
availableCommands = availableCommands =
Player.Commands Player.Commands
@ -167,13 +176,11 @@ class MpvPlayer(
mediaItems: List<MediaItem>, mediaItems: List<MediaItem>,
resetPosition: Boolean, resetPosition: Boolean,
) { ) {
throwIfReleased()
if (DEBUG) Timber.v("setMediaItems") if (DEBUG) Timber.v("setMediaItems")
throwIfReleased()
mediaItems.firstOrNull()?.let { mediaItems.firstOrNull()?.let {
mediaItem = it
if (surface != null) { if (surface != null) {
loadFile(it) sendCommand(MpvCommand.LOAD_FILE, MediaAndPosition(it, C.TIME_UNSET))
} }
} }
} }
@ -184,8 +191,11 @@ class MpvPlayer(
startPositionMs: Long, startPositionMs: Long,
) { ) {
if (DEBUG) Timber.v("setMediaItems") if (DEBUG) Timber.v("setMediaItems")
this.startPositionMs = startPositionMs throwIfReleased()
setMediaItems(mediaItems.subList(startIndex, mediaItems.size), false) sendCommand(
MpvCommand.LOAD_FILE,
MediaAndPosition(mediaItems[startIndex], startPositionMs),
)
} }
override fun addMediaItems( override fun addMediaItems(
@ -214,17 +224,19 @@ class MpvPlayer(
override fun prepare() { override fun prepare() {
if (DEBUG) Timber.v("prepare") if (DEBUG) Timber.v("prepare")
durationMs = 0L playbackState.update {
positionMs = -1L it.copy(
playbackState = STATE_READY state = Player.STATE_READY,
)
}
} }
override fun getPlaybackState(): Int { override fun getPlaybackState(): Int {
if (DEBUG) Timber.v("getPlaybackState") if (DEBUG) Timber.v("getPlaybackState")
return playbackState return playbackState.load().state
} }
override fun getPlaybackSuppressionReason(): Int = Player.PLAYBACK_SUPPRESSION_REASON_NONE override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE
override fun getPlayerError(): PlaybackException? { override fun getPlayerError(): PlaybackException? {
TODO("Not yet implemented") TODO("Not yet implemented")
@ -232,32 +244,21 @@ class MpvPlayer(
override fun setPlayWhenReady(playWhenReady: Boolean) { override fun setPlayWhenReady(playWhenReady: Boolean) {
if (isReleased) return if (isReleased) return
if (DEBUG) Timber.v("setPlayWhenReady") if (DEBUG) Timber.v("setPlayWhenReady: $playWhenReady")
if (playWhenReady) { sendCommand(MpvCommand.PLAY_PAUSE, playWhenReady)
MPVLib.setPropertyBoolean("pause", false)
} else {
MPVLib.setPropertyBoolean("pause", true)
}
notifyListeners(EVENT_PLAY_WHEN_READY_CHANGED) {
onPlayWhenReadyChanged(
playWhenReady,
PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
)
}
} }
override fun getPlayWhenReady(): Boolean { override fun getPlayWhenReady(): Boolean {
if (DEBUG) Timber.v("getPlayWhenReady") if (DEBUG) Timber.v("getPlayWhenReady")
if (isReleased) return false if (isReleased) return false
val isPaused = MPVLib.getPropertyBoolean("pause") ?: this.isPaused return !playbackState.load().isPaused
return !isPaused
} }
override fun setRepeatMode(repeatMode: Int) { override fun setRepeatMode(repeatMode: Int) {
if (DEBUG) Timber.v("setRepeatMode") if (DEBUG) Timber.v("setRepeatMode")
} }
override fun getRepeatMode(): Int = Player.REPEAT_MODE_OFF override fun getRepeatMode(): Int = REPEAT_MODE_OFF
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {
if (DEBUG) Timber.v("setShuffleModeEnabled") if (DEBUG) Timber.v("setShuffleModeEnabled")
@ -265,7 +266,7 @@ class MpvPlayer(
override fun getShuffleModeEnabled(): Boolean = false override fun getShuffleModeEnabled(): Boolean = false
override fun isLoading(): Boolean = isLoadingFile override fun isLoading(): Boolean = playbackState.load().isLoadingFile
override fun getSeekBackIncrement(): Long = 10_000 override fun getSeekBackIncrement(): Long = 10_000
@ -275,30 +276,34 @@ class MpvPlayer(
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
if (DEBUG) Timber.v("setPlaybackParameters") if (DEBUG) Timber.v("setPlaybackParameters")
MPVLib.setPropertyDouble("speed", playbackParameters.speed.toDouble()) sendCommand(MpvCommand.SET_SPEED, playbackParameters.speed)
} }
override fun getPlaybackParameters(): PlaybackParameters { override fun getPlaybackParameters(): PlaybackParameters {
if (DEBUG) Timber.v("getPlaybackParameters") if (DEBUG) Timber.v("getPlaybackParameters")
val speed = MPVLib.getPropertyDouble("speed")?.toFloat() ?: 1f return PlaybackParameters(playbackState.load().speed)
return PlaybackParameters(speed)
} }
override fun stop() { override fun stop() {
if (DEBUG) Timber.v("stop") if (DEBUG) Timber.v("stop")
if (isReleased) return if (isReleased) return
pause() pause()
mediaItem = null internalHandler.removeCallbacks(updatePlaybackState)
positionMs = -1L playbackState.update {
durationMs = 0L PlaybackState.EMPTY
playbackState = STATE_IDLE }
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) } notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) }
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_IDLE) } notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_IDLE) }
} }
override fun release() { override fun release() {
Timber.i("release") Timber.i("release")
internalHandler.removeCallbacks(updatePlaybackState)
playbackState.update {
PlaybackState.EMPTY
}
if (!isReleased) { if (!isReleased) {
thread.quit()
MPVLib.removeObserver(this) MPVLib.removeObserver(this)
clearVideoSurfaceView(null) clearVideoSurfaceView(null)
MPVLib.destroy() MPVLib.destroy()
@ -309,7 +314,7 @@ class MpvPlayer(
override fun getCurrentTracks(): Tracks { override fun getCurrentTracks(): Tracks {
if (DEBUG) Timber.v("getCurrentTracks") if (DEBUG) Timber.v("getCurrentTracks")
if (isReleased) return Tracks.EMPTY if (isReleased) return Tracks.EMPTY
return getTracks() return playbackState.load().tracks
} }
override fun getTrackSelectionParameters(): TrackSelectionParameters { override fun getTrackSelectionParameters(): TrackSelectionParameters {
@ -323,16 +328,16 @@ class MpvPlayer(
override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) { override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) {
Timber.v("TrackSelection: setTrackSelectionParameters %s", parameters) Timber.v("TrackSelection: setTrackSelectionParameters %s", parameters)
if (isReleased) return if (isReleased) return
val tracks = getTracks() val tracks = playbackState.load().tracks
if (C.TRACK_TYPE_TEXT in parameters.disabledTrackTypes) { if (C.TRACK_TYPE_TEXT in parameters.disabledTrackTypes) {
// Subtitles disabled // Subtitles disabled
Timber.v("TrackSelection: disabling subtitles") Timber.v("TrackSelection: disabling subtitles")
MPVLib.setPropertyString("sid", "no") sendCommand(MpvCommand.SET_TRACK_SELECTION, TrackSelection("sid", "no"))
} }
if (C.TRACK_TYPE_AUDIO in parameters.disabledTrackTypes) { if (C.TRACK_TYPE_AUDIO in parameters.disabledTrackTypes) {
// Audio disabled // Audio disabled
Timber.v("TrackSelection: disabling audio") Timber.v("TrackSelection: disabling audio")
MPVLib.setPropertyString("aid", "no") sendCommand(MpvCommand.SET_TRACK_SELECTION, TrackSelection("aid", "no"))
} }
Timber.v("TrackSelection: Got ${parameters.overrides.size} overrides") Timber.v("TrackSelection: Got ${parameters.overrides.size} overrides")
parameters.overrides.forEach { (trackGroup, trackSelectionOverride) -> parameters.overrides.forEach { (trackGroup, trackSelectionOverride) ->
@ -350,7 +355,10 @@ class MpvPlayer(
} }
Timber.v("TrackSelection: activating %s %s '%s'", propertyName, trackId, id) Timber.v("TrackSelection: activating %s %s '%s'", propertyName, trackId, id)
if (trackId != null && propertyName != null) { if (trackId != null && propertyName != null) {
MPVLib.setPropertyString(propertyName, trackId) sendCommand(
MpvCommand.SET_TRACK_SELECTION,
TrackSelection(propertyName, trackId),
)
true true
} else { } else {
false false
@ -397,24 +405,13 @@ class MpvPlayer(
override fun getDuration(): Long { override fun getDuration(): Long {
if (DEBUG) Timber.v("getDuration") if (DEBUG) Timber.v("getDuration")
if (isReleased) { return playbackState.load().durationMs
return durationMs
}
val duration =
MPVLib.getPropertyDouble("duration/full")?.seconds?.inWholeMilliseconds
?: durationMs
return duration
} }
override fun getCurrentPosition(): Long { override fun getCurrentPosition(): Long {
if (DEBUG) Timber.v("getCurrentPosition") if (DEBUG) Timber.v("getCurrentPosition")
if (isReleased) { val state = playbackState.load()
return positionMs return state.positionMs
}
val position =
MPVLib.getPropertyDouble("time-pos/full")?.seconds?.inWholeMilliseconds
?: positionMs
return position
} }
override fun getBufferedPosition(): Long { override fun getBufferedPosition(): Long {
@ -425,7 +422,7 @@ class MpvPlayer(
override fun getTotalBufferedDuration(): Long { override fun getTotalBufferedDuration(): Long {
if (DEBUG) Timber.v("getTotalBufferedDuration") if (DEBUG) Timber.v("getTotalBufferedDuration")
if (isReleased) return 0 if (isReleased) return 0
return MPVLib.getPropertyDouble("demuxer-cache-duration")?.seconds?.inWholeMilliseconds ?: 0 return playbackState.load().bufferMs
} }
override fun isPlayingAd(): Boolean { override fun isPlayingAd(): Boolean {
@ -475,9 +472,8 @@ class MpvPlayer(
MPVLib.attachSurface(surface) MPVLib.attachSurface(surface)
MPVLib.setOptionString("force-window", "yes") MPVLib.setOptionString("force-window", "yes")
Timber.d("Attached surface") Timber.d("Attached surface")
mediaItem?.let(::loadFile) playbackState.load().media?.let {
if (mediaItem == null) { sendCommand(MpvCommand.LOAD_FILE, it)
Timber.w("mediaItem is null in setVideoSurfaceView")
} }
} else { } else {
clearVideoSurfaceView(null) clearVideoSurfaceView(null)
@ -485,11 +481,14 @@ class MpvPlayer(
} }
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { override fun clearVideoSurfaceView(surfaceView: SurfaceView?) {
Timber.d("clearVideoSurfaceView") if (surface == surfaceView?.holder?.surface) {
MPVLib.detachSurface() Timber.d("clearVideoSurfaceView")
MPVLib.setPropertyString("vo", "null") MPVLib.detachSurface()
MPVLib.setPropertyString("force-window", "no") MPVLib.setPropertyString("vo", "null")
mediaItem = null MPVLib.setPropertyString("force-window", "no")
} else {
Timber.w("clearVideoSurfaceView called with different surface")
}
} }
override fun setVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException() override fun setVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException()
@ -499,13 +498,7 @@ class MpvPlayer(
override fun getVideoSize(): VideoSize { override fun getVideoSize(): VideoSize {
if (DEBUG) Timber.v("getVideoSize") if (DEBUG) Timber.v("getVideoSize")
if (isReleased) return VideoSize.UNKNOWN if (isReleased) return VideoSize.UNKNOWN
val width = MPVLib.getPropertyInt("width") return playbackState.load().videoSize
val height = MPVLib.getPropertyInt("height")
return if (width != null && height != null) {
VideoSize(width, height)
} else {
VideoSize.UNKNOWN
}
} }
override fun getSurfaceSize(): Size = throw UnsupportedOperationException() override fun getSurfaceSize(): Size = throw UnsupportedOperationException()
@ -566,7 +559,11 @@ class MpvPlayer(
if (mediaItemIndex == C.INDEX_UNSET) { if (mediaItemIndex == C.INDEX_UNSET) {
return return
} }
MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0) sendCommand(MpvCommand.SEEK, positionMs)
}
override fun onTrackSelectionsInvalidated() {
// no-op
} }
override fun eventProperty(property: String) { override fun eventProperty(property: String) {
@ -579,7 +576,11 @@ class MpvPlayer(
) { ) {
if (DEBUG) Timber.v("eventPropertyLong: $property=$value") if (DEBUG) Timber.v("eventPropertyLong: $property=$value")
when (property) { when (property) {
MPVProperty.POSITION -> positionMs = value.seconds.inWholeMilliseconds MPVProperty.POSITION -> {
playbackState.update {
it.copy(positionMs = value.seconds.inWholeMilliseconds)
}
}
} }
} }
@ -590,7 +591,9 @@ class MpvPlayer(
if (DEBUG) Timber.v("eventPropertyBoolean: $property=$value") if (DEBUG) Timber.v("eventPropertyBoolean: $property=$value")
when (property) { when (property) {
MPVProperty.PAUSED -> { MPVProperty.PAUSED -> {
isPaused = value playbackState.update {
it.copy(isPaused = value)
}
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(!value) } notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(!value) }
} }
} }
@ -609,52 +612,58 @@ class MpvPlayer(
) { ) {
Timber.v("eventPropertyDouble: $property=$value") Timber.v("eventPropertyDouble: $property=$value")
when (property) { when (property) {
MPVProperty.DURATION -> durationMs = value.seconds.inWholeMilliseconds MPVProperty.DURATION -> {
playbackState.update {
it.copy(durationMs = value.seconds.inWholeMilliseconds)
}
}
} }
} }
override fun event(eventId: Int) { override fun event(eventId: Int) {
Timber.v("event: thread=${Thread.currentThread().name}, eventId=$eventId")
when (eventId) { when (eventId) {
// MPV_EVENT_START_FILE -> { MPV_EVENT_START_FILE -> {
// } internalHandler.post(updatePlaybackState)
}
MPV_EVENT_FILE_LOADED -> { MPV_EVENT_FILE_LOADED -> {
isLoadingFile = false playbackState.update {
it.copy(isLoadingFile = false)
}
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) }
Timber.d("event: MPV_EVENT_FILE_LOADED") Timber.d("event: MPV_EVENT_FILE_LOADED")
mediaItem?.let { internalHandler.post(updatePlaybackState)
it.localConfiguration?.subtitleConfigurations?.forEach { playbackState.load().media?.mediaItem?.let { media ->
media.localConfiguration?.subtitleConfigurations?.forEach {
val url = it.uri.toString() val url = it.uri.toString()
val title = it.label ?: "External Subtitles" val title = it.label ?: "External Subtitles"
Timber.v("Adding external subtitle track '$title'") Timber.v("Adding external subtitle track '$title'")
MPVLib.command(arrayOf("sub-add", url, "auto", title)) if (it.language.isNotNullOrBlank()) {
MPVLib.command(arrayOf("sub-add", url, "auto", title, it.language!!))
} else {
MPVLib.command(arrayOf("sub-add", url, "auto", title))
}
} }
} }
notifyListeners(EVENT_RENDERED_FIRST_FRAME) { onRenderedFirstFrame() } notifyListeners(EVENT_RENDERED_FIRST_FRAME) { onRenderedFirstFrame() }
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(true) } notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(true) }
getTracks().let { updateTracksAndNotify()
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
}
} }
MPV_EVENT_PLAYBACK_RESTART -> { MPV_EVENT_PLAYBACK_RESTART -> {
Timber.d("event: MPV_EVENT_PLAYBACK_RESTART") Timber.d("event: MPV_EVENT_PLAYBACK_RESTART")
getTracks().let { updateTracksAndNotify()
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
}
} }
MPV_EVENT_AUDIO_RECONFIG -> { MPV_EVENT_AUDIO_RECONFIG -> {
Timber.d("event: MPV_EVENT_AUDIO_RECONFIG") Timber.d("event: MPV_EVENT_AUDIO_RECONFIG")
getTracks().let { updateTracksAndNotify()
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
}
} }
MPV_EVENT_VIDEO_RECONFIG -> { MPV_EVENT_VIDEO_RECONFIG -> {
Timber.d("event: MPV_EVENT_VIDEO_RECONFIG") Timber.d("event: MPV_EVENT_VIDEO_RECONFIG")
getTracks().let { updateTracksAndNotify()
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) }
}
} }
MPV_EVENT_END_FILE -> { MPV_EVENT_END_FILE -> {
@ -676,6 +685,9 @@ class MpvPlayer(
notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) } notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) }
when (reason) { when (reason) {
MPV_END_FILE_REASON_EOF -> { MPV_END_FILE_REASON_EOF -> {
playbackState.update {
it.copy(state = Player.STATE_ENDED)
}
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
onPlaybackStateChanged(STATE_ENDED) onPlaybackStateChanged(STATE_ENDED)
} }
@ -704,25 +716,44 @@ class MpvPlayer(
} }
} }
private fun loadFile(mediaItem: MediaItem) { private fun updateTracksAndNotify() {
isLoadingFile = true val tracks = createTracks()
playbackState.update {
it.copy(tracks = tracks)
}
notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(tracks) }
}
private fun loadFile(media: MediaAndPosition) {
Timber.v("loadFile: media=$media")
playbackState.update {
it.copy(
isLoadingFile = true,
media = media,
)
}
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) } notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) }
val url = mediaItem.localConfiguration?.uri.toString() val url =
if (startPositionMs > 0) { media.mediaItem.localConfiguration
?.uri
.toString()
if (media.startPositionMs > 0) {
MPVLib.command( MPVLib.command(
arrayOf( arrayOf(
"loadfile", "loadfile",
url, url,
"replace", "replace",
"-1", "-1",
"start=${startPositionMs / 1000.0}", "start=${media.startPositionMs / 1000.0}",
), ),
) )
} else { } else {
MPVLib.command(arrayOf("loadfile", url, "replace", "-1")) MPVLib.command(arrayOf("loadfile", url, "replace", "-1"))
} }
MPVLib.setPropertyString("vo", "gpu") if (enableHardwareDecoding) {
MPVLib.setOptionString("vo", if (useGpuNext) "gpu-next" else "gpu")
}
Timber.d("Called loadfile") Timber.d("Called loadfile")
} }
@ -736,7 +767,7 @@ class MpvPlayer(
eventId: Int, eventId: Int,
block: Player.Listener.() -> Unit, block: Player.Listener.() -> Unit,
) { ) {
handler.post { mainHandler.post {
listeners.queueEvent(eventId) { listeners.queueEvent(eventId) {
block.invoke(it) block.invoke(it)
} }
@ -744,78 +775,14 @@ class MpvPlayer(
} }
} }
private fun getTracks(): Tracks {
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: return Tracks.EMPTY
val groups =
(0..<trackCount).mapNotNull { idx ->
val type = MPVLib.getPropertyString("track-list/$idx/type")
val id = MPVLib.getPropertyInt("track-list/$idx/id")
val lang = MPVLib.getPropertyString("track-list/$idx/lang")
val codec = MPVLib.getPropertyString("track-list/$idx/codec")
val codecDescription = MPVLib.getPropertyString("track-list/$idx/codec-desc")
val isDefault = MPVLib.getPropertyBoolean("track-list/$idx/default") ?: false
val isForced = MPVLib.getPropertyBoolean("track-list/$idx/forced") ?: false
val isExternal = MPVLib.getPropertyBoolean("track-list/$idx/external") ?: false
val isSelected = MPVLib.getPropertyBoolean("track-list/$idx/selected") ?: false
val channelCount = MPVLib.getPropertyInt("track-list/$idx/demux-channel-count")
val title = MPVLib.getPropertyString("track-list/$idx/title")
if (type != null && id != null) {
// TODO do we need the real mimetypes?
val mimeType =
when (type) {
"video" -> MimeTypes.BASE_TYPE_VIDEO + "/todo"
"audio" -> MimeTypes.BASE_TYPE_AUDIO + "/todo"
"sub" -> MimeTypes.BASE_TYPE_TEXT + "/todo"
else -> "unknown/todo"
}
var flags = 0
if (isDefault) flags = flags or C.SELECTION_FLAG_DEFAULT
if (isForced) flags = flags or C.SELECTION_FLAG_FORCED
val builder =
Format
.Builder()
.setId("$idx:$id")
.setCodecs(codec)
.setSampleMimeType(mimeType)
.setLanguage(lang)
.setLabel(listOfNotNull(title, codecDescription).joinToString(","))
.setSelectionFlags(flags)
if (type == "video" && isSelected) {
builder.setWidth(MPVLib.getPropertyInt("width") ?: -1)
builder.setHeight(MPVLib.getPropertyInt("height") ?: -1)
}
channelCount?.let(builder::setChannelCount)
val format = builder.build()
val trackGroup = TrackGroup(format)
val group =
Tracks.Group(
trackGroup,
false,
intArrayOf(C.FORMAT_HANDLED),
booleanArrayOf(isSelected),
)
group
} else {
null
}
}
return Tracks(groups)
}
override fun onTrackSelectionsInvalidated() {
// no-op
}
var subtitleDelaySeconds: Double var subtitleDelaySeconds: Double
get() { get() {
if (isReleased) return 0.0 if (isReleased) return 0.0
return MPVLib.getPropertyDouble("sub-delay") ?: 0.0 return playbackState.load().subtitleDelay
} }
set(value) { set(value) {
if (isReleased) return if (isReleased) return
MPVLib.setPropertyDouble("sub-delay", value) sendCommand(MpvCommand.SET_SUBTITLE_DELAY, value)
} }
var subtitleDelay: Duration var subtitleDelay: Duration
@ -827,11 +794,230 @@ class MpvPlayer(
if (isReleased) return if (isReleased) return
subtitleDelaySeconds = value.inWholeMilliseconds / 1000.0 subtitleDelaySeconds = value.inWholeMilliseconds / 1000.0
} }
private val updatePlaybackState: Runnable =
Runnable {
if (playbackState.load().media == null) {
return@Runnable
}
val positionMs =
MPVLib.getPropertyDouble("time-pos/full")?.seconds?.inWholeMilliseconds
?: C.TIME_UNSET
val bufferMs =
MPVLib.getPropertyDouble("demuxer-cache-duration")?.seconds?.inWholeMilliseconds
?: C.TIME_UNSET
val durationMs =
MPVLib.getPropertyDouble("duration/full")?.seconds?.inWholeMilliseconds
?: C.TIME_UNSET
val speed = MPVLib.getPropertyDouble("speed")?.toFloat() ?: 1f
val paused = MPVLib.getPropertyBoolean("pause") ?: false
val width = MPVLib.getPropertyInt("width")
val height = MPVLib.getPropertyInt("height")
val videoSize =
if (width != null && height != null) {
VideoSize(width, height)
} else {
VideoSize.UNKNOWN
}
playbackState.update {
it.copy(
timestamp = System.currentTimeMillis(),
positionMs = positionMs,
bufferMs = bufferMs,
durationMs = durationMs,
speed = speed,
isPaused = paused,
videoSize = videoSize,
)
}
}
private fun sendCommand(
cmd: MpvCommand,
obj: Any?,
) {
internalHandler.obtainMessage(cmd.ordinal, obj).sendToTarget()
}
override fun handleMessage(msg: Message): Boolean {
val cmd = MpvCommand.entries[msg.what]
Timber.v("handleMessage: cmd=$cmd")
when (cmd) {
MpvCommand.PLAY_PAUSE -> {
val playWhenReady = msg.obj as Boolean
MPVLib.setPropertyBoolean("pause", !playWhenReady)
playbackState.update {
it.copy(isPaused = !playWhenReady)
}
notifyListeners(EVENT_PLAY_WHEN_READY_CHANGED) {
onPlayWhenReadyChanged(
playWhenReady,
PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
)
}
}
MpvCommand.SET_TRACK_SELECTION -> {
val (propertyName, trackId) = msg.obj as TrackSelection
MPVLib.setPropertyString(propertyName, trackId)
updateTracksAndNotify()
}
MpvCommand.SEEK -> {
val positionMs = msg.obj as Long
MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0)
playbackState.update {
it.copy(positionMs = positionMs)
}
}
MpvCommand.SET_SPEED -> {
val value = msg.obj as Float
MPVLib.setPropertyDouble("speed", value.toDouble())
playbackState.update {
it.copy(speed = value)
}
}
MpvCommand.SET_SUBTITLE_DELAY -> {
val value = msg.obj as Double
MPVLib.setPropertyDouble("sub-delay", value)
playbackState.update {
it.copy(subtitleDelay = value)
}
}
MpvCommand.LOAD_FILE -> {
loadFile(msg.obj as MediaAndPosition)
}
}
return true
}
} }
fun MPVLib.setPropertyColor( fun MPVLib.setPropertyColor(
property: String, property: String,
color: Color, color: Color,
) = MPVLib.setPropertyString(property, color.mpvFormat) ) = setPropertyString(property, color.mpvFormat)
private val Color.mpvFormat: String get() = "$red/$green/$blue/$alpha" private val Color.mpvFormat: String get() = "$red/$green/$blue/$alpha"
@OptIn(UnstableApi::class)
private fun createTracks(): Tracks {
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: return Tracks.EMPTY
val groups =
(0..<trackCount).mapNotNull { idx ->
val type = MPVLib.getPropertyString("track-list/$idx/type")
val id = MPVLib.getPropertyInt("track-list/$idx/id")
val lang = MPVLib.getPropertyString("track-list/$idx/lang")
val codec = MPVLib.getPropertyString("track-list/$idx/codec")
val codecDescription = MPVLib.getPropertyString("track-list/$idx/codec-desc")
val isDefault = MPVLib.getPropertyBoolean("track-list/$idx/default") ?: false
val isForced = MPVLib.getPropertyBoolean("track-list/$idx/forced") ?: false
val isExternal = MPVLib.getPropertyBoolean("track-list/$idx/external") ?: false
val isSelected = MPVLib.getPropertyBoolean("track-list/$idx/selected") ?: false
val channelCount = MPVLib.getPropertyInt("track-list/$idx/demux-channel-count")
val title = MPVLib.getPropertyString("track-list/$idx/title")
if (type != null && id != null) {
// TODO do we need the real mimetypes?
val mimeType =
when (type) {
"video" -> MimeTypes.BASE_TYPE_VIDEO + "/todo"
"audio" -> MimeTypes.BASE_TYPE_AUDIO + "/todo"
"sub" -> MimeTypes.BASE_TYPE_TEXT + "/todo"
else -> "unknown/todo"
}
var flags = 0
if (isDefault) flags = flags or C.SELECTION_FLAG_DEFAULT
if (isForced) flags = flags or C.SELECTION_FLAG_FORCED
val builder =
Format
.Builder()
.apply {
if (isExternal) {
setId("$idx:e:$id")
} else {
setId("$idx:$id")
}
}.setCodecs(codec)
.setSampleMimeType(mimeType)
.setLanguage(lang)
.setLabel(listOfNotNull(title, codecDescription).joinToString(","))
.setSelectionFlags(flags)
if (type == "video" && isSelected) {
builder.setWidth(MPVLib.getPropertyInt("width") ?: -1)
builder.setHeight(MPVLib.getPropertyInt("height") ?: -1)
}
channelCount?.let(builder::setChannelCount)
val format = builder.build()
// Timber.v("$idx=$format")
val trackGroup = TrackGroup(format)
val group =
Tracks.Group(
trackGroup,
false,
intArrayOf(C.FORMAT_HANDLED),
booleanArrayOf(isSelected),
)
group
} else {
null
}
}
return Tracks(groups)
}
private data class PlaybackState(
val timestamp: Long,
val isLoadingFile: Boolean,
val media: MediaAndPosition?,
val positionMs: Long,
val bufferMs: Long,
val durationMs: Long,
val isPaused: Boolean,
val speed: Float,
val subtitleDelay: Double,
val videoSize: VideoSize,
@param:Player.State val state: Int,
val tracks: Tracks,
) {
companion object {
val EMPTY =
PlaybackState(
timestamp = C.TIME_UNSET,
isLoadingFile = false,
media = null,
positionMs = C.TIME_UNSET,
durationMs = C.TIME_UNSET,
tracks = Tracks.EMPTY,
bufferMs = C.TIME_UNSET,
isPaused = false,
speed = 1f,
videoSize = VideoSize.UNKNOWN,
state = Player.STATE_IDLE,
subtitleDelay = 0.0,
)
}
}
private data class TrackSelection(
val property: String,
val trackId: String,
)
private data class MediaAndPosition(
val mediaItem: MediaItem,
val startPositionMs: Long,
)
enum class MpvCommand {
PLAY_PAUSE,
SEEK,
SET_TRACK_SELECTION,
SET_SPEED,
SET_SUBTITLE_DELAY,
LOAD_FILE,
}

View file

@ -2,7 +2,7 @@
#include <android/log.h> #include <android/log.h>
#define DEBUG 1 #define DEBUG 0
#define LOG_TAG "mpv" #define LOG_TAG "mpv"
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) #define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)

View file

@ -4,4 +4,16 @@ This scripts are adapted from https://github.com/mpv-android/mpv-android/tree/ae
## Instructions ## Instructions
TODO ```bash
cd scripts/mpv
./get_dependencies
export NDK_PATH=... # Such as ~/Library/Android/sdk/ndk/29.0.14206865
# Build arm64
PATH="$PATH:$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin" ./buildall.sh --clean --arch arm64 mpv
# Build arm32
PATH="$PATH:$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin" ./buildall.sh mpv
cd ../..
env PREFIX32="$(realpath scripts/mpv/prefix/armv7l)" PREFIX64="$(realpath scripts/mpv/prefix/arm64)" "$NDK_PATH/ndk-build" -C app/src/main -j && cp -fr app/src/main/libs/ app/src/main/jnilibs/
```