From 835510115eb19b2ed1c8a574e82cc38b2e413833 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 18 Apr 2026 19:35:16 +0200 Subject: [PATCH] Migrate to mpv --- android/build.gradle | 2 +- android/gradle.properties | 2 +- .../main/java/dev/zoriya/omni/OmniPlayer.kt | 219 +++++++++++------- .../java/dev/zoriya/omni/OmniPlayerFactory.kt | 4 +- .../src/main/java/dev/zoriya/omni/OmniView.kt | 43 ++-- .../zoriya/omni/utils/DeferredObservable.kt | 2 +- example/android/build.gradle | 2 +- .../android/c++/JHybridOmniPlayerSpec.cpp | 9 + .../android/c++/JHybridOmniPlayerSpec.hpp | 2 + .../nitro/omni/HybridOmniPlayerSpec.kt | 6 + .../shared/c++/HybridOmniPlayerSpec.cpp | 2 + .../shared/c++/HybridOmniPlayerSpec.hpp | 2 + shell.nix | 2 +- src/specs/omni-player.nitro.ts | 12 +- src/types/player.ts | 1 + 15 files changed, 203 insertions(+), 107 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index b3cddb5..dcb46f7 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -138,7 +138,7 @@ dependencies { // Add a dependency on NitroModules implementation project(":react-native-nitro-modules") - implementation("org.videolan.android:libvlc-all:4.0.0-eap24") + implementation("dev.jdtech.mpv:libmpv:1.0.0") } if (isNewArchitectureEnabled()) { diff --git a/android/gradle.properties b/android/gradle.properties index cd0f9bb..9f6a078 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,5 +1,5 @@ Omni_kotlinVersion=2.1.20 -Omni_minSdkVersion=23 +Omni_minSdkVersion=26 Omni_targetSdkVersion=35 Omni_compileSdkVersion=34 Omni_ndkVersion=27.1.12297006 diff --git a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt index 64beafe..20be282 100644 --- a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt @@ -1,100 +1,127 @@ package dev.zoriya.omni -import android.net.Uri import com.margelo.nitro.NitroModules -import com.margelo.nitro.omni.HybridOmniEventMapSpec 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.Track +import dev.jdtech.mpv.MPVLib import dev.zoriya.omni.utils.deferredObservable -import org.videolan.libvlc.LibVLC -import org.videolan.libvlc.Media -import org.videolan.libvlc.MediaPlayer -import org.videolan.libvlc.interfaces.IMedia -object VlcConst { - val vlc = LibVLC(NitroModules.applicationContext ?: throw Error("No Context available!")) -} +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) -class OmniPlayer() : HybridOmniPlayerSpec() { - val player = MediaPlayer(VlcConst.vlc) - override val eventMap = EventMap(player); + init { + player.setOptionString("vo", "gpu") + 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") + + // seek to keyframes + player.setOptionString("hr-seek", "no") + + player.init() + } + + override fun dispose() { + super.dispose() + + player.detachSurface() + eventMap.dispose() + player.destroy() + } override var source: Source by deferredObservable { _, _, new -> - val src = new.src.firstOrNull()?.uri ?: return@deferredObservable - player.media = Media(VlcConst.vlc, Uri.parse(src)) + 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.time = (start * 1000.0).toLong().coerceAtLeast(0L) + 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")) + } + } + + fun setSurface(surface: android.view.Surface?) { + if (surface == null) { + player.detachSurface() + } else { + player.attachSurface(surface) } } override val hasPrev get() = source.metadata?.hasPrev ?: false override val hasNext get() = source.metadata?.hasNext ?: false - override val status - get() = when (player.playerState) { - IMedia.State.NothingSpecial -> PlayerStatus.IDLE - IMedia.State.Opening -> PlayerStatus.LOADING - IMedia.State.Playing -> PlayerStatus.READYTOPLAY - IMedia.State.Paused -> PlayerStatus.READYTOPLAY - IMedia.State.Stopped -> PlayerStatus.IDLE - IMedia.State.Ended -> PlayerStatus.IDLE - IMedia.State.Error -> PlayerStatus.ERROR - else -> PlayerStatus.ERROR - } - override val isPlaying: Boolean get() = player.isPlaying - override var currentTime: Double - get() = player.time.coerceAtLeast(0L) / 1000.0 - set(value) { - player.time = (value * 1000.0).toLong().coerceAtLeast(0L) - } - override val buffered: Double - get() { - val total = duration - if (total <= 0.0) return 0.0 - return (total * player.position.toDouble()).coerceIn(0.0, total) - } - override val duration: Double get() = player.length.coerceAtLeast(0L) / 1000.0 - override var playbackRate: Double - get() = player.rate.toDouble() - set(value) { - if (value > 0.0) player.rate = value.toFloat() - } - override var volume: Double - get() = (player.volume.coerceIn(0, 100) / 100.0) - set(value) { - player.volume = (value.coerceIn(0.0, 1.0) * 100.0).toInt() - } - override val videos get() = getTracks(IMedia.Track.Type.Video) - override val audios get() = getTracks(IMedia.Track.Type.Audio) - override val subtitles get() = getTracks(IMedia.Track.Type.Text) + override val status: PlayerStatus + get() { + val idle = player.getPropertyBoolean("core-idle") ?: false + val loading = player.getPropertyBoolean("paused-for-cache") ?: false + return when { + idle -> PlayerStatus.IDLE + loading -> PlayerStatus.LOADING + else -> PlayerStatus.READYTOPLAY + } + } + override val isPlaying get() = !(player.getPropertyBoolean("pause") ?: true) + override var currentTime + get() = (player.getPropertyDouble("time-pos") ?: 0.0).coerceAtLeast(0.0) + set(value) { + player.command(arrayOf("seek", value.coerceAtLeast(0.0).toString(), "absolute")) + } + 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) - fun getTracks(type: Int): Array { - val tracks = player.getTracks(type) - val selected = player.getSelectedTrack(type) - return tracks.map { - Track( - id = it.id, - label = it.name, - language = it.language, - selected = it.id == selected.id - ) - }.toTypedArray() - } + override var playbackRate + get() = player.getPropertyDouble("speed") ?: 1.0 + set(value) { + player.setPropertyDouble("speed", value.coerceAtLeast(0.0)) + } + override var muted + get() = player.getPropertyBoolean("muted") ?: false + set(value) { + player.setPropertyBoolean("muted", 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 val rendition: Array get() = emptyArray() override fun play() { - player.play() + player.setPropertyBoolean("pause", false) } override fun pause() { - player.pause() + player.setPropertyBoolean("pause", true) } override fun seekBy(offset: Double) { - currentTime += offset + player.command(arrayOf("seek", offset.toString(), "relative")) } override fun playPrev() { @@ -105,22 +132,56 @@ class OmniPlayer() : HybridOmniPlayerSpec() { // App-level action; no default implementation in VLC player. } - override fun selectVideo(video: Track) { - player.selectTrack(video.id) - } + override fun selectVideo(video: Track) { + val id = video.id.toIntOrNull() ?: return + player.setPropertyInt("vid", id) + } - override fun selectAudio(audio: Track) { - player.selectTrack(audio.id) - } + override fun selectAudio(audio: Track) { + val id = audio.id.toIntOrNull() ?: return + player.setPropertyInt("aid", id) + } - override fun selectSubtitle(subtitle: Track?) { - when (subtitle) { - null -> player.unselectTrackType(IMedia.Track.Type.Text) - else -> player.selectTrack(subtitle.id) - } - } + override fun selectSubtitle(subtitle: Track?) { + when (subtitle) { + null -> player.setPropertyString("sid", "no") + else -> { + val id = subtitle.id.toIntOrNull() ?: return + player.setPropertyInt("sid", id) + } + } + } override fun selectRendition(rendition: Rendition?) { // Not supported by libVLC MediaPlayer track APIs. } + + private fun getTracks(type: String): Array { + val count = player.getPropertyInt("track-list/count") ?: 0 + if (count <= 0) return emptyArray() + + val tracks = ArrayList(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() + } } diff --git a/android/src/main/java/dev/zoriya/omni/OmniPlayerFactory.kt b/android/src/main/java/dev/zoriya/omni/OmniPlayerFactory.kt index f6a2891..3459ec2 100755 --- a/android/src/main/java/dev/zoriya/omni/OmniPlayerFactory.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniPlayerFactory.kt @@ -2,11 +2,11 @@ package dev.zoriya.omni import com.facebook.react.uimanager.ThemedReactContext import com.margelo.nitro.omni.HybridOmniPlayerFactorySpec -import com.margelo.nitro.omni.HybridOmniPlayerPropsSpec import com.margelo.nitro.omni.HybridOmniPlayerSpec +import com.margelo.nitro.omni.Source class OmniPlayerFactory(val context: ThemedReactContext): HybridOmniPlayerFactorySpec() { - override fun createPlayer(props: HybridOmniPlayerPropsSpec): HybridOmniPlayerSpec { + override fun createPlayer(props: Source): HybridOmniPlayerSpec { return OmniPlayer().apply { source = props } diff --git a/android/src/main/java/dev/zoriya/omni/OmniView.kt b/android/src/main/java/dev/zoriya/omni/OmniView.kt index 524c95e..1aa44c3 100755 --- a/android/src/main/java/dev/zoriya/omni/OmniView.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniView.kt @@ -1,23 +1,43 @@ package dev.zoriya.omni +import android.view.SurfaceHolder +import android.view.SurfaceView import android.view.View import com.facebook.react.uimanager.ThemedReactContext import com.margelo.nitro.omni.HybridOmniPlayerSpec import com.margelo.nitro.omni.HybridOmniViewSpec -import org.videolan.libvlc.util.VLCVideoLayout class OmniView(val context: ThemedReactContext) : HybridOmniViewSpec() { - private val videoLayout = VLCVideoLayout(context) - private var viewsAttached = false + private val surfaceView = SurfaceView(context) + private var surfaceReady = false private var boundPlayer: OmniPlayer? = null - override val view: View = videoLayout + override val view: View = surfaceView override lateinit var player: HybridOmniPlayerSpec override var autoplay: Boolean? = true override var showNotification: Boolean? = true override var autoPip: Boolean? = true + init { + surfaceView.holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + surfaceReady = true + boundPlayer?.setSurface(holder.surface) + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + surfaceReady = true + boundPlayer?.setSurface(holder.surface) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + surfaceReady = false + boundPlayer?.setSurface(null) + } + }) + } + override fun afterUpdate() { if (!::player.isInitialized) { throw IllegalStateException("Player is null in OmniView") @@ -25,17 +45,14 @@ class OmniView(val context: ThemedReactContext) : HybridOmniViewSpec() { val omniPlayer = player as? OmniPlayer ?: throw IllegalStateException("Player is not an OmniPlayer in OmniView") - val vout = omniPlayer.player.vlcVout if (boundPlayer !== omniPlayer) { - boundPlayer?.player?.detachViews() - viewsAttached = false + boundPlayer?.setSurface(null) boundPlayer = omniPlayer } - if (!viewsAttached || !vout.areViewsAttached()) { - omniPlayer.player.attachViews(videoLayout, null, false, false) - viewsAttached = true + if (surfaceReady) { + omniPlayer.setSurface(surfaceView.holder.surface) } if (autoplay == true && !omniPlayer.isPlaying) { @@ -47,11 +64,7 @@ class OmniView(val context: ThemedReactContext) : HybridOmniViewSpec() { if (!::player.isInitialized) return val omniPlayer = player as? OmniPlayer ?: return - val vout = omniPlayer.player.vlcVout - if (vout.areViewsAttached()) { - omniPlayer.player.detachViews() - } - viewsAttached = false + omniPlayer.setSurface(null) boundPlayer = null } } diff --git a/android/src/main/java/dev/zoriya/omni/utils/DeferredObservable.kt b/android/src/main/java/dev/zoriya/omni/utils/DeferredObservable.kt index 20c8ed9..38782ac 100644 --- a/android/src/main/java/dev/zoriya/omni/utils/DeferredObservable.kt +++ b/android/src/main/java/dev/zoriya/omni/utils/DeferredObservable.kt @@ -16,6 +16,6 @@ inline fun deferredObservable( override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { val old = this.value this.value = value - onChange(old, value) + onChange(thisRef, old, value) } } diff --git a/example/android/build.gradle b/example/android/build.gradle index dad99b0..859f764 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { buildToolsVersion = "36.0.0" - minSdkVersion = 24 + minSdkVersion = 26 compileSdkVersion = 36 targetSdkVersion = 36 ndkVersion = "27.1.12297006" diff --git a/nitrogen/generated/android/c++/JHybridOmniPlayerSpec.cpp b/nitrogen/generated/android/c++/JHybridOmniPlayerSpec.cpp index 3f42e79..0c196c7 100644 --- a/nitrogen/generated/android/c++/JHybridOmniPlayerSpec.cpp +++ b/nitrogen/generated/android/c++/JHybridOmniPlayerSpec.cpp @@ -207,6 +207,15 @@ namespace margelo::nitro::omni { static const auto method = _javaPart->javaClassStatic()->getMethod("setVolume"); method(_javaPart, volume); } + bool JHybridOmniPlayerSpec::getMuted() { + static const auto method = _javaPart->javaClassStatic()->getMethod("getMuted"); + auto __result = method(_javaPart); + return static_cast(__result); + } + void JHybridOmniPlayerSpec::setMuted(bool muted) { + static const auto method = _javaPart->javaClassStatic()->getMethod("setMuted"); + method(_javaPart, muted); + } // Methods void JHybridOmniPlayerSpec::play() { diff --git a/nitrogen/generated/android/c++/JHybridOmniPlayerSpec.hpp b/nitrogen/generated/android/c++/JHybridOmniPlayerSpec.hpp index 9973571..bd7b119 100644 --- a/nitrogen/generated/android/c++/JHybridOmniPlayerSpec.hpp +++ b/nitrogen/generated/android/c++/JHybridOmniPlayerSpec.hpp @@ -69,6 +69,8 @@ namespace margelo::nitro::omni { void setPlaybackRate(double playbackRate) override; double getVolume() override; void setVolume(double volume) override; + bool getMuted() override; + void setMuted(bool muted) override; public: // Methods diff --git a/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/HybridOmniPlayerSpec.kt b/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/HybridOmniPlayerSpec.kt index aee8b37..0e210c4 100644 --- a/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/HybridOmniPlayerSpec.kt +++ b/nitrogen/generated/android/kotlin/com/margelo/nitro/omni/HybridOmniPlayerSpec.kt @@ -92,6 +92,12 @@ abstract class HybridOmniPlayerSpec: HybridObject() { @set:DoNotStrip @set:Keep abstract var volume: Double + + @get:DoNotStrip + @get:Keep + @set:DoNotStrip + @set:Keep + abstract var muted: Boolean // Methods @DoNotStrip diff --git a/nitrogen/generated/shared/c++/HybridOmniPlayerSpec.cpp b/nitrogen/generated/shared/c++/HybridOmniPlayerSpec.cpp index faf6d56..20bab6a 100644 --- a/nitrogen/generated/shared/c++/HybridOmniPlayerSpec.cpp +++ b/nitrogen/generated/shared/c++/HybridOmniPlayerSpec.cpp @@ -33,6 +33,8 @@ namespace margelo::nitro::omni { prototype.registerHybridSetter("playbackRate", &HybridOmniPlayerSpec::setPlaybackRate); prototype.registerHybridGetter("volume", &HybridOmniPlayerSpec::getVolume); prototype.registerHybridSetter("volume", &HybridOmniPlayerSpec::setVolume); + prototype.registerHybridGetter("muted", &HybridOmniPlayerSpec::getMuted); + prototype.registerHybridSetter("muted", &HybridOmniPlayerSpec::setMuted); prototype.registerHybridMethod("play", &HybridOmniPlayerSpec::play); prototype.registerHybridMethod("pause", &HybridOmniPlayerSpec::pause); prototype.registerHybridMethod("seekBy", &HybridOmniPlayerSpec::seekBy); diff --git a/nitrogen/generated/shared/c++/HybridOmniPlayerSpec.hpp b/nitrogen/generated/shared/c++/HybridOmniPlayerSpec.hpp index 8ebfaf4..e17a84b 100644 --- a/nitrogen/generated/shared/c++/HybridOmniPlayerSpec.hpp +++ b/nitrogen/generated/shared/c++/HybridOmniPlayerSpec.hpp @@ -79,6 +79,8 @@ namespace margelo::nitro::omni { virtual void setPlaybackRate(double playbackRate) = 0; virtual double getVolume() = 0; virtual void setVolume(double volume) = 0; + virtual bool getMuted() = 0; + virtual void setMuted(bool muted) = 0; public: // Methods diff --git a/shell.nix b/shell.nix index a18ed5d..61c1c69 100644 --- a/shell.nix +++ b/shell.nix @@ -23,7 +23,7 @@ in nodejs biome jdk - ((android-studio.withSdk android.androidsdk).overrideAttrs {forceWayland = true;}) + ((android-studio.override {forceWayland = true;}).withSdk android.androidsdk) ]; shellHook = '' diff --git a/src/specs/omni-player.nitro.ts b/src/specs/omni-player.nitro.ts index 3da7399..a9fdf31 100644 --- a/src/specs/omni-player.nitro.ts +++ b/src/specs/omni-player.nitro.ts @@ -1,17 +1,17 @@ import type { HybridObject } from "react-native-nitro-modules"; import type { OmniEvents } from "../types/events"; import type { - OmniPlayerState, + // OmniPlayerState, OmniPlayer as OmniPlayerT, } from "../types/player"; import type { Source } from "../types/source"; export interface OmniEventMap extends HybridObject<{ android: "kotlin" }> { - addStateListener( - key: keyof OmniPlayerState, - cb: (value: number) => void, - ): void; - removeStateListener(cb: OmniEvents["end"]): void; + // addStateListener( + // key: keyof OmniPlayerState, + // cb: (value: number) => void, + // ): void; + // removeStateListener(cb: OmniEvents["end"]): void; addOnEndListener(cb: OmniEvents["end"]): void; removeOnEndListener(cb: OmniEvents["end"]): void; diff --git a/src/types/player.ts b/src/types/player.ts index ea70c96..8c57724 100644 --- a/src/types/player.ts +++ b/src/types/player.ts @@ -32,6 +32,7 @@ export interface OmniPlayerState { playbackRate: number; // between 0 and 1 volume: number; + muted: boolean; } export type PlayerStatus = "idle" | "loading" | "readyToPlay" | "error";