Compare commits

..
27 changed files with 2522 additions and 474 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
```
+1479 -255
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/
+43 -16
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,
@@ -381,15 +391,18 @@ function PlayerExample({
isAutoQuality && styles.selectedTrackButtonText,
]}
>
Auto{isAutoQuality && tracks.renditions.find(r => r.selected) ? ` (${tracks.renditions.find(r => r.selected)!.height}p)` : ""}
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,
},
+3 -3
View File
@@ -24,9 +24,9 @@
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@react-native-community/cli": "20.2.0",
"@react-native-community/cli-platform-android": "20.2.0",
"@react-native-community/cli-platform-ios": "20.2.0",
"@react-native-community/cli": "20.1.0",
"@react-native-community/cli-platform-android": "20.1.0",
"@react-native-community/cli-platform-ios": "20.1.0",
"@react-native/babel-preset": "0.84.1",
"@react-native/eslint-config": "0.84.1",
"@react-native/metro-config": "0.84.1",
Binary file not shown.
-1
View File
@@ -4,7 +4,6 @@
"exclude": ["**/node_modules", "**/Pods"],
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"react-native-omni": ["../src"]
}
+70 -61
View File
@@ -5,65 +5,74 @@ 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,
},
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") }],
},
};
+30 -19
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",
@@ -14,7 +14,9 @@
"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",
@@ -47,34 +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",
"@videojs/react": "^10.0.0-beta.24"
"@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",
@@ -89,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",
+3
View File
@@ -52,6 +52,9 @@ export function usePlayerState<Key extends keyof OmniPlayerState>(
case "status":
em.addPlayerStatusListener(setState);
return () => em.removePlayerStatusListener(setState);
case "castStatus":
// TODO: implement this
return;
}
}, [player, key]);
+62 -14
View File
@@ -1,15 +1,20 @@
import {
type Selector,
selectAudioTrack,
selectBuffer,
selectError,
selectPlayback,
selectPlaybackRate,
selectQuality,
selectRemotePlayback,
selectTextTrack,
selectTime,
selectVolume,
usePlayer,
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";
@@ -63,6 +68,29 @@ const eventMapper: EventMapperConfig = {
}
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>(
@@ -73,15 +101,31 @@ export const useEvent = <Event extends keyof OmniEvents>(
const callbackRef = useRef(callback);
callbackRef.current = callback;
const prevRef = useRef<any>(undefined);
const value = usePlayer(config?.selector ?? (() => ({})));
const value = useStoreSelector(config?.selector ?? (() => undefined));
const player = usePlayer() as WebOmniPlayer;
useEffect(() => {
if (!config) return;
const prev = prevRef.current;
config.handler(callbackRef.current as any, value, prev);
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>(
@@ -130,13 +174,17 @@ export const stateMapper = {
...createMapper("muted", selectVolume, (s) => {
return s?.muted ?? false;
}),
...createMapper(
"isAutoQuality",
() => {},
() => {
return true;
},
),
...createMapper("isAutoQuality", selectQuality, (q) => {
if (!q) return true;
return !q.videoRenditionList.some((r) => r.selected);
}),
...createMapper("castStatus", selectRemotePlayback, (r) => {
if (!r) return "unavailable";
if (r.remotePlaybackState === "connecting") return "connecting";
if (r.remotePlaybackState === "connected") return "connected";
if (r.remotePlaybackAvailability === "available") return "available";
return "unavailable";
}),
};
export function usePlayerState<Key extends keyof OmniPlayerState>(
@@ -152,6 +200,6 @@ export function usePlayerState<Key extends keyof OmniPlayerState>(
): OmniPlayerState[Key] {
const config = stateMapper[key];
if (!config) throw new Error(`No mapper for ${key}`);
const ret = usePlayer(config.selector as Selector<any, any>);
const ret = useStoreSelector(config.selector as Selector<any, any>);
return config.mapper(ret) as OmniPlayerState[Key];
}
+17
View File
@@ -1,3 +1,20 @@
export { useEvent, usePlayerState } from "./events";
export { OmniProvider, usePlayer } from "./provider";
export type { OmniEvents } from "./types/events";
export type {
CastStatus,
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";
+223 -59
View File
@@ -1,37 +1,97 @@
import type { MediaVideoRendition } from "@videojs/core";
import type { VideoPlayerStore } from "@videojs/core/dom";
import type Hls from "hls.js";
import {
selectAudioTrack,
selectQuality,
selectTextTrack,
} from "@videojs/react";
import { stateMapper } from "./events.web";
import type {
CastStatus,
OmniPlayer,
PlayerStatus,
Rendition,
Track,
} from "./types/player";
import type { Source } from "./types/source";
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?: () => void;
onNext?: () => void;
onPrev = new Set<() => void>();
onNext = new Set<() => void>();
constructor(store: VideoPlayerStore) {
this._store = store;
}
private getHls(): Hls | null {
const media = this._store.target?.media;
return (media?.engine as Hls | undefined) ?? null;
get castStatus(): CastStatus {
return stateMapper.castStatus.mapper(this._store.state);
}
source: Source | null = null;
toggleCastStatus(): void {
this._store.state.toggleRemotePlayback();
}
_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 {
// TODO
return false;
return this._showNotification;
}
set showNotification(_: boolean) {
// TODO
set showNotification(value: boolean) {
this._showNotification = value;
this.updateMediaSession();
}
get status(): PlayerStatus {
@@ -47,6 +107,10 @@ export class WebOmniPlayer implements OmniPlayer {
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;
@@ -84,17 +148,20 @@ export class WebOmniPlayer implements OmniPlayer {
}
get isAutoQuality(): boolean {
const hls = this.getHls();
if (!hls) return false;
return hls.autoLevelEnabled;
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 {
@@ -102,21 +169,19 @@ export class WebOmniPlayer implements OmniPlayer {
}
playPrev(): void {
this.onPrev?.();
for (const cb of this.onPrev) cb();
}
playNext(): void {
this.onNext?.();
for (const cb of this.onNext) cb();
}
get hasPrev(): boolean {
// TODO
return false;
return this._source?.metadata?.hasPrev ?? false;
}
get hasNext(): boolean {
// TODO
return false;
return this._source?.metadata?.hasNext ?? false;
}
get videos(): Track[] {
@@ -129,60 +194,159 @@ export class WebOmniPlayer implements OmniPlayer {
}
get audios(): Track[] {
const hls = this.getHls();
if (!hls) return [];
const currentTrackId = hls.audioTrack;
return hls.audioTracks.map((track, index) => ({
id: index.toString(),
label: track.name,
language: track.lang,
selected: index === currentTrackId,
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 hls = this.getHls();
if (!hls) return;
hls.audioTrack = parseInt(audio.id, 10);
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[] {
return this._store.textTrackList
.map((x, i) => ({
id: i.toString(),
kind: x.kind,
label: x.label,
language: x.language,
selected: x.mode === "showing",
}))
.filter((x) => x.kind === "subtitles" || x.kind === "captions");
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 {
for (let i = 0; i < this._store.textTrackList.length; i++) {
const track = this._store.textTrackList[i]!;
if (track.kind !== "subtitles" && track.kind !== "captions") continue;
track.mode =
subtitle && i.toString() === subtitle.id ? "showing" : "hidden";
}
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[] {
const hls = this.getHls();
if (!hls) return [];
const currentLevel = hls.currentLevel;
return hls.levels.map((level, index) => ({
id: index.toString(),
width: level.width,
height: level.height,
bitrate: level.bitrate,
selected: index === currentLevel,
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 hls = this.getHls();
if (!hls) return;
hls.nextLevel = rendition ? parseInt(rendition.id, 10) : -1;
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);
}
}
+14 -2
View File
@@ -1,6 +1,12 @@
import { createPlayer } from "@videojs/react";
import { videoFeatures } from "@videojs/react/video";
import { createContext, type ReactNode, useContext, useEffect } from "react";
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";
@@ -39,10 +45,16 @@ const PlayerInitializer = ({
}) => {
const store = VideoPlayer.usePlayer();
const player = useLazyRef(() => new WebOmniPlayer(store));
const seekedForSrc = useRef<string | undefined>(undefined);
useEffect(() => {
player.source = 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;
+9
View File
@@ -5,6 +5,8 @@ export interface OmniPlayer extends OmniPlayerState {
pause(): void;
seekBy(offset: number): void;
toggleCastStatus(): void;
// trigger the prev/next event manually, the user has to implement the event
playPrev(): void;
playNext(): void;
@@ -32,10 +34,17 @@ export interface OmniPlayerState {
volume: number;
muted: boolean;
readonly isAutoQuality: boolean;
readonly castStatus: CastStatus;
}
export type PlayerStatus = "idle" | "loading" | "readyToPlay" | "error";
export type CastStatus =
| "connecting"
| "connected"
| "available"
| "unavailable";
export interface Track {
readonly id: string;
readonly label?: string;
+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} />;
},
);
+150 -11
View File
@@ -1,32 +1,171 @@
import { HlsVideo } from "@videojs/react/media/hls-video";
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 } from "react";
import { useRef } from "react";
import type { WebOmniPlayer } from "./player.web";
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 uri = player.source?.src[0]?.uri;
const Tech = uri?.endsWith("m3u8") ? HlsVideo : Video;
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={style}>
{uri && (
<VideoPlayer.Container
ref={containerRef}
style={{ position: "relative", ...style }}
>
{src && (
<Tech
src={uri}
ref={ref}
src={src.uri}
config={isHls ? config : undefined}
autoPlay={autoplay}
playsInline
crossOrigin="anonymous"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
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
View File
@@ -12,8 +12,6 @@
"noEmit": false,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitUseStrict": false,
"noStrictGenericChecks": false,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,