Compare commits

..
4 Commits
7 changed files with 101 additions and 27 deletions
@@ -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()
} }
@@ -187,6 +211,19 @@ class OmniPlayer(
super.dispose() 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?,
@@ -212,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(
@@ -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) :
@@ -387,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
@@ -599,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)
+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=="],
+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;
+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 -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