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 ->
overrides.keys.any { it.id == groups[i].id }
}
val match = indices.firstOrNull { i ->
val matchesLabel = { i: Int ->
val name: String? = tracks[i].name
val language: String? = tracks[i].language
(labels.isNotEmpty() && name != null && name in labels) ||
(languages.isNotEmpty() && language != null &&
Util.normalizeLanguageCode(language) in languages)
labels.isNotEmpty() && name != null && name in labels
}
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 {
overrideMatch != null -> overrideMatch
match != null -> match
@@ -1,6 +1,8 @@
package dev.zoriya.omni
import android.annotation.SuppressLint
import android.content.Context
import android.os.Looper
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -13,11 +15,16 @@ import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer
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.omni.AndroidBackend
import com.margelo.nitro.omni.CastStatus
@@ -62,9 +69,26 @@ class OmniPlayer(
AndroidBackend.EXOPLAYER -> {
val http = DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
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(
DefaultMediaSourceFactory(DefaultDataSource.Factory(ctx, http))
.experimentalParseSubtitlesDuringExtraction(false)
)
.build()
}
@@ -100,7 +124,6 @@ class OmniPlayer(
.setRemotePlayer(remote)
.build()
}
eventMap.player = active
active
}
@@ -169,18 +192,38 @@ class OmniPlayer(
}
}
override fun dispose() {
showNotification = false
super.dispose()
@Volatile
private var released = false
eventMap.dispose()
override fun release() {
if (released) return
released = true
showNotification = false
runOnMainThread {
eventMap.dispose()
castContext?.removeCastStateListener(castStateListener)
// release both cast and local players.
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(
src: com.margelo.nitro.omni.VideoSrc,
metadata: com.margelo.nitro.omni.Metadata?,
@@ -206,7 +249,10 @@ class OmniPlayer(
.setId(subtitle.id)
.setLanguage(subtitle.language)
.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()
})
.setRequestMetadata(
@@ -337,6 +383,10 @@ class OmniPlayer(
}
}
init {
runOnMainThreadSync { eventMap.player = player }
}
override var source: Source? = null
set(value) {
field = value
@@ -431,15 +481,15 @@ class OmniPlayer(
}?.mediaTrackGroup
if (group != null) {
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 {
val labels = track.label?.let { arrayOf(it) } ?: emptyArray()
if (type == C.TRACK_TYPE_AUDIO) {
params.setPreferredAudioLanguage(track.language)
.setPreferredAudioLabels(*labels)
} else {
params.setPreferredTextLanguage(track.language)
.setPreferredTextLabels(*labels)
}
params.setPreferredTextLanguage(track.language)
.setPreferredTextLabels(*labels)
}
}
player.trackSelectionParameters = params.build()
@@ -1,6 +1,7 @@
package dev.zoriya.omni
import android.app.PictureInPictureParams
import android.graphics.Color
import android.graphics.Rect
import android.os.Build
import android.util.Rational
@@ -117,6 +118,7 @@ class OmniView(val context: ThemedReactContext) :
override var subtitleAssets: SubtitleAssets? = null
init {
view.setBackgroundColor(Color.BLACK)
context.addLifecycleEventListener(this)
}
@@ -46,6 +46,7 @@ import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.interfaces.IMedia.VideoTrack
import org.videolan.libvlc.interfaces.IVLCVout
import java.security.MessageDigest
@SuppressLint("UnsafeOptInUsageError")
class VlcPlayer(ctx: Context) :
@@ -99,11 +100,14 @@ class VlcPlayer(ctx: Context) :
private var currentTrackSelectionParameters = TrackSelectionParameters.Builder().build()
@Volatile
private var playerError: PlaybackException? = null
@Volatile
private var released = false
private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY
private var userInitiatedTransition: Boolean = false
@Volatile
private var cachedBufferedPosition: Long = 0L
private var boundSurfaceView: SurfaceView? = null
private var lastVideoSize: VideoSize = VideoSize.UNKNOWN
private val availableCommands: Player.Commands = Player.Commands.Builder()
.add(COMMAND_PLAY_PAUSE)
@@ -136,6 +140,7 @@ class VlcPlayer(ctx: Context) :
override fun onEvent(event: MediaPlayer.Event) {
when (event.type) {
MediaPlayer.Event.Opening -> {
lastVideoSize = VideoSize.UNKNOWN
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(STATE_BUFFERING)
}
@@ -158,6 +163,7 @@ class VlcPlayer(ctx: Context) :
it.onIsPlayingChanged(true)
it.onTracksChanged(getCurrentTracks())
}
maybeNotifyVideoSizeChanged()
}
MediaPlayer.Event.Paused -> {
@@ -211,12 +217,19 @@ class VlcPlayer(ctx: Context) :
// this improves perf & battery life (native -> js bridge is expensive)
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.ESDeleted,
MediaPlayer.Event.ESSelected -> {
notifyListeners(EVENT_TRACKS_CHANGED) {
it.onTracksChanged(getCurrentTracks())
}
maybeNotifyVideoSizeChanged()
}
MediaPlayer.Event.LengthChanged -> {
@@ -353,6 +366,7 @@ class VlcPlayer(ctx: Context) :
media.addOption(":start-time=${targetMs / 1000.0}")
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
private fun applyRequestHeaders(media: Media, extras: Bundle?) {
if (extras == null) return
@@ -430,7 +458,7 @@ class VlcPlayer(ctx: Context) :
when {
playerError != null -> 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.isPlaying -> STATE_READY
player.isSeekable && player.time >= player.length && player.length > 0 -> STATE_ENDED
@@ -519,13 +547,17 @@ class VlcPlayer(ctx: Context) :
}
override fun release() {
if (released) return
released = true
player.setEventListener(null)
listeners.release()
abandonAudioFocus()
player.stop()
clearVideoSurface()
player.release()
libVLC.release()
applicationHandler.post {
player.release()
libVLC.release()
}
}
override fun getCurrentTracks(): Tracks {
@@ -582,10 +614,12 @@ class VlcPlayer(ctx: Context) :
}
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()
.setId(track.id)
.setLabel(track.name)
.setLanguage(track.language)
.setLabel(slave?.label ?: track.name)
.setLanguage(slave?.language ?: track.language)
.setSampleMimeType("text/x-unknown")
.build()
val group = TrackGroup("vlc-sub-${track.id}", format)
@@ -902,10 +936,30 @@ class VlcPlayer(ctx: Context) :
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 {
val videoTrack = player.getSelectedTrack(IMedia.Track.Type.Video) as? VideoTrack ?: 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
+13 -6
View File
@@ -6,7 +6,12 @@
"name": "react-native-omni",
"dependencies": {
"@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",
"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=="],
"@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=="],
+8 -39
View File
@@ -1,5 +1,5 @@
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 {
type AndroidBackend,
@@ -134,10 +134,6 @@ function PlayerExample({
);
const switchBackend = (target: AndroidBackend) => {
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);
};
@@ -461,27 +457,13 @@ function App(): React.JSX.Element {
// notification stays hidden) before any source is set.
const [hasSource, setHasSource] = useState(false);
const [backend, setBackend] = useState<AndroidBackend>("vlc");
const [pendingBackend, setPendingBackend] = useState<AndroidBackend | null>(
null,
);
// Switching backend recreates the native player, so we fully unmount the
// provider first (rendering nothing) and remount it on the next tick with
// 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.
// Switching the backend just updates the prop: OmniProvider recreates (and
// disposes) the native player internally, so the change applies live.
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(() => {
setCurrentIndex((index) => (index === 0 ? PLAYLIST.length - 1 : index - 1));
}, []);
@@ -525,18 +507,8 @@ function App(): React.JSX.Element {
[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 (
<OmniProvider
key={backend}
source={hasSource ? source : undefined}
backend={{ android: backend }}
cast={{ receiverApplicationId: "D8FB0FC1" }}
@@ -564,12 +536,6 @@ const styles = StyleSheet.create({
paddingVertical: 12,
gap: 12,
},
switching: {
flex: 1,
backgroundColor: "#0b1020",
alignItems: "center",
justifyContent: "center",
},
heading: {
fontSize: 24,
fontWeight: "700",
@@ -581,7 +547,10 @@ const styles = StyleSheet.create({
},
video: {
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,
overflow: "hidden",
},
@@ -241,6 +241,10 @@ namespace margelo::nitro::omni {
}
// Methods
void JHybridOmniPlayerSpec::release() {
static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("release");
method(_javaPart);
}
void JHybridOmniPlayerSpec::play() {
static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("play");
method(_javaPart);
@@ -78,6 +78,7 @@ namespace margelo::nitro::omni {
public:
// Methods
void release() override;
void play() override;
void pause() override;
void seekBy(double offset) override;
@@ -114,6 +114,10 @@ abstract class HybridOmniPlayerSpec: HybridObject() {
abstract val renditions: Array<Rendition>
// Methods
@DoNotStrip
@Keep
abstract fun release(): Unit
@DoNotStrip
@Keep
abstract fun play(): Unit
+1
View File
@@ -39,6 +39,7 @@ namespace margelo::nitro::omni {
prototype.registerHybridGetter("audios", &HybridOmniPlayerSpec::getAudios);
prototype.registerHybridGetter("subtitles", &HybridOmniPlayerSpec::getSubtitles);
prototype.registerHybridGetter("renditions", &HybridOmniPlayerSpec::getRenditions);
prototype.registerHybridMethod("release", &HybridOmniPlayerSpec::release);
prototype.registerHybridMethod("play", &HybridOmniPlayerSpec::play);
prototype.registerHybridMethod("pause", &HybridOmniPlayerSpec::pause);
prototype.registerHybridMethod("seekBy", &HybridOmniPlayerSpec::seekBy);
+1
View File
@@ -91,6 +91,7 @@ namespace margelo::nitro::omni {
public:
// Methods
virtual void release() = 0;
virtual void play() = 0;
virtual void pause() = 0;
virtual void seekBy(double offset) = 0;
+6 -1
View File
@@ -63,7 +63,12 @@
},
"dependencies": {
"@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",
"libpgs": "^0.8.1"
},
-1
View File
@@ -59,7 +59,6 @@ export class WebOmniPlayer implements OmniPlayer {
this._store.state.toggleRemotePlayback();
}
castOptions: CastOptions | null = null;
_source: Source | undefined = undefined;
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 type { OmniPlayerFactory } from "./specs/omni-player.nitro";
import type { OmniPlayer, PlayerBackend } from "./types/player";
import type {
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 { useLazyRef } from "./utils/lazy-ref";
const ProviderFactory =
NitroModules.createHybridObject<OmniPlayerFactory>("OmniPlayerFactory");
@@ -23,18 +32,30 @@ export const OmniProvider = ({
children: ReactNode;
showNotification?: boolean;
}) => {
const player = useLazyRef(() =>
ProviderFactory.createPlayer(source, backend, cast),
const [player, setPlayer] = useState<NativeOmniPlayer | null>(null);
const createPlayer = useEffectEvent((aBackend: AndroidBackend) =>
ProviderFactory.createPlayer(source, { android: aBackend }, cast),
);
useEffect(() => {
player.source = source;
}, [source]);
setPlayer(createPlayer(backend.android ?? "vlc"));
}, [backend.android]);
useEffect(() => {
player.showNotification = showNotification;
}, [showNotification]);
if (!player) return;
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>;
};
+19 -5
View File
@@ -1,4 +1,5 @@
import { createPlayer } from "@videojs/react";
import { GoogleCast } from "@videojs/react/media/google-cast";
import { videoFeatures } from "@videojs/react/video";
import {
createContext,
@@ -7,6 +8,7 @@ import {
useEffect,
useRef,
} from "react";
import { usePlayerState } from "./events";
import { WebOmniPlayer } from "./player.web";
import type { OmniPlayer, PlayerBackend } from "./types/player";
import type { CastOptions, Source } from "./types/source";
@@ -67,15 +69,27 @@ const PlayerInitializer = ({
}
}, [source, store]);
useEffect(() => {
player.castOptions = cast ?? null;
}, [cast]);
useEffect(() => {
player.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);
+2
View File
@@ -80,6 +80,8 @@ export interface OmniPlayer
extends HybridObject<{ android: "kotlin" }>,
OmniPlayerT {
readonly eventMap: OmniEventMap;
release(): void;
}
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 { Video } from "@videojs/react/video";
import {
@@ -106,7 +106,6 @@ export const OmniView = ({
autoplay,
subtitleAssets,
}: OmniViewProps & { style: CSSProperties }) => {
const player = usePlayer() as WebOmniPlayer;
const containerRef = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLVideoElement>(undefined!);
const source = usePlayerState("source");
@@ -119,8 +118,6 @@ export const OmniView = ({
const headersRef = useRef(source?.src?.headers);
headersRef.current = source?.src?.headers;
const castId = source?.castId;
const castData = source?.castData;
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