Start using media3 player as a wrapper

This commit is contained in:
2026-04-20 22:58:44 +02:00
parent f535b2f614
commit a68eb16fb3
3 changed files with 435 additions and 131 deletions
+3
View File
@@ -144,6 +144,9 @@ dependencies {
implementation project(":react-native-nitro-modules")
implementation("dev.jdtech.mpv:libmpv:1.0.0")
implementation("androidx.media3:media3-common:1.4.1")
implementation("androidx.media3:media3-session:1.4.1")
implementation("androidx.media3:media3-ui:1.4.1")
}
if (isNewArchitectureEnabled()) {
@@ -0,0 +1,388 @@
package dev.zoriya.omni
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.os.Looper
import android.view.Surface
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.SimpleBasePlayer
import androidx.media3.common.util.Size
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.margelo.nitro.omni.Source
import com.margelo.nitro.omni.Track
import dev.jdtech.mpv.MPVLib
@SuppressLint("UnsafeOptInUsageError")
class MpvPlayer(ctx: Context) : SimpleBasePlayer(Looper.getMainLooper()), MPVLib.EventObserver {
val mpv: MPVLib = MPVLib.create(ctx) ?: throw Error("Failed to initialize MPVLib")
private var source: Source? = null
private var mediaItem: MediaItem? = null
private var released = false
private var surfaceSize: Size = Size.UNKNOWN
private val availableCommands: Player.Commands = Player.Commands.Builder()
.add(COMMAND_PLAY_PAUSE)
.add(COMMAND_PREPARE)
.add(COMMAND_STOP)
.add(COMMAND_RELEASE)
.add(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
.add(COMMAND_SEEK_BACK)
.add(COMMAND_SEEK_FORWARD)
.add(COMMAND_SET_SPEED_AND_PITCH)
.add(COMMAND_GET_VOLUME)
.add(COMMAND_SET_VOLUME)
.add(COMMAND_SET_VIDEO_SURFACE)
.add(COMMAND_GET_CURRENT_MEDIA_ITEM)
.add(COMMAND_GET_METADATA)
.add(COMMAND_GET_TIMELINE)
.add(Player.COMMAND_GET_MEDIA_ITEMS_METADATA)
.build()
init {
mpv.setOptionString("vo", "gpu-next")
mpv.setOptionString("force-window", "yes")
mpv.setOptionString("gpu-context", "android")
mpv.setOptionString("opengl-es", "yes")
mpv.setOptionString("hwdec", "mediacodec-copy")
mpv.setOptionString("profile", "fast")
mpv.setOptionString("cache", "yes")
mpv.setOptionString("cache-pause-initial", "yes")
mpv.setOptionString("demuxer-max-bytes", "150MiB")
mpv.setOptionString("demuxer-max-back-bytes", "75MiB")
mpv.setOptionString("demuxer-readahead-secs", "20")
mpv.setOptionString("save-position-on-quit", "no")
mpv.setOptionString("ytdl", "no")
mpv.setOptionString("hr-seek", "no")
mpv.init()
mpv.addObserver(this)
mpv.observeProperty("core-idle", MPVLib.MpvFormat.MPV_FORMAT_FLAG)
mpv.observeProperty("paused-for-cache", MPVLib.MpvFormat.MPV_FORMAT_FLAG)
mpv.observeProperty("pause", MPVLib.MpvFormat.MPV_FORMAT_FLAG)
mpv.observeProperty("eof-reached", MPVLib.MpvFormat.MPV_FORMAT_FLAG)
mpv.observeProperty("time-pos", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE)
mpv.observeProperty("demuxer-cache-time", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE)
mpv.observeProperty("duration", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE)
mpv.observeProperty("speed", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE)
mpv.observeProperty("volume", MPVLib.MpvFormat.MPV_FORMAT_DOUBLE)
}
fun setSource(value: Source) {
source = value
mediaItem = buildMediaItem(value)
mpv.command(arrayOf("stop"))
val src = value.src.firstOrNull()
if (src == null) {
invalidateState()
return
}
mpv.setPropertyString(
"http-header-fields",
src.headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
)
mpv.setPropertyDouble("start", (value.startTime ?: 0.0).coerceAtLeast(0.0))
mpv.command(arrayOf("loadfile", src.uri, "replace"))
for (subtitle in value.subtitles) {
mpv.command(arrayOf("sub-add", subtitle.link, "cached"))
}
invalidateState()
}
fun setSurface(surface: Surface?) {
if (surface == null) {
mpv.setOptionString("vo", "null")
mpv.setOptionString("force-window", "no")
mpv.detachSurface()
surfaceSize = Size.UNKNOWN
} else {
mpv.attachSurface(surface)
mpv.setOptionString("force-window", "yes")
mpv.setOptionString("vo", "gpu-next")
}
invalidateState()
}
fun setSurfaceSize(width: Int, height: Int) {
if (width <= 0 || height <= 0) return
surfaceSize = Size(width, height)
mpv.setPropertyString("android-surface-size", "${width}x${height}")
invalidateState()
}
fun getTracks(type: String): Array<Track> {
val count = mpv.getPropertyInt("track-list/count") ?: 0
if (count <= 0) return emptyArray()
val tracks = ArrayList<Track>(count)
for (i in 0 until count) {
val base = "track-list/$i"
val trackType = mpv.getPropertyString("$base/type") ?: continue
if (trackType != type) continue
val id = mpv.getPropertyInt("$base/id") ?: continue
val selected = mpv.getPropertyBoolean("$base/selected") ?: false
val label = mpv.getPropertyString("$base/title") ?: mpv.getPropertyString("$base/codec")
val language = mpv.getPropertyString("$base/lang")
tracks.add(
Track(
id = id.toString(),
label = label,
language = language,
selected = selected
)
)
}
return tracks.toTypedArray()
}
fun selectVideo(video: Track) {
val id = video.id.toIntOrNull() ?: return
mpv.setPropertyInt("vid", id)
}
fun selectAudio(audio: Track) {
val id = audio.id.toIntOrNull() ?: return
mpv.setPropertyInt("aid", id)
}
fun selectSubtitle(subtitle: Track?) {
when (subtitle) {
null -> mpv.setPropertyString("sid", "no")
else -> {
val id = subtitle.id.toIntOrNull() ?: return
mpv.setPropertyInt("sid", id)
}
}
}
fun seekBy(offsetSeconds: Double) {
mpv.command(arrayOf("seek", offsetSeconds.toString(), "relative"))
invalidateState()
}
fun getCurrentTimeSeconds(): Double {
return (mpv.getPropertyDouble("time-pos") ?: 0.0).coerceAtLeast(0.0)
}
fun setCurrentTimeSeconds(value: Double) {
mpv.command(arrayOf("seek", value.coerceAtLeast(0.0).toString(), "absolute"))
invalidateState()
}
fun getBufferedSeconds(): Double {
return (mpv.getPropertyDouble("demuxer-cache-time") ?: 0.0).coerceAtLeast(0.0)
}
fun getDurationSeconds(): Double {
return (mpv.getPropertyDouble("duration") ?: 0.0).coerceAtLeast(0.0)
}
fun getPlaybackRate(): Double {
return mpv.getPropertyDouble("speed") ?: 1.0
}
fun setPlaybackRate(value: Double) {
mpv.setPropertyDouble("speed", value.coerceAtLeast(0.0))
invalidateState()
}
fun isMuted(): Boolean {
return mpv.getPropertyBoolean("mute") ?: false
}
fun setMuted(value: Boolean) {
mpv.setPropertyBoolean("mute", value)
invalidateState()
}
fun getVolume01(): Double {
return (mpv.getPropertyDouble("volume") ?: 100.0).coerceIn(0.0, 100.0) / 100.0
}
fun setVolume01(value: Double) {
mpv.setPropertyDouble("volume", value.coerceIn(0.0, 1.0) * 100.0)
invalidateState()
}
fun isPlayingMpv(): Boolean {
return !(mpv.getPropertyBoolean("pause") ?: true)
}
fun hasPrevMetadata(): Boolean = source?.metadata?.hasPrev ?: false
fun hasNextMetadata(): Boolean = source?.metadata?.hasNext ?: false
private fun buildMediaItem(value: Source): MediaItem? {
val src = value.src.firstOrNull() ?: return null
val metadata = value.metadata
val mediaMetadataBuilder = MediaMetadata.Builder()
.setTitle(metadata?.title)
.setAlbumTitle(metadata?.album)
.setArtist(metadata?.artist)
metadata?.imageLink?.let {
mediaMetadataBuilder.setArtworkUri(Uri.parse(it))
}
return MediaItem.Builder()
.setUri(src.uri)
.setMediaId(src.uri)
.setMediaMetadata(mediaMetadataBuilder.build())
.build()
}
private fun playbackState(): Int {
val ended = mpv.getPropertyBoolean("eof-reached") ?: false
if (ended) return STATE_ENDED
val idle = mpv.getPropertyBoolean("core-idle") ?: false
if (idle) return STATE_IDLE
val loading = mpv.getPropertyBoolean("paused-for-cache") ?: false
return if (loading) STATE_BUFFERING else STATE_READY
}
override fun getState(): State {
val isPaused = mpv.getPropertyBoolean("pause") ?: true
val speed = (mpv.getPropertyDouble("speed") ?: 1.0).toFloat().coerceAtLeast(0f)
val volume = (mpv.getPropertyDouble("volume") ?: 100.0).coerceIn(0.0, 100.0).toFloat() / 100f
val posMs = (getCurrentTimeSeconds() * 1000.0).toLong()
val bufferedMs = (getBufferedSeconds() * 1000.0).toLong()
val durationMs = (getDurationSeconds() * 1000.0).toLong()
val builder = State.Builder()
.setAvailableCommands(availableCommands)
.setPlayWhenReady(!isPaused, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
.setPlaybackState(playbackState())
.setPlaybackSuppressionReason(PLAYBACK_SUPPRESSION_REASON_NONE)
.setIsLoading(mpv.getPropertyBoolean("paused-for-cache") ?: false)
.setPlaybackParameters(PlaybackParameters(speed))
.setVolume(volume)
.setSurfaceSize(surfaceSize)
.setContentPositionMs(posMs)
.setContentBufferedPositionMs { posMs + bufferedMs }
.setTotalBufferedDurationMs { bufferedMs }
val currentMediaItem = mediaItem
if (currentMediaItem != null) {
val mediaItemData = MediaItemData.Builder(currentMediaItem.mediaId)
.setMediaItem(currentMediaItem)
.setMediaMetadata(currentMediaItem.mediaMetadata)
.setIsSeekable(true)
.setDurationUs(if (durationMs > 0L) durationMs * 1000L else C.TIME_UNSET)
.build()
builder
.setPlaylist(listOf(mediaItemData))
.setCurrentMediaItemIndex(0)
}
return builder.build()
}
override fun handlePrepare(): ListenableFuture<*> {
return Futures.immediateFuture(null)
}
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
mpv.setPropertyBoolean("pause", !playWhenReady)
invalidateState()
return Futures.immediateFuture(null)
}
override fun handleSetPlaybackParameters(playbackParameters: PlaybackParameters): ListenableFuture<*> {
mpv.setPropertyDouble("speed", playbackParameters.speed.coerceAtLeast(0f).toDouble())
invalidateState()
return Futures.immediateFuture(null)
}
override fun handleSeek(
mediaItemIndex: Int,
positionMs: Long,
seekCommand: Int
): ListenableFuture<*> {
if (mediaItemIndex != C.INDEX_UNSET && mediaItemIndex != 0) {
return Futures.immediateFuture(null)
}
val targetMs = if (positionMs == C.TIME_UNSET) 0L else positionMs.coerceAtLeast(0L)
mpv.command(arrayOf("seek", (targetMs / 1000.0).toString(), "absolute"))
invalidateState()
return Futures.immediateFuture(null)
}
override fun handleSetVolume(volume: Float): ListenableFuture<*> {
mpv.setPropertyDouble("volume", volume.coerceIn(0f, 1f) * 100.0)
invalidateState()
return Futures.immediateFuture(null)
}
override fun handleSetVideoOutput(videoOutput: Any): ListenableFuture<*> {
if (videoOutput is Surface) {
setSurface(videoOutput)
}
return Futures.immediateFuture(null)
}
override fun handleClearVideoOutput(videoOutput: Any): ListenableFuture<*> {
if (videoOutput is Surface) {
setSurface(null)
}
return Futures.immediateFuture(null)
}
override fun handleStop(): ListenableFuture<*> {
mpv.command(arrayOf("stop"))
invalidateState()
return Futures.immediateFuture(null)
}
override fun handleRelease(): ListenableFuture<*> {
if (!released) {
released = true
mpv.removeObserver(this)
mpv.detachSurface()
mpv.destroy()
}
return Futures.immediateFuture(null)
}
override fun event(event: Int) {
invalidateState()
}
override fun eventProperty(property: String) {
invalidateState()
}
override fun eventProperty(property: String, value: Long) {
invalidateState()
}
override fun eventProperty(property: String, value: Double) {
invalidateState()
}
override fun eventProperty(property: String, value: Boolean) {
invalidateState()
}
override fun eventProperty(property: String, value: String) {
invalidateState()
}
}
@@ -1,145 +1,96 @@
package dev.zoriya.omni
import android.util.Log
import androidx.media3.common.Player
import com.margelo.nitro.NitroModules
import com.margelo.nitro.omni.Source
import com.margelo.nitro.omni.HybridOmniPlayerSpec
import com.margelo.nitro.omni.PlayerStatus
import com.margelo.nitro.omni.Rendition
import com.margelo.nitro.omni.Source
import com.margelo.nitro.omni.Track
import dev.jdtech.mpv.MPVLib
import dev.zoriya.omni.utils.deferredObservable
class OmniPlayer : HybridOmniPlayerSpec() {
val ctx = NitroModules.applicationContext ?: throw Error("No Context available!")
val player = MPVLib.create(ctx) ?: throw Error("Failed to initialize MPVLib")
override val eventMap = EventMap(player)
init {
// `vo` is effectively fixed after init in libmpv.
// Initializing with `null` keeps audio working but prevents video output forever.
player.setOptionString("vo", "gpu-next")
player.setOptionString("force-window", "yes")
player.setOptionString("gpu-context", "android")
player.setOptionString("opengl-es", "yes")
player.setOptionString("hwdec", "mediacodec-copy")
player.setOptionString("profile", "fast")
player.setOptionString("cache", "yes")
player.setOptionString("cache-pause-initial", "yes")
player.setOptionString("demuxer-max-bytes", "150MiB")
player.setOptionString("demuxer-max-back-bytes", "75MiB")
player.setOptionString("demuxer-readahead-secs", "20")
player.setOptionString("save-position-on-quit", "no")
player.setOptionString("ytdl", "no")
// seek to keyframes
player.setOptionString("hr-seek", "no")
player.init()
}
val player = MpvPlayer(ctx)
override val eventMap = EventMap(player.mpv)
private val mediaSessionBridge = OmniMediaSessionBridge(ctx, this)
override fun dispose() {
super.dispose()
player.detachSurface()
mediaSessionBridge.dispose()
eventMap.dispose()
player.destroy()
player.release()
}
override var source: Source by deferredObservable { _, _, new ->
Log.e("omni", "Chaning source")
player.command(arrayOf("stop"))
val src = new.src.firstOrNull() ?: return@deferredObservable
player.setPropertyString(
"http-header-fields",
src.headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
)
new.startTime?.let { start ->
player.setPropertyDouble("start", start.coerceAtLeast(0.0))
}
// TODO: set video/audio/sub specified
player.command(arrayOf("loadfile", src.uri, "replace"))
for (subtitle in new.subtitles) {
player.command(arrayOf("sub-add", subtitle.link, "cached"))
}
player.setSource(new)
mediaSessionBridge.onSourceChanged()
}
fun setSurface(surface: android.view.Surface?) {
if (surface == null) {
player.setOptionString("vo", "null")
player.setOptionString("force-window", "no")
player.detachSurface()
} else {
player.attachSurface(surface)
player.setOptionString("force-window", "yes")
player.setOptionString("vo", "gpu-next")
}
player.setSurface(surface)
}
fun setSurfaceSize(width: Int, height: Int) {
if (width <= 0 || height <= 0) return
player.setPropertyString("android-surface-size", "${width}x${height}")
player.setSurfaceSize(width, height)
}
override val hasPrev get() = source.metadata?.hasPrev ?: false
override val hasNext get() = source.metadata?.hasNext ?: false
override val hasPrev get() = player.hasPrevMetadata()
override val hasNext get() = player.hasNextMetadata()
override val status: PlayerStatus
get() {
val loading = player.getPropertyBoolean("paused-for-cache") ?: false
val idle = player.getPropertyBoolean("core-idle") ?: false
return when {
loading -> PlayerStatus.LOADING
idle -> PlayerStatus.IDLE
get() = when (player.playbackState) {
Player.STATE_IDLE -> PlayerStatus.IDLE
Player.STATE_ENDED -> PlayerStatus.IDLE
Player.STATE_BUFFERING -> PlayerStatus.LOADING
else -> PlayerStatus.READYTOPLAY
}
}
override val isPlaying get() = !(player.getPropertyBoolean("pause") ?: true)
override val isPlaying get() = player.isPlaying
override var currentTime
get() = (player.getPropertyDouble("time-pos") ?: 0.0).coerceAtLeast(0.0)
get() = player.getCurrentTimeSeconds()
set(value) {
player.command(arrayOf("seek", value.coerceAtLeast(0.0).toString(), "absolute"))
player.setCurrentTimeSeconds(value)
}
override val buffered
get() = (player.getPropertyDouble("demuxer-cache-time") ?: 0.0).coerceAtLeast(0.0)
override val duration get() = (player.getPropertyDouble("duration") ?: 0.0).coerceAtLeast(0.0)
override val buffered get() = player.getBufferedSeconds()
override val duration get() = player.getDurationSeconds()
override var playbackRate
get() = player.getPropertyDouble("speed") ?: 1.0
get() = player.getPlaybackRate()
set(value) {
player.setPropertyDouble("speed", value.coerceAtLeast(0.0))
player.setPlaybackRate(value)
}
override var muted
get() = player.getPropertyBoolean("mute") ?: false
get() = player.isMuted()
set(value) {
player.setPropertyBoolean("mute", value)
player.setMuted(value)
}
override var volume
get() = (player.getPropertyDouble("volume") ?: 100.0).coerceIn(0.0, 100.0) / 100.0
set(value) {
player.setPropertyDouble("volume", value.coerceIn(0.0, 1.0) * 100.0)
}
override val videos get() = getTracks("video")
override val audios get() = getTracks("audio")
override val subtitles get() = getTracks("sub")
// TODO: use `edition-list`
override var volume
get() = player.getVolume01()
set(value) {
player.setVolume01(value)
}
override val videos get() = player.getTracks("video")
override val audios get() = player.getTracks("audio")
override val subtitles get() = player.getTracks("sub")
override val rendition: Array<Rendition> get() = emptyArray()
override fun play() {
player.setPropertyBoolean("pause", false)
player.play()
}
override fun pause() {
player.setPropertyBoolean("pause", true)
player.pause()
}
override fun seekBy(offset: Double) {
player.command(arrayOf("seek", offset.toString(), "relative"))
player.seekBy(offset)
}
override fun playPrev() {
@@ -151,55 +102,17 @@ class OmniPlayer : HybridOmniPlayerSpec() {
}
override fun selectVideo(video: Track) {
val id = video.id.toIntOrNull() ?: return
player.setPropertyInt("vid", id)
player.selectVideo(video)
}
override fun selectAudio(audio: Track) {
val id = audio.id.toIntOrNull() ?: return
player.setPropertyInt("aid", id)
player.selectAudio(audio)
}
override fun selectSubtitle(subtitle: Track?) {
when (subtitle) {
null -> player.setPropertyString("sid", "no")
else -> {
val id = subtitle.id.toIntOrNull() ?: return
player.setPropertyInt("sid", id)
}
}
player.selectSubtitle(subtitle)
}
override fun selectRendition(rendition: Rendition?) {
// Not supported by libVLC MediaPlayer track APIs.
}
private fun getTracks(type: String): Array<Track> {
val count = player.getPropertyInt("track-list/count") ?: 0
if (count <= 0) return emptyArray()
val tracks = ArrayList<Track>(count)
for (i in 0 until count) {
val base = "track-list/$i"
val trackType = player.getPropertyString("$base/type") ?: continue
if (trackType != type) continue
val id = player.getPropertyInt("$base/id") ?: continue
val selected = player.getPropertyBoolean("$base/selected") ?: false
val label = player.getPropertyString("$base/title")
?: player.getPropertyString("$base/codec")
val language = player.getPropertyString("$base/lang")
tracks.add(
Track(
id = id.toString(),
label = label,
language = language,
selected = selected
)
)
}
return tracks.toTypedArray()
}
}