Compare commits

...
Author SHA1 Message Date
zoriya 0d8b33aee3 fix(event): fix stale callbacks 2026-07-16 13:58:32 +02:00
zoriya 645b226359 fix(source): ensure object update doesn't reset player 2026-07-16 12:27:50 +02:00
zoriya 61ee6238d5 fix: oups i need to use sementic commits 2026-07-14 17:15:48 +02:00
zoriya a7717dfcb9 Fix native track ids 2026-07-14 17:12:31 +02:00
zoriya 337f338224 Fix hls tech detection 2026-07-14 16:20:04 +02:00
zoriya 72328aacf6 fix: peer dependencies are now loose 2026-07-14 15:04:31 +02:00
zoriya b1966b0c57 fix: setup sementic-release 2026-07-14 14:50:02 +02:00
zoriya 77c1e36047 Fix jassub blurry issues on low quality 2026-07-14 14:28:10 +02:00
zoriya a4d125738a Update stuff, fix jassub font issue 2026-07-14 12:59:28 +02:00
zoriya 14dca7405e Add a readme, make it easier to install 2026-07-13 13:10:10 +02:00
zoriya 3c9e850f31 Add jassub/libpgs support on the web 2026-07-13 11:31:31 +02:00
zoriya 1c7f922996 use videojs for audio & rendition 2026-07-12 21:20:39 +02:00
zoriya 453f86a448 Implement audio and rendition selection on web 2026-06-23 23:09:46 +02:00
zoriya c553c75433 Fix source/tech seleciton on web 2026-05-29 12:32:41 +02:00
zoriya 0ac36e9890 Setup react native web 2026-05-28 12:49:58 +02:00
zoriya 466b792878 Cleanup setSource 2026-05-25 18:33:44 +02:00
zoriya 89799cd5cc Cleanup web player 2026-05-24 23:38:18 +02:00
zoriya d603f95720 Cleanup view on web 2026-05-24 22:19:07 +02:00
zoriya 0466e0fab4 Cleanup useEvent on web 2026-05-24 17:09:58 +02:00
zoriya 9410846a0f Cleanup usePlayerStatus on web 2026-05-24 16:32:26 +02:00
zoriya 22c41331f9 Init videojs v10 implementation 2026-05-24 16:32:26 +02:00
37 changed files with 3620 additions and 393 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ permissions:
on:
push:
branches:
- main
- master
paths:
- '.github/workflows/android-build.yml'
- 'example/android/**'
+9 -7
View File
@@ -1,6 +1,9 @@
name: Release
on:
push:
branches:
- master
workflow_dispatch:
permissions:
@@ -28,26 +31,25 @@ jobs:
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# OIDC trusted publishing requires npm CLI >= 11.5.1; ubuntu-latest ships an older one.
- name: Upgrade npm CLI (for OIDC trusted publishing)
run: npm install -g npm@latest
- name: Cache bun dependencies
id: bun-cache
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install npm dependencies (bun)
- name: Install/Typecheck/Build
# We use a `prepare` script that run builds to support git installs.
run: bun install
- name: Build lib
run: bun run build
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
GIT_AUTHOR_NAME: ${{ github.actor }}
GIT_AUTHOR_EMAIL: '${{ github.actor }}@users.noreply.github.com'
GIT_COMMITTER_NAME: ${{ github.actor }}
+9
View File
@@ -0,0 +1,9 @@
{
"branches": ["master"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/npm",
"@semantic-release/github"
]
}
+245 -14
View File
@@ -1,18 +1,19 @@
# react-native-omni
video player using vlc and videojs
A library to have real players on android and web. It uses VLC on android and
videojs v10 on the web (ios not implemented yet, PR welcome)
[![Version](https://img.shields.io/npm/v/react-native-omni.svg)](https://www.npmjs.com/package/react-native-omni)
[![Downloads](https://img.shields.io/npm/dm/react-native-omni.svg)](https://www.npmjs.com/package/react-native-omni)
[![License](https://img.shields.io/npm/l/react-native-omni.svg)](https://github.com/patrickkabwe/react-native-omni/LICENSE)
## Features
## Requirements
- **vlc v4**: to support more codecs than exoplayer, hdr and so on
- **Adaptive streaming**: HLS out of the box, with automatic quality
(rendition) selection or manual override.
- **Multi-track playback**: enumerate and switch video, audio, and subtitle
tracks at runtime.
- **Rich subtitle support**: vtt, srt, ass (via [jassub](https://github.com/ThaUnknown/jassub)) and pgs (via [libpgs](https://github.com/Arcus92/libpgs-js))
- **Picture-in-Picture**: enter PiP automatically or on demand on Android.
- **and basic player stuff**: media sessions, playlists, hook based api...
- React Native v0.76.0 or higher
- Node 18.0.0 or higher
> [!IMPORTANT]
> To Support `Nitro Views` you need to install React Native version v0.78.0 or higher.
## Installation
@@ -20,10 +21,240 @@ video player using vlc and videojs
bun add react-native-omni react-native-nitro-modules
```
## Credits
### Expo config plugin (Android)
Bootstrapped with [create-nitro-module](https://github.com/patrickkabwe/create-nitro-module).
The library ships an Expo config plugin that wires up media notifications and
picture-in-picture. Add it to your `app.json` / `app.config.js`:
## Contributing
```json
{
"expo": {
"plugins": ["react-native-omni"]
}
}
```
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
The plugin will:
- Register the `OmniPlayerService` media session service and add the
`FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MEDIA_PLAYBACK` permissions.
- Enable Picture-in-Picture on your `MainActivity` (declares
`supportsPictureInPicture`, adds the required `configChanges`, and hooks the
pip lifecycle callbacks).
If you are not using Expo, replicate those manifest/activity changes manually.
### Web setup
Most features are available out of the box, you need custom steps for advanced
subtitles rendering:
<details>
<summary>ass rendering</summary>
#### Cross-origin isolation
jassub's wasm is multi-threaded and relies on `SharedArrayBuffer`, which
browsers only expose on a **cross-origin-isolated** page. Serve these response
headers on your HTML document:
```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
```
`credentialless` keeps cross-origin video/subtitle/font requests working without
requiring `Cross-Origin-Resource-Policy` headers on every remote asset. Without
isolation, JASSUB initializes but silently never renders.
</details>
## Quick start
Wrap your player UI in an `OmniProvider` with a `source`, then render an
`OmniView` and drive it with the hooks.
```tsx
import {
OmniProvider,
OmniView,
usePlayer,
usePlayerState,
useEvent,
} from "react-native-omni";
function Player() {
const player = usePlayer();
const isPlaying = usePlayerState("isPlaying");
const currentTime = usePlayerState("currentTime");
const duration = usePlayerState("duration");
useEvent("end", () => player.playNext());
return (
<>
<OmniView style={{ width: "100%", aspectRatio: 16 / 9 }} autoplay />
<Button
title={isPlaying ? "Pause" : "Play"}
onPress={() => (isPlaying ? player.pause() : player.play())}
/>
<Text>
{currentTime.toFixed(0)} / {duration.toFixed(0)}
</Text>
</>
);
}
export default function App() {
const source = {
src: [{ uri: "https://example.com/stream.m3u8", headers: {} }],
subtitles: [],
metadata: { title: "My video", hasPrev: false, hasNext: true },
};
return (
<OmniProvider source={source} showNotification>
<Player />
</OmniProvider>
);
}
```
## API
### `<OmniProvider>`
Creates the underlying player and exposes it to children. Must wrap any
component that uses `<OmniView>`, `usePlayer`, `usePlayerState`, or `useEvent`.
| Prop | Type | Default | Description |
| ------------------ | --------- | ------- | ---------------------------------------------------- |
| `source` | `Source` | — | The media to play. |
| `showNotification` | `boolean` | `false` | Create a media session (OS controls/notification). |
| `children` | `ReactNode` | — | Your player UI. |
Updating `source` swaps the media in place (and seeks to `source.startTime` if
set) without recreating the player.
### `<OmniView>`
Renders the video surface. Place it inside an `OmniProvider`. Accepts a `style`
plus:
| Prop | Type | Platform | Description |
| ---------------- | ---------------- | -------- | ----------------------------------------------------------------------- |
| `autoplay` | `boolean` | all | Start playing as soon as the media is ready. |
| `autoPip` | `boolean` | Android | Automatically enter Picture-in-Picture when the app is backgrounded. |
| `subtitleAssets` | `SubtitleAssets` | Web | URLs for the ASS/PGS renderer worker/wasm/font assets (see below). |
### `usePlayer()`
Returns the `OmniPlayer` instance — an imperative handle for controlling
playback and reading/writing state.
**Playback controls**
```ts
player.play();
player.pause();
player.seekBy(offset); // relative seek in seconds
player.playPrev(); // fires the `prev` event (implement navigation yourself)
player.playNext(); // fires the `next` event
player.hasPrev; // readonly boolean
player.hasNext; // readonly boolean
```
**Writable state**
```ts
player.currentTime = 42; // seek to absolute position (seconds)
player.playbackRate = 1.5;
player.volume = 0.8; // 0..1
player.muted = true;
```
**Readonly state**
```ts
player.status; // "idle" | "loading" | "readyToPlay" | "error"
player.isPlaying;
player.buffered; // seconds buffered
player.duration; // seconds
player.isAutoQuality; // whether rendition selection is automatic
```
**Tracks & renditions**
```ts
player.videos; // Track[]
player.selectVideo(track);
player.audios; // Track[]
player.selectAudio(track);
player.subtitles; // Track[]
player.selectSubtitle(track); // pass undefined to turn subtitles off
player.rendition; // Rendition[]
player.selectRendition(rendition); // pass undefined for automatic quality
```
> Prefer `usePlayerState`/`useEvent` for values that change over time — reading
> them directly off `player` gives you a one-time snapshot and won't re-render.
### `usePlayerState(key, refresh?)`
Subscribes to a single reactive player property and re-renders when it changes.
Only the component using it re-renders.
```ts
const status = usePlayerState("status");
const isPlaying = usePlayerState("isPlaying");
const duration = usePlayerState("duration");
const currentTime = usePlayerState("currentTime", 1); // poll every 1s
```
The optional `refresh` argument (seconds) sets a polling interval.
Useful for `currentTime`, which defaults to a 1s refresh (we don't refresh every
ms for this because crossing js/native bridge on native consumes lot of battery)
### `useEvent(event, callback)`
Subscribes to a player event for the lifetime of the component.
```ts
useEvent("end", () => player.playNext());
useEvent("prev", handlePrev);
useEvent("next", handleNext);
useEvent("error", (type, message) => console.warn(type, message));
useEvent("audioFocusChange", (status) => {/* … */});
useEvent("videoTrackChange", (track) => {/* … */});
useEvent("audioTrackChange", (track) => {/* … */});
useEvent("subtitleChange", (track) => {/* track may be undefined = off */});
useEvent("renditionChange", (rendition) => {/* … */});
```
| Event | Signature | When |
| ------------------ | -------------------------------------- | --------------------------------------------- |
| `end` | `() => void` | Playback reached the end. |
| `prev` / `next` | `() => void` | Prev/next requested (from UI or OS controls). |
| `error` | `(type, message) => void` | A playback error occurred. |
| `audioFocusChange` | `(status) => void` | The OS audio focus changed. |
| `videoTrackChange` | `(track) => void` | The active video track changed. |
| `audioTrackChange` | `(track) => void` | The active audio track changed. |
| `subtitleChange` | `(track?) => void` | The active subtitle changed (`undefined` = off). |
| `renditionChange` | `(rendition) => void` | The active quality/rendition changed. |
## Example app
A full example (playlist navigation, track/rendition selectors, an event log,
and web subtitle assets) lives in [`example/`](./example). To run it:
```bash
bun install
bun run android # Android
bun run web # Web
```
@@ -24,7 +24,6 @@ import androidx.core.net.toUri
import androidx.media3.common.MediaItem.RequestMetadata
import androidx.media3.common.MediaItem.SubtitleConfiguration
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
@@ -64,43 +63,6 @@ class OmniPlayer : HybridOmniPlayerSpec() {
runOnMainThread { player.release() }
}
private var currentSource: Source? = null
override var source: Source
get() = currentSource
?: throw IllegalStateException("source should be initialized before get")
set(value) {
Log.i("omni", "update source")
currentSource = value
val src = value.src.firstOrNull()
if (src == null) {
runOnMainThreadSync {
player.setMediaItem(MediaItem.EMPTY)
player.prepare()
}
return
}
val currentItem = buildMediaItem(src, value.metadata, value.subtitles)
val mediaItems = mutableListOf<MediaItem>()
if (value.metadata?.hasPrev == true) {
mediaItems.add(currentItem)
}
mediaItems.add(currentItem)
if (value.metadata?.hasNext == true) {
mediaItems.add(currentItem)
}
runOnMainThreadSync {
val startIndex = if (value.metadata?.hasPrev == true) 1 else 0
val startPositionMs = (value.startTime?.coerceAtLeast(0.0) ?: 0.0) * 1000.0
player.setMediaItems(mediaItems, startIndex, startPositionMs.toLong())
player.prepare()
}
}
private fun buildMediaItem(
src: com.margelo.nitro.omni.VideoSrc,
metadata: com.margelo.nitro.omni.Metadata?,
@@ -153,8 +115,8 @@ class OmniPlayer : HybridOmniPlayerSpec() {
}
}
override val hasPrev: Boolean get() = currentSource?.metadata?.hasPrev == true
override val hasNext: Boolean get() = currentSource?.metadata?.hasNext == true
override val hasPrev: Boolean get() = player.hasPreviousMediaItem()
override val hasNext: Boolean get() = player.hasNextMediaItem()
override val status by mainThreadProperty {
when (player.playbackState) {
Player.STATE_IDLE,
@@ -207,6 +169,38 @@ class OmniPlayer : HybridOmniPlayerSpec() {
}
}
override fun setSource(src: Source) {
Log.i("omni", "update source")
val source = src.src.firstOrNull()
if (source == null) {
runOnMainThreadSync {
player.setMediaItem(MediaItem.EMPTY)
player.prepare()
}
return
}
val currentItem = buildMediaItem(source, src.metadata, src.subtitles)
val mediaItems = mutableListOf<MediaItem>()
if (src.metadata?.hasPrev == true) {
mediaItems.add(currentItem)
}
mediaItems.add(currentItem)
if (src.metadata?.hasNext == true) {
mediaItems.add(currentItem)
}
runOnMainThreadSync {
val startIndex = if (src.metadata?.hasPrev == true) 1 else 0
val startPositionMs = (src.startTime?.coerceAtLeast(0.0) ?: 0.0) * 1000.0
player.setMediaItems(mediaItems, startIndex, startPositionMs.toLong())
player.prepare()
}
}
override fun play() {
runOnMainThreadSync { player.play() }
}
+2124 -235
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -73,3 +73,4 @@ yarn-error.log
!.yarn/releases
!.yarn/sdks
!.yarn/versions
dist/
+42 -15
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import {
OmniProvider,
@@ -15,6 +15,7 @@ const PLAYLIST = [
artist: "multi audio",
album: "Adaptive",
uri: "https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/index.m3u8",
imageLink: undefined,
},
{
title: "Big Buck Bunny (HLS)",
@@ -23,6 +24,7 @@ const PLAYLIST = [
artwork:
"https://peach.blender.org/wp-content/uploads/title_anouncement.jpg",
uri: "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
imageLink: undefined,
},
{
title: "Sintel Trailer (MP4)",
@@ -31,10 +33,10 @@ const PLAYLIST = [
artwork:
"https://download.blender.org/durian/trailer/sintel_trailer-480p.jpg",
uri: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4",
imageLink: undefined,
},
] as const;
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return "00:00";
@@ -217,7 +219,15 @@ function PlayerExample({
<Text style={styles.heading}>react-native-omni</Text>
<Text style={styles.subheading}>{trackLabel}</Text>
<OmniView style={styles.video} autoplay={true} showNotification={true} />
<OmniView
style={styles.video}
autoplay={true}
subtitleAssets={{
jassub: {
fontUrl: "/jassub/default.woff2",
},
}}
/>
<View style={styles.row}>
<Pressable style={styles.button} onPress={togglePlayback}>
@@ -272,9 +282,9 @@ function PlayerExample({
{tracks.videos.length === 0 ? (
<Text style={styles.emptyTrackText}>No video tracks</Text>
) : (
tracks.videos.map((video, index) => (
tracks.videos.map((video) => (
<Pressable
key={`video-${video.id}-${index}`}
key={`video-${video.id}`}
style={[
styles.trackButton,
video.selected && styles.selectedTrackButton,
@@ -299,9 +309,9 @@ function PlayerExample({
{tracks.audios.length === 0 ? (
<Text style={styles.emptyTrackText}>No audio tracks</Text>
) : (
tracks.audios.map((audio, index) => (
tracks.audios.map((audio) => (
<Pressable
key={`audio-${audio.id}-${index}`}
key={`audio-${audio.id}`}
style={[
styles.trackButton,
audio.selected && styles.selectedTrackButton,
@@ -344,9 +354,9 @@ function PlayerExample({
{tracks.subtitles.length === 0 ? (
<Text style={styles.emptyTrackText}>No subtitles</Text>
) : (
tracks.subtitles.map((subtitle, index) => (
tracks.subtitles.map((subtitle) => (
<Pressable
key={`subtitle-${subtitle.id}-${index}`}
key={`subtitle-${subtitle.id}`}
style={[
styles.trackButton,
subtitle.selected && styles.selectedTrackButton,
@@ -382,14 +392,17 @@ function PlayerExample({
]}
>
Auto
{isAutoQuality && tracks.renditions.find((r) => r.selected)
? ` (${tracks.renditions.find((r) => r.selected)!.height}p)`
: ""}
</Text>
</Pressable>
{tracks.renditions.length === 0 ? (
<Text style={styles.emptyTrackText}>No renditions</Text>
) : (
tracks.renditions.map((rendition, index) => (
tracks.renditions.map((rendition) => (
<Pressable
key={`rendition-${rendition.id}-${index}`}
key={`rendition-${rendition.id}`}
style={[
styles.trackButton,
rendition.selected && styles.selectedTrackButton,
@@ -419,8 +432,8 @@ function PlayerExample({
{logs.length === 0 ? (
<Text style={styles.logText}>Event log will appear here.</Text>
) : (
logs.map((entry, index) => (
<Text key={`${entry}-${index}`} style={styles.logText}>
logs.map((entry) => (
<Text key={`${entry}`} style={styles.logText}>
{entry}
</Text>
))
@@ -449,12 +462,26 @@ function App(): React.JSX.Element {
headers: {},
},
],
subtitles: [],
subtitles: [
{
id: "kusu",
link: "https://jassub.pages.dev/subtitles/Kusriya%20S2%20OP1v3.ass",
},
{
id: "pgs",
link: "https://raw.githubusercontent.com/Arcus92/libpgs-js/main/tests/files/test.sup",
label: "PGS test",
},
],
fonts: [
"https://jassub.pages.dev/fonts/FOT-TsukuCOldMinPr6NR.OTF",
"https://jassub.pages.dev/fonts/arial.ttf",
],
metadata: {
title: PLAYLIST[currentIndex].title,
artist: PLAYLIST[currentIndex].artist,
album: PLAYLIST[currentIndex].album,
imageLink: PLAYLIST[currentIndex].artwork,
imageLink: PLAYLIST[currentIndex].imageLink,
hasPrev: true,
hasNext: true,
},
+13
View File
@@ -0,0 +1,13 @@
/**
* @format
*/
import { AppRegistry } from "react-native";
import App from "./App";
import { name as appName } from "./app.json";
AppRegistry.registerComponent(appName, () => App);
AppRegistry.runApplication(appName, {
rootTag: document.getElementById("root"),
});
+18 -1
View File
@@ -2,6 +2,8 @@ const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
const useWeb = process.env.USE_WEB === "true" || process.env.PLATFORM === "web";
/**
* Metro configuration
* https://facebook.github.io/metro/docs/configuration
@@ -9,7 +11,22 @@ const root = path.resolve(__dirname, "..");
* @type {import('metro-config').MetroConfig}
*/
const config = {
watchFolders: [root],
watchFolders: [root],
...(useWeb && {
resolver: {
extraNodeModules: {
"react-native": path.resolve(__dirname, "node_modules", "react-native-web"),
},
},
}),
transformer: {
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
},
};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
+15 -4
View File
@@ -7,15 +7,18 @@
"ios": "react-native run-ios --simulator='iPhone 16'",
"lint": "eslint .",
"start": "react-native start --reset-cache --port 8082",
"web": "webpack serve --mode development --open",
"test": "jest",
"pod": "bundle install && bundle exec pod install --project-directory=ios"
},
"dependencies": {
"react": "19.2.3",
"react-native": "0.84.1",
"@react-native/new-app-screen": "0.84.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.84.1",
"react-native-nitro-modules": "0.35.3",
"react-native-safe-area-context": "^5.5.2",
"react-native-nitro-modules": "0.35.3"
"react-native-web": "^0.20.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@@ -29,7 +32,15 @@
"@react-native/metro-config": "0.84.1",
"@react-native/typescript-config": "0.84.1",
"@types/jest": "^29.5.13",
"babel-plugin-module-resolver": "^5.0.2"
"babel-loader": "^10.1.1",
"babel-plugin-module-resolver": "^5.0.2",
"css-loader": "^7.1.4",
"html-webpack-plugin": "^5.6.7",
"style-loader": "^4.0.0",
"url-loader": "^4.1.1",
"webpack": "^5.107.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.4"
},
"engines": {
"node": ">= 22.11.0"
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Native Omni Example</title>
<style>
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div id="root"></div>
</body>
</html>
Binary file not shown.
-1
View File
@@ -4,7 +4,6 @@
"exclude": ["**/node_modules", "**/Pods"],
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"react-native-omni": ["../src"]
}
+78
View File
@@ -0,0 +1,78 @@
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const appDirectory = path.resolve(__dirname);
const rootDirectory = path.resolve(__dirname, "..");
module.exports = {
mode: "development",
devtool: "eval-source-map",
entry: path.resolve(__dirname, "index.web.js"),
output: {
path: path.resolve(__dirname, "dist"),
filename: "bundle.web.js",
publicPath: "/",
},
resolve: {
extensions: [".web.tsx", ".web.ts", ".tsx", ".ts", ".web.js", ".js"],
alias: {
"react-native$": "react-native-web",
"react-native-omni": path.resolve(rootDirectory, "src"),
react: path.resolve(appDirectory, "node_modules", "react"),
"react-dom": path.resolve(appDirectory, "node_modules", "react-dom"),
},
},
module: {
rules: [
{
test: /\.(js|ts|tsx)$/,
exclude:
/node_modules\/(?!(react-native|@react-native|react-native-web|react-native-omni)\/).*/,
use: {
loader: "babel-loader",
options: {
presets: [
"@babel/preset-env",
"@babel/preset-react",
"@babel/preset-typescript",
],
plugins: [
["@babel/plugin-transform-react-jsx", { runtime: "automatic" }],
[
"module-resolver",
{
extensions: [".js", ".ts", ".json", ".jsx", ".tsx"],
alias: {
"react-native-omni": path.resolve(rootDirectory, "src"),
},
},
],
],
},
},
},
{
test: /\.(png|jpe?g|gif|svg)$/i,
type: "asset/resource",
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "public/index.html"),
}),
],
devServer: {
port: 3000,
hot: true,
historyApiFallback: true,
// jassub's wasm is multithreaded (pthreads), which needs SharedArrayBuffer
// and therefore a cross-origin-isolated page. `credentialless` keeps
// cross-origin video/subtitle/font requests working without CORP headers.
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "credentialless",
},
static: [{ directory: path.resolve(appDirectory, "public") }],
},
};
+19 -24
View File
@@ -9,6 +9,12 @@
// Forward declaration of `HybridOmniEventMapSpec` to properly resolve imports.
namespace margelo::nitro::omni { class HybridOmniEventMapSpec; }
// Forward declaration of `Track` to properly resolve imports.
namespace margelo::nitro::omni { struct Track; }
// Forward declaration of `Rendition` to properly resolve imports.
namespace margelo::nitro::omni { struct Rendition; }
// Forward declaration of `PlayerStatus` to properly resolve imports.
namespace margelo::nitro::omni { enum class PlayerStatus; }
// Forward declaration of `Source` to properly resolve imports.
namespace margelo::nitro::omni { struct Source; }
// Forward declaration of `VideoSrc` to properly resolve imports.
@@ -19,23 +25,23 @@ namespace margelo::nitro::omni { struct Subtitle; }
namespace margelo::nitro::omni { struct Metadata; }
// Forward declaration of `MixAudioMode` to properly resolve imports.
namespace margelo::nitro::omni { enum class MixAudioMode; }
// Forward declaration of `Track` to properly resolve imports.
namespace margelo::nitro::omni { struct Track; }
// Forward declaration of `Rendition` to properly resolve imports.
namespace margelo::nitro::omni { struct Rendition; }
// Forward declaration of `PlayerStatus` to properly resolve imports.
namespace margelo::nitro::omni { enum class PlayerStatus; }
#include <memory>
#include "HybridOmniEventMapSpec.hpp"
#include "JHybridOmniEventMapSpec.hpp"
#include <optional>
#include "Track.hpp"
#include <vector>
#include "JTrack.hpp"
#include <string>
#include "Rendition.hpp"
#include "JRendition.hpp"
#include "PlayerStatus.hpp"
#include "JPlayerStatus.hpp"
#include "Source.hpp"
#include "JSource.hpp"
#include "VideoSrc.hpp"
#include <vector>
#include "JVideoSrc.hpp"
#include <string>
#include <optional>
#include <unordered_map>
#include "Subtitle.hpp"
#include "JSubtitle.hpp"
@@ -43,12 +49,6 @@ namespace margelo::nitro::omni { enum class PlayerStatus; }
#include "JMetadata.hpp"
#include "MixAudioMode.hpp"
#include "JMixAudioMode.hpp"
#include "Track.hpp"
#include "JTrack.hpp"
#include "Rendition.hpp"
#include "JRendition.hpp"
#include "PlayerStatus.hpp"
#include "JPlayerStatus.hpp"
namespace margelo::nitro::omni {
@@ -85,15 +85,6 @@ namespace margelo::nitro::omni {
auto __result = method(_javaPart);
return __result->getJHybridOmniEventMapSpec();
}
Source JHybridOmniPlayerSpec::getSource() {
static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<JSource>()>("getSource");
auto __result = method(_javaPart);
return __result->toCpp();
}
void JHybridOmniPlayerSpec::setSource(const Source& source) {
static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<JSource> /* source */)>("setSource");
method(_javaPart, JSource::fromCpp(source));
}
std::optional<bool> JHybridOmniPlayerSpec::getShowNotification() {
static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<jni::JBoolean>()>("getShowNotification");
auto __result = method(_javaPart);
@@ -232,6 +223,10 @@ namespace margelo::nitro::omni {
}
// Methods
void JHybridOmniPlayerSpec::setSource(const Source& src) {
static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<JSource> /* src */)>("setSource");
method(_javaPart, JSource::fromCpp(src));
}
void JHybridOmniPlayerSpec::play() {
static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("play");
method(_javaPart);
+1 -2
View File
@@ -51,8 +51,6 @@ namespace margelo::nitro::omni {
public:
// Properties
std::shared_ptr<HybridOmniEventMapSpec> getEventMap() override;
Source getSource() override;
void setSource(const Source& source) override;
std::optional<bool> getShowNotification() override;
void setShowNotification(std::optional<bool> showNotification) override;
bool getHasPrev() override;
@@ -77,6 +75,7 @@ namespace margelo::nitro::omni {
public:
// Methods
void setSource(const Source& src) override;
void play() override;
void pause() override;
void seekBy(double offset) override;
@@ -29,12 +29,6 @@ abstract class HybridOmniPlayerSpec: HybridObject() {
@get:Keep
abstract val eventMap: HybridOmniEventMapSpec
@get:DoNotStrip
@get:Keep
@set:DoNotStrip
@set:Keep
abstract var source: Source
@get:DoNotStrip
@get:Keep
@set:DoNotStrip
@@ -110,6 +104,10 @@ abstract class HybridOmniPlayerSpec: HybridObject() {
abstract val isAutoQuality: Boolean
// Methods
@DoNotStrip
@Keep
abstract fun setSource(src: Source): Unit
@DoNotStrip
@Keep
abstract fun play(): Unit
+1 -2
View File
@@ -15,8 +15,6 @@ namespace margelo::nitro::omni {
// load custom methods/properties
registerHybrids(this, [](Prototype& prototype) {
prototype.registerHybridGetter("eventMap", &HybridOmniPlayerSpec::getEventMap);
prototype.registerHybridGetter("source", &HybridOmniPlayerSpec::getSource);
prototype.registerHybridSetter("source", &HybridOmniPlayerSpec::setSource);
prototype.registerHybridGetter("showNotification", &HybridOmniPlayerSpec::getShowNotification);
prototype.registerHybridSetter("showNotification", &HybridOmniPlayerSpec::setShowNotification);
prototype.registerHybridGetter("hasPrev", &HybridOmniPlayerSpec::getHasPrev);
@@ -38,6 +36,7 @@ namespace margelo::nitro::omni {
prototype.registerHybridGetter("muted", &HybridOmniPlayerSpec::getMuted);
prototype.registerHybridSetter("muted", &HybridOmniPlayerSpec::setMuted);
prototype.registerHybridGetter("isAutoQuality", &HybridOmniPlayerSpec::getIsAutoQuality);
prototype.registerHybridMethod("setSource", &HybridOmniPlayerSpec::setSource);
prototype.registerHybridMethod("play", &HybridOmniPlayerSpec::play);
prototype.registerHybridMethod("pause", &HybridOmniPlayerSpec::pause);
prototype.registerHybridMethod("seekBy", &HybridOmniPlayerSpec::seekBy);
+4 -5
View File
@@ -15,23 +15,23 @@
// Forward declaration of `HybridOmniEventMapSpec` to properly resolve imports.
namespace margelo::nitro::omni { class HybridOmniEventMapSpec; }
// Forward declaration of `Source` to properly resolve imports.
namespace margelo::nitro::omni { struct Source; }
// Forward declaration of `Track` to properly resolve imports.
namespace margelo::nitro::omni { struct Track; }
// Forward declaration of `Rendition` to properly resolve imports.
namespace margelo::nitro::omni { struct Rendition; }
// Forward declaration of `PlayerStatus` to properly resolve imports.
namespace margelo::nitro::omni { enum class PlayerStatus; }
// Forward declaration of `Source` to properly resolve imports.
namespace margelo::nitro::omni { struct Source; }
#include <memory>
#include "HybridOmniEventMapSpec.hpp"
#include "Source.hpp"
#include <optional>
#include "Track.hpp"
#include <vector>
#include "Rendition.hpp"
#include "PlayerStatus.hpp"
#include "Source.hpp"
namespace margelo::nitro::omni {
@@ -61,8 +61,6 @@ namespace margelo::nitro::omni {
public:
// Properties
virtual std::shared_ptr<HybridOmniEventMapSpec> getEventMap() = 0;
virtual Source getSource() = 0;
virtual void setSource(const Source& source) = 0;
virtual std::optional<bool> getShowNotification() = 0;
virtual void setShowNotification(std::optional<bool> showNotification) = 0;
virtual bool getHasPrev() = 0;
@@ -87,6 +85,7 @@ namespace margelo::nitro::omni {
public:
// Methods
virtual void setSource(const Source& src) = 0;
virtual void play() = 0;
virtual void pause() = 0;
virtual void seekBy(double offset) = 0;
+31 -18
View File
@@ -1,6 +1,6 @@
{
"name": "react-native-omni",
"version": "0.1.0",
"version": "0.0.0-development",
"description": "video player using vlc and videojs",
"main": "./lib/commonjs/index.js",
"module": "./lib/module/index.js",
@@ -10,10 +10,13 @@
"scripts": {
"dev": "cd example && bun start",
"android": " cd example && bunx react-native run-android --no-packager --port 8082",
"web": "cd example && bun web",
"typecheck": "tsc --noEmit && tsc -p plugin/tsconfig.json --noEmit",
"build:plugin": "tsc -p plugin/tsconfig.json",
"build": "bun run typecheck && bob build && bun run build:plugin",
"codegen": "nitrogen --logLevel=\"debug\" && node post-script.js"
"prepare": "bun run build",
"codegen": "nitrogen --logLevel=\"debug\" && node post-script.js",
"release": "semantic-release"
},
"keywords": [
"react-native",
@@ -46,33 +49,40 @@
"workspaces": [
"example"
],
"repository": "https://github.com/zoeroux/react-native-omni.git",
"repository": {
"type": "git",
"url": "git+https://github.com/zoriya/react-native-omni.git"
},
"author": "Zoe Roux",
"license": "MIT",
"bugs": "https://github.com/zoeroux/react-native-omni/issues",
"homepage": "https://github.com/zoeroux/react-native-omni#readme",
"bugs": "https://github.com/zoriya/react-native-omni/issues",
"homepage": "https://github.com/zoriya/react-native-omni#readme",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"dependencies": {
"@expo/config-plugins": "^10.0.2"
"@expo/config-plugins": "^57.0.3",
"@videojs/react": "10.0.0-beta.25",
"jassub": "^2.5.7",
"libpgs": "^0.8.1"
},
"devDependencies": {
"@biomejs/biome": "2.4.10",
"@types/jest": "^29.5.12",
"@types/react": "19.2.0",
"nitrogen": "0.35.3",
"react": "19.2.3",
"react-native": "0.84.1",
"react-native-builder-bob": "^0.40.18",
"react-native-nitro-modules": "0.35.3",
"typescript": "^5.8.3"
"@biomejs/biome": "2.5.3",
"@types/jest": "^30.0.0",
"@types/react": "19.2.17",
"nitrogen": "0.36.1",
"react": "19.2.7",
"react-native": "0.86.0",
"react-native-builder-bob": "^0.43.0",
"react-native-nitro-modules": "0.36.1",
"semantic-release": "^25.0.7",
"typescript": "^7.0.2"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-nitro-modules": "*"
"react": ">=19.0.0",
"react-native": ">=0.86.0",
"react-native-nitro-modules": ">=0.36.0"
},
"react-native-builder-bob": {
"source": "src",
@@ -87,5 +97,8 @@
}
]
]
},
"patchedDependencies": {
"jassub@2.5.7": "patches/jassub@2.5.7.patch"
}
}
+117
View File
@@ -0,0 +1,117 @@
diff --git a/dist/jassub.d.ts b/dist/jassub.d.ts
index 8b09b107be84ebde02961eb63637128bfb62f1a9..01f3792bb0e6532d782659b240aeb34e73018534 100644
--- a/dist/jassub.d.ts
+++ b/dist/jassub.d.ts
@@ -24,6 +24,14 @@ export type JASSUBOptions = {
queryFonts?: 'local' | 'localandremote' | false;
libassMemoryLimit?: number;
libassGlyphLimit?: number;
+ /**
+ * The video's native/source resolution, used as libass' storage size. Set
+ * this on adaptive (MSE/HLS) streams — where `video.videoWidth` follows the
+ * currently-decoded rendition — so subtitles keep full quality regardless
+ * of the active bitrate. Defaults to the decoded video size.
+ */
+ videoWidth?: number;
+ videoHeight?: number;
} & ({
video: HTMLVideoElement;
canvas?: HTMLCanvasElement;
@@ -49,6 +57,8 @@ export default class JASSUB {
_video: HTMLVideoElement | undefined;
_videoWidth: number;
_videoHeight: number;
+ _storageWidth: number;
+ _storageHeight: number;
_videoColorSpace: string | null;
_canvas: HTMLCanvasElement;
_ro: ResizeObserver;
diff --git a/dist/jassub.js b/dist/jassub.js
index bbdaa8d4dcb5269617c53652c0702968093dc922..66cfdac4168e004ff9bb047da7e6ee6b53e02ce4 100644
--- a/dist/jassub.js
+++ b/dist/jassub.js
@@ -21,6 +21,13 @@ export default class JASSUB {
_video;
_videoWidth = 0;
_videoHeight = 0;
+ // The video's native/source resolution, used as libass' storage size. On
+ // adaptive (MSE/HLS) streams `video.videoWidth` reports the currently
+ // decoded rendition, which drops with bandwidth and would blur subtitles;
+ // set the `videoWidth`/`videoHeight` options to the stream's real
+ // resolution to keep subtitle quality independent of the active rendition.
+ _storageWidth = 0;
+ _storageHeight = 0;
_videoColorSpace = null;
_canvas;
_ro = new ResizeObserver(async () => {
@@ -41,6 +48,8 @@ export default class JASSUB {
JASSUB._test();
this.timeOffset = opts.timeOffset ?? 0;
this._video = opts.video;
+ this._storageWidth = opts.videoWidth ?? 0;
+ this._storageHeight = opts.videoHeight ?? 0;
this._canvas = opts.canvas ?? document.createElement('canvas');
if (this._video && !opts.canvas) {
this._canvas.className = 'JASSUB';
@@ -119,7 +128,7 @@ export default class JASSUB {
this._canvas.style.top = videoSize.y + 'px';
this._canvas.style.left = videoSize.x + 'px';
}
- await this.renderer._resizeCanvas(renderWidth, renderHeight, this._videoWidth || renderWidth, this._videoHeight || renderHeight);
+ await this.renderer._resizeCanvas(renderWidth, renderHeight, this._storageWidth || this._videoWidth || renderWidth, this._storageHeight || this._videoHeight || renderHeight);
if (this._lastDemandTime)
await this._demandRender(forceRepaint);
}
diff --git a/src/jassub.ts b/src/jassub.ts
index 65d290d6a5134aca26caf30c1720a2fbbe881430..c5c0596ec0db35b0607124cbb2045d4312c94259 100644
--- a/src/jassub.ts
+++ b/src/jassub.ts
@@ -37,6 +37,14 @@ export type JASSUBOptions = {
queryFonts?: 'local' | 'localandremote' | false
libassMemoryLimit?: number
libassGlyphLimit?: number
+ /**
+ * The video's native/source resolution, used as libass' storage size. Set
+ * this on adaptive (MSE/HLS) streams — where `video.videoWidth` follows the
+ * currently-decoded rendition — so subtitles keep full quality regardless of
+ * the active bitrate. Defaults to the decoded video size.
+ */
+ videoWidth?: number
+ videoHeight?: number
} & ({
video: HTMLVideoElement
canvas?: HTMLCanvasElement
@@ -63,6 +71,13 @@ export default class JASSUB {
_video
_videoWidth = 0
_videoHeight = 0
+ // The video's native/source resolution, used as libass' storage size. On
+ // adaptive (MSE/HLS) streams `video.videoWidth` reports the currently
+ // decoded rendition, which drops with bandwidth and would blur subtitles;
+ // set the `videoWidth`/`videoHeight` options to the stream's real
+ // resolution to keep subtitle quality independent of the active rendition.
+ _storageWidth = 0
+ _storageHeight = 0
_videoColorSpace: string | null = null
_canvas
_ro = new ResizeObserver(async () => {
@@ -83,6 +98,8 @@ export default class JASSUB {
this.timeOffset = opts.timeOffset ?? 0
this._video = opts.video
+ this._storageWidth = opts.videoWidth ?? 0
+ this._storageHeight = opts.videoHeight ?? 0
this._canvas = opts.canvas ?? document.createElement('canvas')
if (this._video && !opts.canvas) {
this._canvas.className = 'JASSUB'
@@ -183,8 +200,8 @@ export default class JASSUB {
await this.renderer._resizeCanvas(
renderWidth,
renderHeight,
- this._videoWidth || renderWidth,
- this._videoHeight || renderHeight
+ this._storageWidth || this._videoWidth || renderWidth,
+ this._storageHeight || this._videoHeight || renderHeight
)
if (this._lastDemandTime) await this._demandRender(forceRepaint)
-1
View File
@@ -2,7 +2,6 @@
"compilerOptions": {
"target": "ES2019",
"module": "CommonJS",
"moduleResolution": "Node",
"declaration": true,
"outDir": "build",
"rootDir": "src",
+197
View File
@@ -0,0 +1,197 @@
import {
type Selector,
selectAudioTrack,
selectBuffer,
selectError,
selectPlayback,
selectPlaybackRate,
selectQuality,
selectTextTrack,
selectTime,
selectVolume,
usePlayer as useStoreSelector,
} from "@videojs/react";
import { useEffect, useRef } from "react";
import type { WebOmniPlayer } from "./player.web";
import { usePlayer } from "./provider.web";
import type { OmniEvents } from "./types/events";
import type { OmniPlayerState } from "./types/player";
type EventMapperConfig = {
[Key in keyof OmniEvents]?: {
selector: Selector<any, any>;
handler: (cb: OmniEvents[Key], value: any, prev: any) => void;
};
};
function createEventMapper<Key extends keyof OmniEvents, Result>(
key: Key,
selector: Selector<any, Result>,
handler: (
cb: OmniEvents[Key],
value: Result,
prev: Result | undefined,
) => void,
) {
return { [key]: { selector, handler } };
}
const eventMapper: EventMapperConfig = {
...createEventMapper("end", selectPlayback, (cb, value, prev) => {
if (value?.ended && prev && !prev.ended) cb();
}),
...createEventMapper("error", selectError, (cb, value, prev) => {
if (value?.error && value.error !== prev?.error) {
cb(
value.error.message ?? "Unknown error",
value.error.message ?? "Unknown error",
);
}
}),
...createEventMapper("subtitleChange", selectTextTrack, (cb, value, prev) => {
if (value === prev) return;
const tracks = value?.textTrackList;
if (tracks?.length) {
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (track?.mode === "showing") {
cb({
id: `text-${i}`,
label: track.label,
language: track.language,
selected: true,
});
return;
}
}
}
cb(undefined);
}),
...createEventMapper("audioTrackChange", selectAudioTrack, (cb, value) => {
if (!value) return;
const track = value.audioTrackList.find((t) => t.enabled);
if (!track) return;
cb({
id: track.id!,
label: track.label,
language: track.language,
selected: true,
});
}),
...createEventMapper("renditionChange", selectQuality, (cb, value) => {
const active = value?.activeVideoRendition;
if (!active) return;
cb({
id: active.id!,
width: active.width ?? 0,
height: active.height ?? 0,
bitrate: active.bitrate ?? 0,
selected: true,
});
}),
};
export const useEvent = <Event extends keyof OmniEvents>(
event: Event,
callback: OmniEvents[Event],
) => {
const config = eventMapper[event];
const callbackRef = useRef(callback);
callbackRef.current = callback;
const prevRef = useRef<any>(undefined);
const value = useStoreSelector(config?.selector ?? (() => undefined));
const player = usePlayer() as WebOmniPlayer;
useEffect(() => {
if (!config) return;
config.handler(callbackRef.current as any, value, prevRef.current);
prevRef.current = value;
}, [value, config]);
useEffect(() => {
// don't use callbackRef.current directly to keep future callback updates
const cb = () => (callbackRef.current as () => void)();
if (event === "prev") {
player.onPrev.add(cb);
return () => {
player.onPrev.delete(cb);
};
}
if (event === "next") {
player.onNext.add(cb);
return () => {
player.onNext.delete(cb);
};
}
return undefined;
}, [event, player]);
};
function createMapper<Key extends keyof OmniPlayerState, State, Result>(
key: Key,
selector: Selector<State, Result>,
mapper: (ret: Result) => OmniPlayerState[Key],
): Pick<
{
[K in keyof OmniPlayerState]: {
selector: Selector<State, Result>;
mapper: (ret: Result) => OmniPlayerState[K];
};
},
Key
> {
return { [key]: { selector, mapper } } as any;
}
export const stateMapper = {
...createMapper("status", selectPlayback, (s) => {
if (s?.waiting) return "loading";
if (s?.ended) return "idle";
return "readyToPlay";
}),
...createMapper("isPlaying", selectPlayback, (s) => {
if (!s) return false;
return !s.paused;
}),
...createMapper("currentTime", selectTime, (s) => {
return s?.currentTime ?? 0;
}),
...createMapper("buffered", selectBuffer, (s) => {
if (!s?.buffered.length) return 0;
const last = s.buffered[s.buffered.length - 1];
return last?.[1] ?? 0;
}),
...createMapper("duration", selectTime, (s) => {
return s?.duration ?? 0;
}),
...createMapper("playbackRate", selectPlaybackRate, (s) => {
return s?.playbackRate ?? 1;
}),
...createMapper("volume", selectVolume, (s) => {
return s?.volume ?? 1;
}),
...createMapper("muted", selectVolume, (s) => {
return s?.muted ?? false;
}),
...createMapper("isAutoQuality", selectQuality, (q) => {
if (!q) return true;
return !q.videoRenditionList.some((r) => r.selected);
}),
};
export function usePlayerState<Key extends keyof OmniPlayerState>(
key: Key,
): OmniPlayerState[Key];
export function usePlayerState(
key: "currentTime",
refresh?: number,
): OmniPlayerState["currentTime"];
export function usePlayerState<Key extends keyof OmniPlayerState>(
key: Key,
_refresh?: number,
): OmniPlayerState[Key] {
const config = stateMapper[key];
if (!config) throw new Error(`No mapper for ${key}`);
const ret = useStoreSelector(config.selector as Selector<any, any>);
return config.mapper(ret) as OmniPlayerState[Key];
}
+16
View File
@@ -1,3 +1,19 @@
export { useEvent, usePlayerState } from "./events";
export { OmniProvider, usePlayer } from "./provider";
export type { OmniEvents } from "./types/events";
export type {
OmniPlayer,
OmniPlayerState,
PlayerStatus,
Rendition,
Track,
} from "./types/player";
export type {
Metadata,
MixAudioMode,
Source,
Subtitle,
VideoSrc,
} from "./types/source";
export type { SubtitleAssets } from "./types/subtitles";
export { OmniView } from "./view";
+343
View File
@@ -0,0 +1,343 @@
import type { MediaVideoRendition } from "@videojs/core";
import type { VideoPlayerStore } from "@videojs/core/dom";
import {
selectAudioTrack,
selectQuality,
selectTextTrack,
} from "@videojs/react";
import { stateMapper } from "./events.web";
import type {
OmniPlayer,
PlayerStatus,
Rendition,
Track,
} from "./types/player";
import type { Source, Subtitle } from "./types/source";
export type SubtitleFormat = "vtt" | "ass" | "pgs" | "native";
export const getSubtitleFormat = (subtitle: {
mimeType?: string;
link: string;
}): SubtitleFormat => {
const mime = subtitle.mimeType?.toLowerCase() ?? "";
const ext = subtitle.link.split(/[?#]/)[0]?.split(".").pop()?.toLowerCase();
if (
mime.includes("ass") ||
mime.includes("ssa") ||
ext === "ass" ||
ext === "ssa"
)
return "ass";
if (mime.includes("pgs") || ext === "sup") return "pgs";
if (mime.includes("vtt") || ext === "vtt") return "vtt";
return "native";
};
export const isCustomSubtitle = (subtitle: {
mimeType?: string;
link: string;
}): boolean => {
const format = getSubtitleFormat(subtitle);
return format === "ass" || format === "pgs";
};
export class WebOmniPlayer implements OmniPlayer {
_store: VideoPlayerStore;
onPrev = new Set<() => void>();
onNext = new Set<() => void>();
constructor(store: VideoPlayerStore) {
this._store = store;
}
_source: Source | null = null;
private _showNotification = false;
// Selected ASS/PGS subtitle (drawn by the overlay); `null` when the active
// subtitle is native or off. Exposed as an external store so the view can
// react to selection changes.
private overlaySubtitle: Subtitle | null = null;
private overlayListeners = new Set<() => void>();
get source(): Source | null {
return this._source;
}
set source(source: Source | null) {
this._source = source;
// Drop the overlay subtitle if it is not part of the new source.
if (
this.overlaySubtitle &&
!source?.subtitles.some((s) => s.id === this.overlaySubtitle?.id)
) {
this.setOverlaySubtitle(null);
}
this.updateMediaSession();
}
get showNotification(): boolean {
return this._showNotification;
}
set showNotification(value: boolean) {
this._showNotification = value;
this.updateMediaSession();
}
get status(): PlayerStatus {
const state = this._store.state;
return stateMapper.status.mapper(state);
}
get isPlaying(): boolean {
return !this._store.state.paused && !this._store.state.ended;
}
get currentTime(): number {
return this._store.state.currentTime;
}
set currentTime(time: number) {
this._store.seek(time).catch(() => {});
}
get buffered(): number {
const buffered = this._store.state.buffered;
if (buffered.length === 0) return 0;
return buffered[buffered.length - 1]?.[1] ?? 0;
}
get duration(): number {
return this._store.state.duration;
}
get playbackRate(): number {
return this._store.state.playbackRate;
}
set playbackRate(rate: number) {
this._store.setPlaybackRate(rate);
}
get volume(): number {
return this._store.state.volume;
}
set volume(vol: number) {
this._store.setVolume(vol);
}
get muted(): boolean {
return this._store.state.muted;
}
set muted(value: boolean) {
if (value !== this.muted) {
this._store.toggleMuted();
}
}
get isAutoQuality(): boolean {
const quality = selectQuality(this._store.state);
if (!quality) return true;
// ABR ("auto") is on whenever no rendition is explicitly pinned.
return !quality.videoRenditionList.some((r) => r.selected);
}
play(): void {
this._store.play();
this.setPlaybackState("playing");
}
pause(): void {
this._store.pause();
this.setPlaybackState("paused");
}
seekBy(offset: number): void {
this._store.seek(this.currentTime + offset).catch(() => {});
}
playPrev(): void {
for (const cb of this.onPrev) cb();
}
playNext(): void {
for (const cb of this.onNext) cb();
}
get hasPrev(): boolean {
return this._source?.metadata?.hasPrev ?? false;
}
get hasNext(): boolean {
return this._source?.metadata?.hasNext ?? false;
}
get videos(): Track[] {
// hls.js does not support alternative video tracks (e.g. camera angles)
return [];
}
selectVideo(_video: Track): void {
// hls.js does not support alternative video tracks
}
get audios(): Track[] {
const audio = selectAudioTrack(this._store.state);
if (!audio) return [];
return audio.audioTrackList.map((track, i) => ({
id: track.id ?? i.toString(),
label: track.label,
language: track.language,
selected: track.enabled,
}));
}
selectAudio(audio: Track): void {
const tracks = selectAudioTrack(this._store.state);
if (!tracks) return;
tracks.selectAudioTrack(audio.id);
}
private get overlaySubtitles(): Subtitle[] {
return (this._source?.subtitles ?? []).filter(isCustomSubtitle);
}
get subtitles(): Track[] {
const textTracks = selectTextTrack(this._store.state)?.textTrackList ?? [];
const native = textTracks
.filter((x) => x.kind === "subtitles" || x.kind === "captions")
.map((track) => ({
id: track.id!,
label: track.label,
language: track.language,
selected: track.mode === "showing",
}));
const overlay = this.overlaySubtitles.map((sub) => ({
id: sub.id,
label: sub.label,
language: sub.language,
selected: this.overlaySubtitle?.id === sub.id,
}));
return [...native, ...overlay];
}
selectSubtitle(subtitle?: Track): void {
const overlay = subtitle
? this.overlaySubtitles.find((s) => s.id === subtitle.id)
: undefined;
const tracks = selectTextTrack(this._store.state);
tracks?.selectSubtitlesTrack(overlay || !subtitle ? "off" : subtitle.id);
this.setOverlaySubtitle(overlay ?? null);
}
private setOverlaySubtitle(sub: Subtitle | null): void {
if (this.overlaySubtitle === sub) return;
this.overlaySubtitle = sub;
for (const listener of this.overlayListeners) listener();
}
// External store used by the view to render the ASS/PGS overlay.
subscribeOverlaySubtitle = (callback: () => void): (() => void) => {
this.overlayListeners.add(callback);
return () => this.overlayListeners.delete(callback);
};
getOverlaySubtitle = (): Subtitle | null => this.overlaySubtitle;
get rendition(): Rendition[] {
function isSameRendition(
a: MediaVideoRendition,
b: MediaVideoRendition | null,
): boolean {
return (
b != null &&
a.id === b.id &&
a.width === b.width &&
a.height === b.height &&
a.bitrate === b.bitrate
);
}
const quality = selectQuality(this._store.state);
if (!quality) return [];
const active = quality.activeVideoRendition;
return quality.videoRenditionList.map((rendition, i) => ({
id: rendition.id ?? i.toString(),
width: rendition.width ?? 0,
height: rendition.height ?? 0,
bitrate: rendition.bitrate ?? 0,
selected: rendition.selected || isSameRendition(rendition, active),
}));
}
selectRendition(rendition?: Rendition): void {
const tracks = selectQuality(this._store.state);
if (!tracks) return;
tracks.selectVideoRendition(rendition ? rendition.id : "auto");
}
private setPlaybackState(state: MediaSessionPlaybackState): void {
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) {
return;
}
if (this._showNotification) navigator.mediaSession.playbackState = state;
}
private updateMediaSession(): void {
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) {
return;
}
const session = navigator.mediaSession;
const actions: MediaSessionAction[] = [
"play",
"pause",
"seekbackward",
"seekforward",
"seekto",
"previoustrack",
"nexttrack",
];
if (!this._showNotification) {
session.metadata = null;
for (const action of actions) {
try {
session.setActionHandler(action, null);
} catch {}
}
return;
}
const metadata = this._source?.metadata;
if (metadata && typeof MediaMetadata !== "undefined") {
session.metadata = new MediaMetadata({
title: metadata.title,
artist: metadata.artist ?? "",
album: metadata.album ?? "",
artwork: metadata.imageLink ? [{ src: metadata.imageLink }] : [],
});
}
const set = (
action: MediaSessionAction,
handler: MediaSessionActionHandler | null,
) => {
try {
session.setActionHandler(action, handler);
} catch {}
};
set("play", () => this.play());
set("pause", () => this.pause());
set("seekbackward", (d) => this.seekBy(-(d.seekOffset ?? 10)));
set("seekforward", (d) => this.seekBy(d.seekOffset ?? 10));
set("seekto", (d) => {
if (d.seekTime != null) this.currentTime = d.seekTime;
});
set("previoustrack", this.hasPrev ? () => this.playPrev() : null);
set("nexttrack", this.hasNext ? () => this.playNext() : null);
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ export const OmniProvider = ({
const player = useLazyRef(() => ProviderFactory.createPlayer(source));
useEffect(() => {
player.source = source;
player.setSource(source);
}, [source]);
useEffect(() => {
+66
View File
@@ -0,0 +1,66 @@
import { createPlayer } from "@videojs/react";
import { videoFeatures } from "@videojs/react/video";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useRef,
} from "react";
import { WebOmniPlayer } from "./player.web";
import type { OmniPlayer } from "./types/player";
import type { Source } from "./types/source";
import { useLazyRef } from "./utils/lazy-ref";
export const VideoPlayer = createPlayer({ features: videoFeatures });
const PlayerCtx = createContext<OmniPlayer>(null!);
export const OmniProvider = ({
children,
source,
showNotification = false,
}: {
source: Source;
children: ReactNode;
showNotification?: boolean;
}) => {
return (
<VideoPlayer.Provider>
<PlayerInitializer source={source} showNotification={showNotification}>
{children}
</PlayerInitializer>
</VideoPlayer.Provider>
);
};
const PlayerInitializer = ({
children,
source,
showNotification,
}: {
children: ReactNode;
source: Source;
showNotification: boolean;
}) => {
const store = VideoPlayer.usePlayer();
const player = useLazyRef(() => new WebOmniPlayer(store));
const seekedForSrc = useRef<string | undefined>(undefined);
useEffect(() => {
player.source = source;
const uri = source.src[0]?.uri;
if (uri !== seekedForSrc.current) {
seekedForSrc.current = uri;
if (source.startTime) store.seek(source.startTime)
}
}, [source, store]);
useEffect(() => {
player.showNotification = showNotification;
}, [showNotification]);
return <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>;
};
export const usePlayer = () => useContext(PlayerCtx);
+2
View File
@@ -56,6 +56,8 @@ export interface OmniPlayer
extends HybridObject<{ android: "kotlin" }>,
OmniPlayerT {
readonly eventMap: OmniEventMap;
setSource(src: Source): void;
}
export interface OmniPlayerFactory extends HybridObject<{ android: "kotlin" }> {
+1 -4
View File
@@ -1,7 +1,4 @@
import type { Source } from "./source";
export interface OmniPlayer extends OmniPlayerState {
source: Source;
showNotification?: boolean;
play(): void;
@@ -34,7 +31,7 @@ export interface OmniPlayerState {
// between 0 and 1
volume: number;
muted: boolean;
readonly isAutoQuality: boolean
readonly isAutoQuality: boolean;
}
export type PlayerStatus = "idle" | "loading" | "readyToPlay" | "error";
+4 -1
View File
@@ -2,6 +2,9 @@ export interface Source {
src: VideoSrc[];
startTime?: number;
subtitles: Subtitle[];
// fonts that can be used by ass subtitles on the web (native will use
// embedded fonts)
fonts?: string[];
metadata?: Metadata;
mixAudio?: MixAudioMode;
}
@@ -11,7 +14,7 @@ export interface VideoSrc {
mimeType?: string;
// header sends with xhr requests (especially useful for HLS)
// might not be available depending on browser/platform.
headers: Record<string, string>;
headers: Record<string, string | undefined>;
}
export interface Subtitle {
+16
View File
@@ -0,0 +1,16 @@
export interface SubtitleAssets {
jassub?: {
/** URL of `jassub-worker.js`. */
workerUrl?: string;
/** URL of `jassub-worker.wasm`. */
wasmUrl?: string;
/** URL of `jassub-worker-modern.wasm`. */
modernWasmUrl?: string;
/** URL of a fallback font used when the ASS file references none. */
fontUrl?: string;
};
pgs?: {
/** URL of `libpgs.worker.js`. */
workerUrl?: string;
};
}
+4
View File
@@ -1,4 +1,8 @@
import type { SubtitleAssets } from "./subtitles";
export interface OmniViewProps {
autoplay?: boolean;
autoPip?: boolean;
/** Web-only: URLs for the ASS/PGS subtitle renderer assets. */
subtitleAssets?: SubtitleAssets;
}
+1 -1
View File
@@ -2,5 +2,5 @@ import { useState } from "react";
export const useLazyRef = <T>(init: () => T): T => {
const [ret] = useState<T>(init);
return ret
return ret;
};
+12 -6
View File
@@ -1,3 +1,5 @@
import { memo } from "react";
import type { ViewStyle } from "react-native";
import {
getHostComponent,
type HybridViewMethods,
@@ -7,14 +9,18 @@ import { usePlayer } from "./provider";
import type { OmniPlayer } from "./specs/omni-player.nitro";
import type { Props } from "./specs/omni-view.nitro";
import type { OmniViewProps } from "./types/view";
import type { ViewStyle } from "react-native";
import { memo } from "react";
const NativeView = memo(
getHostComponent<Props, HybridViewMethods>("OmniView", () => OmniConfig),
);
export const OmniView = memo((props: OmniViewProps & { style: ViewStyle }) => {
const player = usePlayer() as OmniPlayer;
return <NativeView player={player} {...props} />;
});
export const OmniView = memo(
({
// Web-only; not forwarded to the native view.
subtitleAssets: _subtitleAssets,
...props
}: OmniViewProps & { style: ViewStyle }) => {
const player = usePlayer() as OmniPlayer;
return <NativeView player={player} {...props} />;
},
);
+171
View File
@@ -0,0 +1,171 @@
import type { HlsMediaConfig } from "@videojs/core/dom/media/hls-js";
import { HlsJsVideo } from "@videojs/react/media/hlsjs-video";
import { Video } from "@videojs/react/video";
import {
type CSSProperties,
type RefObject,
useEffect,
useMemo,
useRef,
useSyncExternalStore,
} from "react";
import {
getSubtitleFormat,
isCustomSubtitle,
type WebOmniPlayer,
} from "./player.web";
import { usePlayer, VideoPlayer } from "./provider.web";
import type { SubtitleAssets } from "./types/subtitles";
import type { OmniViewProps } from "./types/view";
const SubtitleOverlay = ({
video,
assets,
fonts,
}: {
video: RefObject<HTMLVideoElement>;
assets?: SubtitleAssets;
fonts?: string[];
}) => {
const player = usePlayer() as WebOmniPlayer;
const subtitle = useSyncExternalStore(
player.subscribeOverlaySubtitle,
player.getOverlaySubtitle,
() => null,
);
const assetsRef = useRef(assets);
assetsRef.current = assets;
const fontsRef = useRef(fonts);
fontsRef.current = fonts;
useEffect(() => {
const el = video.current;
if (!el || !subtitle) return;
let renderer: { destroy(): void } | null = null;
let cancelled = false;
const attach = (created: { destroy(): void }) => {
if (cancelled) created.destroy();
else renderer = created;
};
if (getSubtitleFormat(subtitle) === "ass") {
const jassub = assetsRef.current?.jassub;
const fonts = fontsRef.current;
// The video's source resolution (its highest available rendition).
// jassub uses this as libass' storage size so subtitles stay sharp
// even while a low-bitrate rendition is playing — otherwise the
// raster tracks the currently-decoded (possibly tiny) frame size.
const renditions = player.rendition;
const videoWidth = Math.max(0, ...renditions.map((r) => r.width));
const videoHeight = Math.max(0, ...renditions.map((r) => r.height));
import("jassub").then(({ default: JASSUB }) => {
const instance = new JASSUB({
video: el,
subUrl: subtitle.link,
...(videoWidth && { videoWidth }),
...(videoHeight && { videoHeight }),
...(fonts?.length && { fonts }),
...(jassub?.workerUrl && { workerUrl: jassub.workerUrl }),
...(jassub?.wasmUrl && { wasmUrl: jassub.wasmUrl }),
...(jassub?.modernWasmUrl && { modernWasmUrl: jassub.modernWasmUrl }),
...(jassub?.fontUrl && {
availableFonts: { "liberation sans": jassub.fontUrl },
defaultFont: "liberation sans",
}),
});
attach({ destroy: () => instance.destroy() });
});
} else {
const pgs = assetsRef.current?.pgs;
import("libpgs").then(({ PgsRenderer }) => {
const instance = new PgsRenderer({
video: el,
subUrl: subtitle.link,
workerUrl:
pgs?.workerUrl ??
new URL("libpgs/dist/libpgs.worker.js", import.meta.url).href,
});
attach({ destroy: () => instance.dispose() });
});
}
return () => {
cancelled = true;
renderer?.destroy();
};
}, [video, subtitle, player]);
return null;
};
export const OmniView = ({
style,
autoplay,
subtitleAssets,
}: OmniViewProps & { style: CSSProperties }) => {
const player = usePlayer() as WebOmniPlayer;
const containerRef = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLVideoElement>(undefined!);
const src = player.source?.src[0];
const isHls =
src?.mimeType?.toLowerCase().includes("mpegurl") ||
src?.uri.split(/[?#]/)[0]?.toLowerCase().endsWith(".m3u8") ||
false;
const Tech = isHls ? HlsJsVideo : Video;
const headersRef = useRef(src?.headers);
headersRef.current = src?.headers;
const config = useMemo<HlsMediaConfig>(
() => ({
hlsJs: {
xhrSetup: (xhr: XMLHttpRequest) => {
const headers = headersRef.current;
if (!headers) return;
for (const [key, value] of Object.entries(headers)) {
if (value) xhr.setRequestHeader(key, value);
}
},
},
}),
[],
);
return (
<VideoPlayer.Container
ref={containerRef}
style={{ position: "relative", ...style }}
>
{src && (
<Tech
ref={ref}
src={src.uri}
config={isHls ? config : undefined}
autoPlay={autoplay}
playsInline
crossOrigin="anonymous"
style={{ width: "100%", height: "100%", objectFit: "contain" }}
>
{(player.source?.subtitles ?? [])
.filter((subtitle) => !isCustomSubtitle(subtitle))
.map((subtitle) => (
<track
key={subtitle.id}
id={subtitle.id}
kind="subtitles"
src={subtitle.link}
srcLang={subtitle.language}
label={subtitle.label ?? subtitle.language ?? subtitle.id}
/>
))}
</Tech>
)}
<SubtitleOverlay
video={ref}
assets={subtitleAssets}
fonts={player.source?.fonts}
/>
</VideoPlayer.Container>
);
};
+2 -4
View File
@@ -6,14 +6,12 @@
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",
"lib": ["esnext"],
"lib": ["esnext", "dom"],
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"noEmit": false,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitUseStrict": false,
"noStrictGenericChecks": false,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,