diff --git a/android/build.gradle b/android/build.gradle index 39e0d5f..a1485fe 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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()) { diff --git a/android/src/main/java/dev/zoriya/omni/MpvPlayer.kt b/android/src/main/java/dev/zoriya/omni/MpvPlayer.kt new file mode 100644 index 0000000..e2b3e56 --- /dev/null +++ b/android/src/main/java/dev/zoriya/omni/MpvPlayer.kt @@ -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 { + val count = mpv.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 = 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() + } +} diff --git a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt index 3a48639..17bd486 100644 --- a/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt +++ b/android/src/main/java/dev/zoriya/omni/OmniPlayer.kt @@ -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 - else -> PlayerStatus.READYTOPLAY - } + 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 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 { - 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() } }