Compare commits

...
8 Commits
17 changed files with 230 additions and 99 deletions
@@ -60,13 +60,18 @@ class OmniCastTrackSelector : CastTrackSelector() {
val overrideMatch = indices.firstOrNull { i -> val overrideMatch = indices.firstOrNull { i ->
overrides.keys.any { it.id == groups[i].id } overrides.keys.any { it.id == groups[i].id }
} }
val match = indices.firstOrNull { i -> val matchesLabel = { i: Int ->
val name: String? = tracks[i].name val name: String? = tracks[i].name
val language: String? = tracks[i].language labels.isNotEmpty() && name != null && name in labels
(labels.isNotEmpty() && name != null && name in labels) ||
(languages.isNotEmpty() && language != null &&
Util.normalizeLanguageCode(language) in languages)
} }
val matchesLanguage = { i: Int ->
val language: String? = tracks[i].language
languages.isNotEmpty() && language != null &&
Util.normalizeLanguageCode(language) in languages
}
val match = indices.firstOrNull { matchesLabel(it) && matchesLanguage(it) }
?: indices.firstOrNull { matchesLabel(it) }
?: indices.firstOrNull { matchesLanguage(it) }
val chosen = when { val chosen = when {
overrideMatch != null -> overrideMatch overrideMatch != null -> overrideMatch
match != null -> match match != null -> match
@@ -1,6 +1,8 @@
package dev.zoriya.omni package dev.zoriya.omni
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.os.Looper
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -13,11 +15,16 @@ import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.text.TextRenderer
import com.margelo.nitro.NitroModules import com.margelo.nitro.NitroModules
import com.margelo.nitro.omni.AndroidBackend import com.margelo.nitro.omni.AndroidBackend
import com.margelo.nitro.omni.CastStatus import com.margelo.nitro.omni.CastStatus
@@ -62,9 +69,26 @@ class OmniPlayer(
AndroidBackend.EXOPLAYER -> { AndroidBackend.EXOPLAYER -> {
val http = DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true) val http = DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
httpDataSourceFactory = http httpDataSourceFactory = http
ExoPlayer.Builder(ctx) // karaoke subs make exoplayer crash without this...
val renderersFactory = object : DefaultRenderersFactory(ctx) {
override fun buildTextRenderers(
context: Context,
output: TextOutput,
outputLooper: Looper,
extensionRendererMode: Int,
out: ArrayList<Renderer>,
) {
out.add(
TextRenderer(output, outputLooper).apply {
experimentalSetLegacyDecodingEnabled(true)
}
)
}
}
ExoPlayer.Builder(ctx, renderersFactory)
.setMediaSourceFactory( .setMediaSourceFactory(
DefaultMediaSourceFactory(DefaultDataSource.Factory(ctx, http)) DefaultMediaSourceFactory(DefaultDataSource.Factory(ctx, http))
.experimentalParseSubtitlesDuringExtraction(false)
) )
.build() .build()
} }
@@ -100,7 +124,6 @@ class OmniPlayer(
.setRemotePlayer(remote) .setRemotePlayer(remote)
.build() .build()
} }
eventMap.player = active
active active
} }
@@ -169,18 +192,38 @@ class OmniPlayer(
} }
} }
override fun dispose() { @Volatile
showNotification = false private var released = false
super.dispose()
eventMap.dispose() override fun release() {
if (released) return
released = true
showNotification = false
runOnMainThread { runOnMainThread {
eventMap.dispose()
castContext?.removeCastStateListener(castStateListener) castContext?.removeCastStateListener(castStateListener)
// release both cast and local players.
player.release() player.release()
} }
} }
override fun dispose() {
release()
super.dispose()
}
private fun inferSubtitleMimeType(link: String): String? {
val ext = link.toUri().lastPathSegment?.substringAfterLast('.', "")?.lowercase()
return when (ext) {
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
"srt" -> MimeTypes.APPLICATION_SUBRIP
"ass", "ssa" -> MimeTypes.TEXT_SSA
"ttml", "dfxp", "xml" -> MimeTypes.APPLICATION_TTML
"sup" -> MimeTypes.APPLICATION_PGS
"sub" -> MimeTypes.APPLICATION_VOBSUB
else -> null
}
}
private fun buildMediaItem( private fun buildMediaItem(
src: com.margelo.nitro.omni.VideoSrc, src: com.margelo.nitro.omni.VideoSrc,
metadata: com.margelo.nitro.omni.Metadata?, metadata: com.margelo.nitro.omni.Metadata?,
@@ -206,7 +249,10 @@ class OmniPlayer(
.setId(subtitle.id) .setId(subtitle.id)
.setLanguage(subtitle.language) .setLanguage(subtitle.language)
.setLabel(subtitle.label) .setLabel(subtitle.label)
.setMimeType(subtitle.mimeType) // exoplayer requires a mime type to pick a subtitle parser.
// fall back to inferring it from the link's extension so
// consumers don't have to provide one.
.setMimeType(subtitle.mimeType ?: inferSubtitleMimeType(subtitle.link))
.build() .build()
}) })
.setRequestMetadata( .setRequestMetadata(
@@ -337,6 +383,10 @@ class OmniPlayer(
} }
} }
init {
runOnMainThreadSync { eventMap.player = player }
}
override var source: Source? = null override var source: Source? = null
set(value) { set(value) {
field = value field = value
@@ -431,15 +481,15 @@ class OmniPlayer(
}?.mediaTrackGroup }?.mediaTrackGroup
if (group != null) { if (group != null) {
params.setOverrideForType(TrackSelectionOverride(group, 0)) params.setOverrideForType(TrackSelectionOverride(group, 0))
}
// also set preferred flags for cast selection
val labels = track.label?.let { arrayOf(it) } ?: emptyArray()
if (type == C.TRACK_TYPE_AUDIO) {
params.setPreferredAudioLanguage(track.language)
.setPreferredAudioLabels(*labels)
} else { } else {
val labels = track.label?.let { arrayOf(it) } ?: emptyArray() params.setPreferredTextLanguage(track.language)
if (type == C.TRACK_TYPE_AUDIO) { .setPreferredTextLabels(*labels)
params.setPreferredAudioLanguage(track.language)
.setPreferredAudioLabels(*labels)
} else {
params.setPreferredTextLanguage(track.language)
.setPreferredTextLabels(*labels)
}
} }
} }
player.trackSelectionParameters = params.build() player.trackSelectionParameters = params.build()
@@ -1,6 +1,7 @@
package dev.zoriya.omni package dev.zoriya.omni
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.graphics.Color
import android.graphics.Rect import android.graphics.Rect
import android.os.Build import android.os.Build
import android.util.Rational import android.util.Rational
@@ -117,6 +118,7 @@ class OmniView(val context: ThemedReactContext) :
override var subtitleAssets: SubtitleAssets? = null override var subtitleAssets: SubtitleAssets? = null
init { init {
view.setBackgroundColor(Color.BLACK)
context.addLifecycleEventListener(this) context.addLifecycleEventListener(this)
} }
@@ -46,6 +46,7 @@ import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.interfaces.IMedia.VideoTrack import org.videolan.libvlc.interfaces.IMedia.VideoTrack
import org.videolan.libvlc.interfaces.IVLCVout import org.videolan.libvlc.interfaces.IVLCVout
import java.security.MessageDigest
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
class VlcPlayer(ctx: Context) : class VlcPlayer(ctx: Context) :
@@ -99,11 +100,14 @@ class VlcPlayer(ctx: Context) :
private var currentTrackSelectionParameters = TrackSelectionParameters.Builder().build() private var currentTrackSelectionParameters = TrackSelectionParameters.Builder().build()
@Volatile @Volatile
private var playerError: PlaybackException? = null private var playerError: PlaybackException? = null
@Volatile
private var released = false
private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY
private var userInitiatedTransition: Boolean = false private var userInitiatedTransition: Boolean = false
@Volatile @Volatile
private var cachedBufferedPosition: Long = 0L private var cachedBufferedPosition: Long = 0L
private var boundSurfaceView: SurfaceView? = null private var boundSurfaceView: SurfaceView? = null
private var lastVideoSize: VideoSize = VideoSize.UNKNOWN
private val availableCommands: Player.Commands = Player.Commands.Builder() private val availableCommands: Player.Commands = Player.Commands.Builder()
.add(COMMAND_PLAY_PAUSE) .add(COMMAND_PLAY_PAUSE)
@@ -136,6 +140,7 @@ class VlcPlayer(ctx: Context) :
override fun onEvent(event: MediaPlayer.Event) { override fun onEvent(event: MediaPlayer.Event) {
when (event.type) { when (event.type) {
MediaPlayer.Event.Opening -> { MediaPlayer.Event.Opening -> {
lastVideoSize = VideoSize.UNKNOWN
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(STATE_BUFFERING) it.onPlaybackStateChanged(STATE_BUFFERING)
} }
@@ -158,6 +163,7 @@ class VlcPlayer(ctx: Context) :
it.onIsPlayingChanged(true) it.onIsPlayingChanged(true)
it.onTracksChanged(getCurrentTracks()) it.onTracksChanged(getCurrentTracks())
} }
maybeNotifyVideoSizeChanged()
} }
MediaPlayer.Event.Paused -> { MediaPlayer.Event.Paused -> {
@@ -211,12 +217,19 @@ class VlcPlayer(ctx: Context) :
// this improves perf & battery life (native -> js bridge is expensive) // this improves perf & battery life (native -> js bridge is expensive)
MediaPlayer.Event.TimeChanged -> Unit MediaPlayer.Event.TimeChanged -> Unit
// vlc has no video-size event, Vout event still has unknown size.
// This is the first callback with known size.
MediaPlayer.Event.PositionChanged -> {
if (lastVideoSize == VideoSize.UNKNOWN) maybeNotifyVideoSizeChanged()
}
MediaPlayer.Event.ESAdded, MediaPlayer.Event.ESAdded,
MediaPlayer.Event.ESDeleted, MediaPlayer.Event.ESDeleted,
MediaPlayer.Event.ESSelected -> { MediaPlayer.Event.ESSelected -> {
notifyListeners(EVENT_TRACKS_CHANGED) { notifyListeners(EVENT_TRACKS_CHANGED) {
it.onTracksChanged(getCurrentTracks()) it.onTracksChanged(getCurrentTracks())
} }
maybeNotifyVideoSizeChanged()
} }
MediaPlayer.Event.LengthChanged -> { MediaPlayer.Event.LengthChanged -> {
@@ -353,6 +366,7 @@ class VlcPlayer(ctx: Context) :
media.addOption(":start-time=${targetMs / 1000.0}") media.addOption(":start-time=${targetMs / 1000.0}")
player.setMedia(media) player.setMedia(media)
media.release()
} }
} }
@@ -374,6 +388,20 @@ class VlcPlayer(ctx: Context) :
} }
} }
// vlc keys each slave's tracks by the md5 of its uri ("<md5(uri)>/spu/<n>")
private fun subtitleSlaveForTrackId(trackId: String): MediaItem.SubtitleConfiguration? {
val hash = trackId.substringBefore("/spu/", "")
if (hash.isEmpty()) return null
return mediaItems.getOrNull(currentMediaItemIndex)
?.localConfiguration?.subtitleConfigurations
?.firstOrNull { md5(it.uri.toString()) == hash }
}
private fun md5(value: String): String =
MessageDigest.getInstance("MD5")
.digest(value.toByteArray())
.joinToString("") { "%02x".format(it.toInt() and 0xFF) }
// vlc doesn't allow arbitrary headers :c // vlc doesn't allow arbitrary headers :c
private fun applyRequestHeaders(media: Media, extras: Bundle?) { private fun applyRequestHeaders(media: Media, extras: Bundle?) {
if (extras == null) return if (extras == null) return
@@ -430,7 +458,7 @@ class VlcPlayer(ctx: Context) :
when { when {
playerError != null -> STATE_IDLE playerError != null -> STATE_IDLE
currentMediaItemIndex == INDEX_UNSET -> STATE_IDLE currentMediaItemIndex == INDEX_UNSET -> STATE_IDLE
player.media == null -> STATE_IDLE player.media?.also { it.release() } == null -> STATE_IDLE
player.playerState == IMedia.State.Opening -> STATE_BUFFERING player.playerState == IMedia.State.Opening -> STATE_BUFFERING
player.isPlaying -> STATE_READY player.isPlaying -> STATE_READY
player.isSeekable && player.time >= player.length && player.length > 0 -> STATE_ENDED player.isSeekable && player.time >= player.length && player.length > 0 -> STATE_ENDED
@@ -519,13 +547,17 @@ class VlcPlayer(ctx: Context) :
} }
override fun release() { override fun release() {
if (released) return
released = true
player.setEventListener(null) player.setEventListener(null)
listeners.release() listeners.release()
abandonAudioFocus() abandonAudioFocus()
player.stop() player.stop()
clearVideoSurface() clearVideoSurface()
player.release() applicationHandler.post {
libVLC.release() player.release()
libVLC.release()
}
} }
override fun getCurrentTracks(): Tracks { override fun getCurrentTracks(): Tracks {
@@ -582,10 +614,12 @@ class VlcPlayer(ctx: Context) :
} }
player.getTracks(IMedia.Track.Type.Text)?.forEach { track -> player.getTracks(IMedia.Track.Type.Text)?.forEach { track ->
// external subs don't have label/language, match them back to surface them
val slave = subtitleSlaveForTrackId(track.id)
val format = Format.Builder() val format = Format.Builder()
.setId(track.id) .setId(track.id)
.setLabel(track.name) .setLabel(slave?.label ?: track.name)
.setLanguage(track.language) .setLanguage(slave?.language ?: track.language)
.setSampleMimeType("text/x-unknown") .setSampleMimeType("text/x-unknown")
.build() .build()
val group = TrackGroup("vlc-sub-${track.id}", format) val group = TrackGroup("vlc-sub-${track.id}", format)
@@ -902,10 +936,30 @@ class VlcPlayer(ctx: Context) :
override fun clearVideoTextureView(textureView: TextureView?) = Unit override fun clearVideoTextureView(textureView: TextureView?) = Unit
/**
* VLC never pushes a video-size event, so emit our own once the selected video
* track (and its sample aspect ratio) is known. Listeners such as OmniView use
* this to size the content frame -> the PIP aspect ratio matches the video
* instead of the raw SurfaceView bounds (ExoPlayer does this on its own).
*/
private fun maybeNotifyVideoSizeChanged() {
val size = getVideoSize()
if (size == VideoSize.UNKNOWN || size == lastVideoSize) return
lastVideoSize = size
notifyListeners(EVENT_VIDEO_SIZE_CHANGED) {
it.onVideoSizeChanged(size)
}
}
override fun getVideoSize(): VideoSize { override fun getVideoSize(): VideoSize {
val videoTrack = player.getSelectedTrack(IMedia.Track.Type.Video) as? VideoTrack ?: return VideoSize.UNKNOWN val videoTrack = player.getSelectedTrack(IMedia.Track.Type.Video) as? VideoTrack ?: return VideoSize.UNKNOWN
if (videoTrack.width <= 0 || videoTrack.height <= 0) return VideoSize.UNKNOWN if (videoTrack.width <= 0 || videoTrack.height <= 0) return VideoSize.UNKNOWN
return VideoSize(videoTrack.width, videoTrack.height) val par = if (videoTrack.sarNum > 0 && videoTrack.sarDen > 0) {
videoTrack.sarNum.toFloat() / videoTrack.sarDen.toFloat()
} else {
1f
}
return VideoSize(videoTrack.width, videoTrack.height, par)
} }
override fun getSurfaceSize(): Size = Size.UNKNOWN override fun getSurfaceSize(): Size = Size.UNKNOWN
+13 -6
View File
@@ -6,7 +6,12 @@
"name": "react-native-omni", "name": "react-native-omni",
"dependencies": { "dependencies": {
"@expo/config-plugins": "^57.0.3", "@expo/config-plugins": "^57.0.3",
"@videojs/react": "10.0.0-beta.25", "@videojs/core": "github:zoriya/v10#build26-core",
"@videojs/media": "github:zoriya/v10#build26-media",
"@videojs/react": "github:zoriya/v10#build26-react",
"@videojs/spf": "github:zoriya/v10#build26-spf",
"@videojs/store": "github:zoriya/v10#build26-store",
"@videojs/utils": "github:zoriya/v10#build26-utils",
"jassub": "^2.5.7", "jassub": "^2.5.7",
"libpgs": "^0.8.1", "libpgs": "^0.8.1",
}, },
@@ -723,15 +728,17 @@
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"@videojs/core": ["@videojs/core@10.0.0-beta.25", "", { "dependencies": { "@videojs/spf": "10.0.0-beta.25", "@videojs/store": "10.0.0-beta.25", "@videojs/utils": "10.0.0-beta.25", "@vimeo/player": "^2.30.3", "dashjs": "^5.2.0", "hls.js": "^1.6.7", "mux-embed": "^5.17.10" } }, "sha512-Yxd6c+u7LrYl6aKzZRyR5PjDqUt1lUbgFwIhg6pMzI8bJszIGjLS7TDzXdALpEFJpM1Z08dbqCEzCGaMj7Eu6A=="], "@videojs/core": ["@videojs/core@github:zoriya/v10#02a4769", { "peerDependencies": { "@videojs/media": "*", "@videojs/store": "*", "@videojs/utils": "*" } }, "zoriya-v10-02a4769", "sha512-eq79BQJ+e2Lx6k9tCW68vBolcJD3OynwgE7Y8pmI9gb+RWQAYJFLEY8B576ueKHhcgOOT/taerI/Wg3eqougSg=="],
"@videojs/react": ["@videojs/react@10.0.0-beta.25", "", { "dependencies": { "@videojs/core": "10.0.0-beta.25", "@videojs/spf": "10.0.0-beta.25", "@videojs/store": "10.0.0-beta.25", "@videojs/utils": "10.0.0-beta.25" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-MUG8NFntK3u4OTArwqG4utZ11HU1Ku5kzzTfTA2BMG5shNj7KWqPz0BMR2Nnv6hwEvcxpm9vwZxk+AwkmoB5jA=="], "@videojs/media": ["@videojs/media@github:zoriya/v10#46507f9", { "dependencies": { "@vimeo/player": "^2.30.3", "dashjs": "^5.2.0", "hls.js": "^1.6.7", "mux-embed": "^5.17.10" }, "peerDependencies": { "@videojs/spf": "*", "@videojs/utils": "*" } }, "zoriya-v10-46507f9", "sha512-1f7jIP5KPGKo4NeyG+Q22eS7KhvYZam8yvG985Y9edCp7wFcrVcraEQwKJrESaa/GDNgeKIeEg05MZ+l+ZHftQ=="],
"@videojs/spf": ["@videojs/spf@10.0.0-beta.25", "", { "dependencies": { "@videojs/utils": "10.0.0-beta.25", "signal-polyfill": "^0.2.2" } }, "sha512-SETOzCPbSxuzCoX+bF2u7/Z80AA/Xwpqpy5ki//yslIyPxWkMT0GFydXdoZ/bJpNsBqGmDbDxEBDo1uMjnM4mQ=="], "@videojs/react": ["@videojs/react@github:zoriya/v10#784a383", { "peerDependencies": { "@videojs/core": "*", "@videojs/media": "*", "@videojs/spf": "*", "@videojs/store": "*", "@videojs/utils": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "zoriya-v10-784a383", "sha512-wLuXUjLfhLNZAVSxCsTmpidN8DZPy6Us788vXDSRJKh/a6WDTCWV2op26EK3eCgeC1P8Hwc5s5cOEhqx2fGMRQ=="],
"@videojs/store": ["@videojs/store@10.0.0-beta.25", "", { "dependencies": { "@videojs/utils": "10.0.0-beta.25" }, "peerDependencies": { "@videojs/element": "10.0.0-beta.25", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@videojs/element", "react"] }, "sha512-XvPC0+MQ6AZ/oKGpSzmjOM56aYESMpPsTg5zddBDXhcqeAOUxpEsXDqhU1rsm751tU4ZavGxHENx70lBOa5dUA=="], "@videojs/spf": ["@videojs/spf@github:zoriya/v10#8920135", { "dependencies": { "signal-polyfill": "^0.2.2" }, "peerDependencies": { "@videojs/utils": "*" } }, "zoriya-v10-8920135", "sha512-jGXo5tyPpIk96XWksTKbWpi7POHzdt4KtvqDZ9+SDa+MOqLV3TcgM8soo6KO2jMnkv8d8APqaz+E27qfDf16kg=="],
"@videojs/utils": ["@videojs/utils@10.0.0-beta.25", "", {}, "sha512-tq/848qmzgTncopeYE5iiYs1Pfzj4OEdSe7zE2H6xoOB0PUMlnPOThA3aybHJ8Lh9bmw+wTb6QJ0NZanIzt2Zg=="], "@videojs/store": ["@videojs/store@github:zoriya/v10#3f9bddd", { "peerDependencies": { "@videojs/element": "*", "@videojs/utils": "*", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@videojs/element", "react"] }, "zoriya-v10-3f9bddd", "sha512-QjSGJ6EO6uoij41F7KgMKrNi5XjvXfkODOrn17wcFa6+Ig1+ftnckkNZsJ44bARdJ1XUkuj3wNyGidYMrmKRmQ=="],
"@videojs/utils": ["@videojs/utils@github:zoriya/v10#9fed1b6", {}, "zoriya-v10-9fed1b6", "sha512-ZgbMw3v41aEru0056VVwy0UtiZk8zq6Tm6ddRAJtm5VXz2nEf36CoejFFvmGICW5cQ2WWhvMQmtqCSr+HmYGIw=="],
"@vimeo/player": ["@vimeo/player@2.30.4", "", { "dependencies": { "native-promise-only": "0.8.1", "weakmap-polyfill": "2.0.4" } }, "sha512-M8m1UAhJSb+KCWuXDLWHViwj+3YY/0ogwFquRfMHs9e9LYjXT9iB7n+sOCKwUusbiXuU2HKmXx+FEGHtYZfUSg=="], "@vimeo/player": ["@vimeo/player@2.30.4", "", { "dependencies": { "native-promise-only": "0.8.1", "weakmap-polyfill": "2.0.4" } }, "sha512-M8m1UAhJSb+KCWuXDLWHViwj+3YY/0ogwFquRfMHs9e9LYjXT9iB7n+sOCKwUusbiXuU2HKmXx+FEGHtYZfUSg=="],
+8 -39
View File
@@ -1,5 +1,5 @@
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { import {
type AndroidBackend, type AndroidBackend,
@@ -134,10 +134,6 @@ function PlayerExample({
); );
const switchBackend = (target: AndroidBackend) => { const switchBackend = (target: AndroidBackend) => {
if (target === backend) return; if (target === backend) return;
// Pause before tearing down: the native media-session guard refuses a
// second player while the current one is still *playing*, so this keeps
// the transition clean (we don't preserve state across a switch anyway).
player.pause();
onSwitchBackend(target); onSwitchBackend(target);
}; };
@@ -461,27 +457,13 @@ function App(): React.JSX.Element {
// notification stays hidden) before any source is set. // notification stays hidden) before any source is set.
const [hasSource, setHasSource] = useState(false); const [hasSource, setHasSource] = useState(false);
const [backend, setBackend] = useState<AndroidBackend>("vlc"); const [backend, setBackend] = useState<AndroidBackend>("vlc");
const [pendingBackend, setPendingBackend] = useState<AndroidBackend | null>(
null,
);
// Switching backend recreates the native player, so we fully unmount the // Switching the backend just updates the prop: OmniProvider recreates (and
// provider first (rendering nothing) and remount it on the next tick with // disposes) the native player internally, so the change applies live.
// the new backend. This lets the old OmniView/player tear down before the
// new one is created, avoiding the "only one view"/"two players" guards.
const handleSwitchBackend = useCallback((next: AndroidBackend) => { const handleSwitchBackend = useCallback((next: AndroidBackend) => {
setPendingBackend(next); setBackend(next);
}, []); }, []);
useEffect(() => {
if (pendingBackend === null) return;
const id = setTimeout(() => {
setBackend(pendingBackend);
setPendingBackend(null);
}, 300);
return () => clearTimeout(id);
}, [pendingBackend]);
const handlePrev = useCallback(() => { const handlePrev = useCallback(() => {
setCurrentIndex((index) => (index === 0 ? PLAYLIST.length - 1 : index - 1)); setCurrentIndex((index) => (index === 0 ? PLAYLIST.length - 1 : index - 1));
}, []); }, []);
@@ -525,18 +507,8 @@ function App(): React.JSX.Element {
[currentIndex], [currentIndex],
); );
// While switching, render nothing so the current player is torn down first.
if (pendingBackend !== null) {
return (
<View style={styles.switching}>
<Text style={styles.subheading}>Switching to {pendingBackend}…</Text>
</View>
);
}
return ( return (
<OmniProvider <OmniProvider
key={backend}
source={hasSource ? source : undefined} source={hasSource ? source : undefined}
backend={{ android: backend }} backend={{ android: backend }}
cast={{ receiverApplicationId: "D8FB0FC1" }} cast={{ receiverApplicationId: "D8FB0FC1" }}
@@ -564,12 +536,6 @@ const styles = StyleSheet.create({
paddingVertical: 12, paddingVertical: 12,
gap: 12, gap: 12,
}, },
switching: {
flex: 1,
backgroundColor: "#0b1020",
alignItems: "center",
justifyContent: "center",
},
heading: { heading: {
fontSize: 24, fontSize: 24,
fontWeight: "700", fontWeight: "700",
@@ -581,7 +547,10 @@ const styles = StyleSheet.create({
}, },
video: { video: {
width: "100%", width: "100%",
aspectRatio: 16 / 9, // Intentionally wider than the 16:9 sample videos so the view's aspect
// ratio differs from the video's. This surfaces the PIP aspect-ratio bug
// where PIP would otherwise inherit the view's shape, not the video's.
aspectRatio: 21 / 9,
borderRadius: 14, borderRadius: 14,
overflow: "hidden", overflow: "hidden",
}, },
@@ -241,6 +241,10 @@ namespace margelo::nitro::omni {
} }
// Methods // Methods
void JHybridOmniPlayerSpec::release() {
static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("release");
method(_javaPart);
}
void JHybridOmniPlayerSpec::play() { void JHybridOmniPlayerSpec::play() {
static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("play"); static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("play");
method(_javaPart); method(_javaPart);
@@ -78,6 +78,7 @@ namespace margelo::nitro::omni {
public: public:
// Methods // Methods
void release() override;
void play() override; void play() override;
void pause() override; void pause() override;
void seekBy(double offset) override; void seekBy(double offset) override;
@@ -114,6 +114,10 @@ abstract class HybridOmniPlayerSpec: HybridObject() {
abstract val renditions: Array<Rendition> abstract val renditions: Array<Rendition>
// Methods // Methods
@DoNotStrip
@Keep
abstract fun release(): Unit
@DoNotStrip @DoNotStrip
@Keep @Keep
abstract fun play(): Unit abstract fun play(): Unit
+1
View File
@@ -39,6 +39,7 @@ namespace margelo::nitro::omni {
prototype.registerHybridGetter("audios", &HybridOmniPlayerSpec::getAudios); prototype.registerHybridGetter("audios", &HybridOmniPlayerSpec::getAudios);
prototype.registerHybridGetter("subtitles", &HybridOmniPlayerSpec::getSubtitles); prototype.registerHybridGetter("subtitles", &HybridOmniPlayerSpec::getSubtitles);
prototype.registerHybridGetter("renditions", &HybridOmniPlayerSpec::getRenditions); prototype.registerHybridGetter("renditions", &HybridOmniPlayerSpec::getRenditions);
prototype.registerHybridMethod("release", &HybridOmniPlayerSpec::release);
prototype.registerHybridMethod("play", &HybridOmniPlayerSpec::play); prototype.registerHybridMethod("play", &HybridOmniPlayerSpec::play);
prototype.registerHybridMethod("pause", &HybridOmniPlayerSpec::pause); prototype.registerHybridMethod("pause", &HybridOmniPlayerSpec::pause);
prototype.registerHybridMethod("seekBy", &HybridOmniPlayerSpec::seekBy); prototype.registerHybridMethod("seekBy", &HybridOmniPlayerSpec::seekBy);
+1
View File
@@ -91,6 +91,7 @@ namespace margelo::nitro::omni {
public: public:
// Methods // Methods
virtual void release() = 0;
virtual void play() = 0; virtual void play() = 0;
virtual void pause() = 0; virtual void pause() = 0;
virtual void seekBy(double offset) = 0; virtual void seekBy(double offset) = 0;
+6 -1
View File
@@ -63,7 +63,12 @@
}, },
"dependencies": { "dependencies": {
"@expo/config-plugins": "^57.0.3", "@expo/config-plugins": "^57.0.3",
"@videojs/react": "10.0.0-beta.25", "@videojs/core": "github:zoriya/v10#build26-core",
"@videojs/media": "github:zoriya/v10#build26-media",
"@videojs/react": "github:zoriya/v10#build26-react",
"@videojs/spf": "github:zoriya/v10#build26-spf",
"@videojs/store": "github:zoriya/v10#build26-store",
"@videojs/utils": "github:zoriya/v10#build26-utils",
"jassub": "^2.5.7", "jassub": "^2.5.7",
"libpgs": "^0.8.1" "libpgs": "^0.8.1"
}, },
-1
View File
@@ -59,7 +59,6 @@ export class WebOmniPlayer implements OmniPlayer {
this._store.state.toggleRemotePlayback(); this._store.state.toggleRemotePlayback();
} }
castOptions: CastOptions | null = null;
_source: Source | undefined = undefined; _source: Source | undefined = undefined;
private _showNotification = false; private _showNotification = false;
+31 -10
View File
@@ -1,9 +1,18 @@
import { createContext, type ReactNode, useContext, useEffect } from "react"; import {
createContext,
type ReactNode,
useContext,
useEffect,
useEffectEvent,
useState,
} from "react";
import { NitroModules } from "react-native-nitro-modules"; import { NitroModules } from "react-native-nitro-modules";
import type { OmniPlayerFactory } from "./specs/omni-player.nitro"; import type {
import type { OmniPlayer, PlayerBackend } from "./types/player"; OmniPlayer as NativeOmniPlayer,
OmniPlayerFactory,
} from "./specs/omni-player.nitro";
import type { AndroidBackend, OmniPlayer, PlayerBackend } from "./types/player";
import type { CastOptions, Source } from "./types/source"; import type { CastOptions, Source } from "./types/source";
import { useLazyRef } from "./utils/lazy-ref";
const ProviderFactory = const ProviderFactory =
NitroModules.createHybridObject<OmniPlayerFactory>("OmniPlayerFactory"); NitroModules.createHybridObject<OmniPlayerFactory>("OmniPlayerFactory");
@@ -23,18 +32,30 @@ export const OmniProvider = ({
children: ReactNode; children: ReactNode;
showNotification?: boolean; showNotification?: boolean;
}) => { }) => {
const player = useLazyRef(() => const [player, setPlayer] = useState<NativeOmniPlayer | null>(null);
ProviderFactory.createPlayer(source, backend, cast),
const createPlayer = useEffectEvent((aBackend: AndroidBackend) =>
ProviderFactory.createPlayer(source, { android: aBackend }, cast),
); );
useEffect(() => { useEffect(() => {
player.source = source; setPlayer(createPlayer(backend.android ?? "vlc"));
}, [source]); }, [backend.android]);
useEffect(() => { useEffect(() => {
player.showNotification = showNotification; if (!player) return;
}, [showNotification]); return () => player.release();
}, [player]);
useEffect(() => {
if (player) player.source = source;
}, [player, source]);
useEffect(() => {
if (player) player.showNotification = showNotification;
}, [player, showNotification]);
if (!player) return null;
return <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>; return <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>;
}; };
+19 -5
View File
@@ -1,4 +1,5 @@
import { createPlayer } from "@videojs/react"; import { createPlayer } from "@videojs/react";
import { GoogleCast } from "@videojs/react/media/google-cast";
import { videoFeatures } from "@videojs/react/video"; import { videoFeatures } from "@videojs/react/video";
import { import {
createContext, createContext,
@@ -7,6 +8,7 @@ import {
useEffect, useEffect,
useRef, useRef,
} from "react"; } from "react";
import { usePlayerState } from "./events";
import { WebOmniPlayer } from "./player.web"; import { WebOmniPlayer } from "./player.web";
import type { OmniPlayer, PlayerBackend } from "./types/player"; import type { OmniPlayer, PlayerBackend } from "./types/player";
import type { CastOptions, Source } from "./types/source"; import type { CastOptions, Source } from "./types/source";
@@ -67,15 +69,27 @@ const PlayerInitializer = ({
} }
}, [source, store]); }, [source, store]);
useEffect(() => {
player.castOptions = cast ?? null;
}, [cast]);
useEffect(() => { useEffect(() => {
player.showNotification = showNotification; player.showNotification = showNotification;
}, [showNotification]); }, [showNotification]);
return <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>; return (
<PlayerCtx.Provider value={player}>
<CastBridge cast={cast} />
{children}
</PlayerCtx.Provider>
);
};
const CastBridge = ({ cast }: { cast?: CastOptions | null }) => {
const source = usePlayerState("source");
return (
<GoogleCast
receiver={cast?.receiverApplicationId}
src={source?.castId}
customData={source?.castData}
/>
);
}; };
export const usePlayer = () => useContext(PlayerCtx); export const usePlayer = () => useContext(PlayerCtx);
+2
View File
@@ -80,6 +80,8 @@ export interface OmniPlayer
extends HybridObject<{ android: "kotlin" }>, extends HybridObject<{ android: "kotlin" }>,
OmniPlayerT { OmniPlayerT {
readonly eventMap: OmniEventMap; readonly eventMap: OmniEventMap;
release(): void;
} }
export interface OmniPlayerFactory extends HybridObject<{ android: "kotlin" }> { export interface OmniPlayerFactory extends HybridObject<{ android: "kotlin" }> {
+2 -10
View File
@@ -1,4 +1,4 @@
import type { HlsMediaConfig } from "@videojs/core/dom/media/hls-js"; import type { HlsMediaConfig } from "@videojs/media/dom/hls-js";
import { HlsJsVideo } from "@videojs/react/media/hlsjs-video"; import { HlsJsVideo } from "@videojs/react/media/hlsjs-video";
import { Video } from "@videojs/react/video"; import { Video } from "@videojs/react/video";
import { import {
@@ -106,7 +106,6 @@ export const OmniView = ({
autoplay, autoplay,
subtitleAssets, subtitleAssets,
}: OmniViewProps & { style: CSSProperties }) => { }: OmniViewProps & { style: CSSProperties }) => {
const player = usePlayer() as WebOmniPlayer;
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLVideoElement>(undefined!); const ref = useRef<HTMLVideoElement>(undefined!);
const source = usePlayerState("source"); const source = usePlayerState("source");
@@ -119,8 +118,6 @@ export const OmniView = ({
const headersRef = useRef(source?.src?.headers); const headersRef = useRef(source?.src?.headers);
headersRef.current = source?.src?.headers; headersRef.current = source?.src?.headers;
const castId = source?.castId;
const castData = source?.castData;
const config = useMemo<HlsMediaConfig>( const config = useMemo<HlsMediaConfig>(
() => ({ () => ({
@@ -133,13 +130,8 @@ export const OmniView = ({
} }
}, },
}, },
googleCast: {
receiver: player.castOptions?.receiverApplicationId,
customData: castData ?? null,
...(castId && { src: castId }),
},
}), }),
[player.castOptions, castData, castId], [],
); );
// While casting, the receiver renders subtitles (the player forwards the // While casting, the receiver renders subtitles (the player forwards the