diff --git a/bun.lock b/bun.lock
index 8b0b2b9..3c50218 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,6 +6,8 @@
"dependencies": {
"@expo/config-plugins": "^10.0.2",
"@videojs/react": "10.0.0-beta.25",
+ "jassub": "^2.5.6",
+ "libpgs": "^0.8.1",
},
"devDependencies": {
"@biomejs/biome": "2.4.10",
@@ -707,6 +709,8 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
+ "abslink": ["abslink@1.2.2", "", {}, "sha512-HCOItvzORPjMzddCmEpbH8+W1VM3yjH+CR7DLtxlxFKX/jz5twJbm7KLfblO7eKLnXINLah8Hk2ZAJqxXeSkOg=="],
+
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -1403,6 +1407,8 @@
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
+ "jassub": ["jassub@2.5.6", "", { "dependencies": { "abslink": "^1.2.2", "lfa-ponyfill": "^1.1.0", "rvfc-polyfill": "^1.0.8", "throughput": "^1.0.2" } }, "sha512-jsbEPVR+JvcOcGa8PbyVvHmFol+rQtAfty7JDp1WWrS3GEfThitjI5sXT0VLfG6/BLuyIuv1+2qykWdWyLkpQw=="],
+
"jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="],
"jest-environment-node": ["jest-environment-node@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw=="],
@@ -1461,6 +1467,10 @@
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
+ "lfa-ponyfill": ["lfa-ponyfill@1.1.1", "", {}, "sha512-Had4tsGEHtIj58MRtf6SlubNDltxinxMSdxIn1GdwrWbye8TD+pZ+D6qKETnJ9+vQOfG9q/Ds4WUPskhyAMp5Q=="],
+
+ "libpgs": ["libpgs@0.8.1", "", {}, "sha512-Z8WCvHRYr37QjU8b2WqiJpCvnoojbYPtoE4FktwdI+m+b1QkNIKqrYQrlPKG1xvYyQWgV1j+16xjFOhun4nBUA=="],
+
"lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
"lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="],
@@ -1817,6 +1827,8 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
+ "rvfc-polyfill": ["rvfc-polyfill@1.0.8", "", {}, "sha512-uA+0wwTkZ4OT8v45pfDfH+7Yq8mY6MvNngiF5Sq6VBgjJsvsfgt7Q18cyZqZjfAhW9rhkgXPX0cW0R9uw7yElA=="],
+
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -1963,6 +1975,8 @@
"throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="],
+ "throughput": ["throughput@1.0.2", "", {}, "sha512-jvK1ZXuhsggjb3qYQjMiU/AVYYiTeqT5thWvYR2yuy2LGM84P5MSSyAinwHahGsdBYKR9m9HncVR/3f3nFKkxg=="],
+
"thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
diff --git a/example/App.tsx b/example/App.tsx
index 08eb255..c9c705f 100644
--- a/example/App.tsx
+++ b/example/App.tsx
@@ -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,
@@ -34,7 +34,6 @@ const PLAYLIST = [
},
] as const;
-
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return "00:00";
@@ -217,7 +216,21 @@ function PlayerExample({
react-native-omni
{trackLabel}
-
+
@@ -381,7 +394,10 @@ 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)`
+ : ""}
{tracks.renditions.length === 0 ? (
@@ -449,7 +465,21 @@ 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,
diff --git a/example/public/jassub/default.woff2 b/example/public/jassub/default.woff2
new file mode 100644
index 0000000..a562391
Binary files /dev/null and b/example/public/jassub/default.woff2 differ
diff --git a/example/webpack.config.js b/example/webpack.config.js
index d6e6ccd..483b8f8 100644
--- a/example/webpack.config.js
+++ b/example/webpack.config.js
@@ -5,65 +5,96 @@ 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",
+ },
+ {
+ // Disable webpack's `new URL(..., import.meta.url)` asset emission for
+ // jassub. jassub references a `./default.woff2` it does not ship (which
+ // would fail the build) and its wasm URLs; we self-host the wasm + font
+ // and pass them via `subtitleAssets` instead. The worker itself is still
+ // bundled (that goes through `new Worker(new URL(...))`, not this rule).
+ test: /[\\/]jassub[\\/]/,
+ parser: { url: false },
+ },
+ ],
+ },
+ 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",
+ },
+ // Host the ASS/PGS subtitle renderer assets under `/jassub` and `/pgs`.
+ // jassub/libpgs ship the worker + wasm; `public/jassub/default.woff2`
+ // provides the fallback font jassub does not bundle.
+ static: [
+ { directory: path.resolve(appDirectory, "public") },
+ {
+ directory: path.resolve(rootDirectory, "node_modules/jassub/dist/wasm"),
+ publicPath: "/jassub",
+ },
+ {
+ directory: path.resolve(rootDirectory, "node_modules/libpgs/dist"),
+ publicPath: "/pgs",
+ },
+ ],
+ },
};
diff --git a/package.json b/package.json
index 7ec19f9..4435863 100644
--- a/package.json
+++ b/package.json
@@ -58,7 +58,9 @@
},
"dependencies": {
"@expo/config-plugins": "^10.0.2",
- "@videojs/react": "10.0.0-beta.25"
+ "@videojs/react": "10.0.0-beta.25",
+ "jassub": "^2.5.6",
+ "libpgs": "^0.8.1"
},
"devDependencies": {
"@biomejs/biome": "2.4.10",
diff --git a/src/index.ts b/src/index.ts
index 7c95962..d407f99 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,4 @@
export { useEvent, usePlayerState } from "./events";
export { OmniProvider, usePlayer } from "./provider";
+export type { SubtitleAssets } from "./types/subtitles";
export { OmniView } from "./view";
diff --git a/src/player.web.tsx b/src/player.web.tsx
index a35ced6..5227fed 100644
--- a/src/player.web.tsx
+++ b/src/player.web.tsx
@@ -1,6 +1,10 @@
import type { MediaVideoRendition } from "@videojs/core";
import type { VideoPlayerStore } from "@videojs/core/dom";
-import { selectAudioTrack, selectQuality } from "@videojs/react";
+import {
+ selectAudioTrack,
+ selectQuality,
+ selectTextTrack,
+} from "@videojs/react";
import { stateMapper } from "./events.web";
import type {
OmniPlayer,
@@ -8,7 +12,35 @@ import type {
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;
@@ -22,12 +54,25 @@ export class WebOmniPlayer implements OmniPlayer {
_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();
}
@@ -156,27 +201,53 @@ export class WebOmniPlayer implements OmniPlayer {
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[] {
function isSameRendition(
a: MediaVideoRendition,
diff --git a/src/types/source.ts b/src/types/source.ts
index ee043ac..41ba85a 100644
--- a/src/types/source.ts
+++ b/src/types/source.ts
@@ -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;
}
diff --git a/src/types/subtitles.ts b/src/types/subtitles.ts
new file mode 100644
index 0000000..71a7614
--- /dev/null
+++ b/src/types/subtitles.ts
@@ -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;
+ };
+}
diff --git a/src/types/view.ts b/src/types/view.ts
index 2478614..02f2f10 100644
--- a/src/types/view.ts
+++ b/src/types/view.ts
@@ -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;
}
diff --git a/src/utils/lazy-ref.ts b/src/utils/lazy-ref.ts
index bc5da85..2791615 100644
--- a/src/utils/lazy-ref.ts
+++ b/src/utils/lazy-ref.ts
@@ -2,5 +2,5 @@ import { useState } from "react";
export const useLazyRef = (init: () => T): T => {
const [ret] = useState(init);
- return ret
+ return ret;
};
diff --git a/src/view.tsx b/src/view.tsx
index 1657bf5..e8c6e60 100644
--- a/src/view.tsx
+++ b/src/view.tsx
@@ -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("OmniView", () => OmniConfig),
);
-export const OmniView = memo((props: OmniViewProps & { style: ViewStyle }) => {
- const player = usePlayer() as OmniPlayer;
- return ;
-});
+export const OmniView = memo(
+ ({
+ // Web-only; not forwarded to the native view.
+ subtitleAssets: _subtitleAssets,
+ ...props
+ }: OmniViewProps & { style: ViewStyle }) => {
+ const player = usePlayer() as OmniPlayer;
+ return ;
+ },
+);
diff --git a/src/view.web.tsx b/src/view.web.tsx
index 3a071b6..66008fe 100644
--- a/src/view.web.tsx
+++ b/src/view.web.tsx
@@ -1,33 +1,100 @@
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, useMemo, useRef } from "react";
-import type { WebOmniPlayer } from "./player.web";
-import { usePlayer } from "./provider";
-import { VideoPlayer } from "./provider.web";
-import type { Subtitle } from "./types/source";
+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 SubtitleTracks = ({ subtitles }: { subtitles: Subtitle[] }) => (
- <>
- {subtitles.map((subtitle) => (
-
- ))}
- >
-);
+const SubtitleOverlay = ({
+ video,
+ assets,
+ fonts,
+}: {
+ video: RefObject;
+ 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;
+ import("jassub").then(({ default: JASSUB }) => {
+ const instance = new JASSUB({
+ video: el,
+ subUrl: subtitle.link,
+ ...(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,
+ ...(pgs?.workerUrl && { workerUrl: pgs.workerUrl }),
+ });
+ attach({ destroy: () => instance.dispose() });
+ });
+ }
+
+ return () => {
+ cancelled = true;
+ renderer?.destroy();
+ };
+ }, [video, subtitle]);
+
+ return null;
+};
export const OmniView = ({
style,
autoplay,
+ subtitleAssets,
}: OmniViewProps & { style: CSSProperties }) => {
const player = usePlayer() as WebOmniPlayer;
const containerRef = useRef(null);
+ const ref = useRef(undefined!);
const src = player.source?.src[0];
const Tech = src?.uri.endsWith("m3u8") ? HlsJsVideo : Video;
@@ -47,23 +114,38 @@ export const OmniView = ({
}, [src?.headers]);
return (
-
+
{src && (
-
+ {(player.source?.subtitles ?? [])
+ .filter((subtitle) => !isCustomSubtitle(subtitle))
+ .map((subtitle) => (
+
+ ))}
)}
+
);
};