mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-05 13:46:59 +00:00
Add jassub/libpgs support on the web
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -73,3 +73,4 @@ yarn-error.log
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
dist
|
||||
|
||||
@@ -54,6 +54,15 @@ module.exports = {
|
||||
test: /\.(png|jpe?g|gif|svg)$/i,
|
||||
type: "asset/resource",
|
||||
},
|
||||
{
|
||||
// jassub references its bundled `./default.woff2` via
|
||||
// `new URL(..., import.meta.url)` in a branch that only runs when no
|
||||
// `defaultFont` is provided. We always pass one, so disable webpack's
|
||||
// eager URL-asset parsing for jassub to avoid resolving a file it does
|
||||
// not ship.
|
||||
test: /[\\/]jassub[\\/]/,
|
||||
parser: { url: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
+26
-18
@@ -71,29 +71,37 @@ const eventMapper: EventMapperConfig = {
|
||||
}
|
||||
cb(undefined);
|
||||
}),
|
||||
...createEventMapper("audioTrackChange", selectAudioTrack, (cb, value, prev) => {
|
||||
if (!value) return;
|
||||
const index = value.audioTrackList.findIndex((t) => t.enabled);
|
||||
if (index === -1) return;
|
||||
const track = value.audioTrackList[index]!;
|
||||
const nextValue = track.id ?? String(index);
|
||||
...createEventMapper(
|
||||
"audioTrackChange",
|
||||
selectAudioTrack,
|
||||
(cb, value, prev) => {
|
||||
if (!value) return;
|
||||
const index = value.audioTrackList.findIndex((t) => t.enabled);
|
||||
if (index === -1) return;
|
||||
const track = value.audioTrackList[index]!;
|
||||
const nextValue = track.id ?? String(index);
|
||||
|
||||
const prevIndex = prev?.audioTrackList.findIndex((t) => t.enabled) ?? -1;
|
||||
const prevTrack = prevIndex >= 0 ? prev!.audioTrackList[prevIndex] : undefined;
|
||||
const prevValue = prevTrack ? (prevTrack.id ?? String(prevIndex)) : undefined;
|
||||
const prevIndex = prev?.audioTrackList.findIndex((t) => t.enabled) ?? -1;
|
||||
const prevTrack =
|
||||
prevIndex >= 0 ? prev!.audioTrackList[prevIndex] : undefined;
|
||||
const prevValue = prevTrack
|
||||
? (prevTrack.id ?? String(prevIndex))
|
||||
: undefined;
|
||||
|
||||
if (nextValue === prevValue) return;
|
||||
cb({
|
||||
id: nextValue,
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
selected: true,
|
||||
});
|
||||
}),
|
||||
if (nextValue === prevValue) return;
|
||||
cb({
|
||||
id: nextValue,
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
selected: true,
|
||||
});
|
||||
},
|
||||
),
|
||||
...createEventMapper("renditionChange", selectQuality, (cb, value, prev) => {
|
||||
const active = value?.activeVideoRendition;
|
||||
if (!active) return;
|
||||
if (renditionKey(active) === renditionKey(prev?.activeVideoRendition)) return;
|
||||
if (renditionKey(active) === renditionKey(prev?.activeVideoRendition))
|
||||
return;
|
||||
|
||||
const index = value.videoRenditionList.findIndex(
|
||||
(r) => renditionKey(r) === renditionKey(active),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useEvent, usePlayerState } from "./events";
|
||||
export { OmniProvider, usePlayer } from "./provider";
|
||||
export type { SubtitleAssets } from "./types/subtitles";
|
||||
export { OmniView } from "./view";
|
||||
|
||||
+77
-12
@@ -2,13 +2,19 @@ import type { MediaVideoRendition } from "@videojs/core";
|
||||
import type { VideoPlayerStore } from "@videojs/core/dom";
|
||||
import { selectAudioTrack, selectQuality } from "@videojs/react";
|
||||
import { stateMapper } from "./events.web";
|
||||
import { isCustomSubtitle } from "./subtitles.web";
|
||||
import type {
|
||||
OmniPlayer,
|
||||
PlayerStatus,
|
||||
Rendition,
|
||||
Track,
|
||||
} from "./types/player";
|
||||
import type { Source } from "./types/source";
|
||||
import type { Source, Subtitle } from "./types/source";
|
||||
|
||||
// Track id prefixes distinguish native `<track>` entries from external
|
||||
// ASS/PGS subtitles that are rendered by a JS overlay.
|
||||
const NATIVE_PREFIX = "n:";
|
||||
const CUSTOM_PREFIX = "x:";
|
||||
|
||||
/**
|
||||
* The quality feature reports the actively-playing rendition (`active`)
|
||||
@@ -37,12 +43,25 @@ export class WebOmniPlayer implements OmniPlayer {
|
||||
_source: Source | null = null;
|
||||
private _showNotification = false;
|
||||
|
||||
// Selected ASS/PGS subtitle (rendered 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 _customSubtitle: Subtitle | null = null;
|
||||
private _customSubtitleListeners = new Set<() => void>();
|
||||
|
||||
get source(): Source | null {
|
||||
return this._source;
|
||||
}
|
||||
|
||||
set source(source: Source | null) {
|
||||
this._source = source;
|
||||
// The previous overlay subtitle no longer belongs to the new source.
|
||||
if (
|
||||
this._customSubtitle &&
|
||||
!source?.subtitles.some((s) => s.id === this._customSubtitle?.id)
|
||||
) {
|
||||
this.setCustomSubtitle(null);
|
||||
}
|
||||
this.updateMediaSession();
|
||||
}
|
||||
|
||||
@@ -172,26 +191,72 @@ export class WebOmniPlayer implements OmniPlayer {
|
||||
}
|
||||
|
||||
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");
|
||||
// Native text tracks (in-manifest + external WebVTT rendered as <track>).
|
||||
const native: Track[] = [];
|
||||
this._store.textTrackList.forEach((track, i) => {
|
||||
if (track.kind !== "subtitles" && track.kind !== "captions") return;
|
||||
native.push({
|
||||
id: `${NATIVE_PREFIX}${i}`,
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
selected: track.mode === "showing",
|
||||
});
|
||||
});
|
||||
|
||||
// External ASS/PGS subtitles rendered by the overlay.
|
||||
const custom: Track[] = (this._source?.subtitles ?? [])
|
||||
.filter(isCustomSubtitle)
|
||||
.map((sub) => ({
|
||||
id: `${CUSTOM_PREFIX}${sub.id}`,
|
||||
label: sub.label,
|
||||
language: sub.language,
|
||||
selected: this._customSubtitle?.id === sub.id,
|
||||
}));
|
||||
|
||||
return [...native, ...custom];
|
||||
}
|
||||
|
||||
selectSubtitle(subtitle?: Track): void {
|
||||
const id = subtitle?.id;
|
||||
|
||||
// External ASS/PGS subtitle -> overlay renderer.
|
||||
if (id?.startsWith(CUSTOM_PREFIX)) {
|
||||
const subId = id.slice(CUSTOM_PREFIX.length);
|
||||
const sub = this._source?.subtitles.find((s) => s.id === subId);
|
||||
if (sub) {
|
||||
this.setNativeSubtitle(undefined);
|
||||
this.setCustomSubtitle(sub);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Native subtitle or off.
|
||||
this.setCustomSubtitle(null);
|
||||
this.setNativeSubtitle(id?.startsWith(NATIVE_PREFIX) ? id : undefined);
|
||||
}
|
||||
|
||||
private setNativeSubtitle(id?: string): 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";
|
||||
track.mode = id === `${NATIVE_PREFIX}${i}` ? "showing" : "hidden";
|
||||
}
|
||||
}
|
||||
|
||||
private setCustomSubtitle(sub: Subtitle | null): void {
|
||||
if (this._customSubtitle === sub) return;
|
||||
this._customSubtitle = sub;
|
||||
for (const listener of this._customSubtitleListeners) listener();
|
||||
}
|
||||
|
||||
// External store used by the view to render the ASS/PGS overlay.
|
||||
subscribeCustomSubtitle = (callback: () => void): (() => void) => {
|
||||
this._customSubtitleListeners.add(callback);
|
||||
return () => this._customSubtitleListeners.delete(callback);
|
||||
};
|
||||
|
||||
getCustomSubtitle = (): Subtitle | null => this._customSubtitle;
|
||||
|
||||
get rendition(): Rendition[] {
|
||||
const quality = selectQuality(this._store.state);
|
||||
if (!quality) return [];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NitroModules } from "react-native-nitro-modules";
|
||||
import type { OmniPlayerFactory } from "./specs/omni-player.nitro";
|
||||
import type { OmniPlayer } from "./types/player";
|
||||
import type { Source } from "./types/source";
|
||||
import type { SubtitleAssets } from "./types/subtitles";
|
||||
import { useLazyRef } from "./utils/lazy-ref";
|
||||
|
||||
const ProviderFactory =
|
||||
@@ -18,6 +19,8 @@ export const OmniProvider = ({
|
||||
source: Source;
|
||||
children: ReactNode;
|
||||
showNotification?: boolean;
|
||||
/** Web-only: URLs for the ASS/PGS subtitle renderer assets. Ignored here. */
|
||||
subtitleAssets?: SubtitleAssets;
|
||||
}) => {
|
||||
const player = useLazyRef(() => ProviderFactory.createPlayer(source));
|
||||
|
||||
|
||||
+11
-3
@@ -10,27 +10,34 @@ import {
|
||||
import { WebOmniPlayer } from "./player.web";
|
||||
import type { OmniPlayer } from "./types/player";
|
||||
import type { Source } from "./types/source";
|
||||
import type { SubtitleAssets } from "./types/subtitles";
|
||||
import { useLazyRef } from "./utils/lazy-ref";
|
||||
|
||||
export const VideoPlayer = createPlayer({ features: videoFeatures });
|
||||
|
||||
const PlayerCtx = createContext<OmniPlayer>(null!);
|
||||
const SourceCtx = createContext<Source | null>(null);
|
||||
const SubtitleAssetsCtx = createContext<SubtitleAssets | undefined>(undefined);
|
||||
|
||||
export const OmniProvider = ({
|
||||
children,
|
||||
source,
|
||||
showNotification = false,
|
||||
subtitleAssets,
|
||||
}: {
|
||||
source: Source;
|
||||
children: ReactNode;
|
||||
showNotification?: boolean;
|
||||
/** Override URLs for the web ASS/PGS subtitle renderer assets. */
|
||||
subtitleAssets?: SubtitleAssets;
|
||||
}) => {
|
||||
return (
|
||||
<VideoPlayer.Provider>
|
||||
<PlayerInitializer source={source} showNotification={showNotification}>
|
||||
{children}
|
||||
</PlayerInitializer>
|
||||
<SubtitleAssetsCtx.Provider value={subtitleAssets}>
|
||||
<PlayerInitializer source={source} showNotification={showNotification}>
|
||||
{children}
|
||||
</PlayerInitializer>
|
||||
</SubtitleAssetsCtx.Provider>
|
||||
</VideoPlayer.Provider>
|
||||
);
|
||||
};
|
||||
@@ -94,3 +101,4 @@ const PlayerInitializer = ({
|
||||
|
||||
export const usePlayer = () => useContext(PlayerCtx);
|
||||
export const useSource = () => useContext(SourceCtx);
|
||||
export const useSubtitleAssets = () => useContext(SubtitleAssetsCtx);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Subtitle } from "./types/source";
|
||||
import type { SubtitleAssets } from "./types/subtitles";
|
||||
|
||||
/**
|
||||
* ASS/SSA and PGS subtitles cannot be rendered by a native `<track>` element,
|
||||
* so they are drawn on a canvas overlay by jassub (ASS) and libpgs (PGS).
|
||||
* Everything else (WebVTT, in-manifest tracks) is handled natively.
|
||||
*
|
||||
* The worker/wasm/font assets must be hosted by the consuming app; these
|
||||
* defaults match the layout jassub / libpgs ship in their packages and can be
|
||||
* overridden through the `subtitleAssets` prop on `OmniProvider`.
|
||||
*/
|
||||
const DEFAULT_ASSETS = {
|
||||
jassub: {
|
||||
workerUrl: "/jassub/jassub-worker.js",
|
||||
wasmUrl: "/jassub/jassub-worker.wasm",
|
||||
modernWasmUrl: "/jassub/jassub-worker-modern.wasm",
|
||||
fontUrl: "/jassub/default.woff2",
|
||||
},
|
||||
pgs: {
|
||||
workerUrl: "/pgs/libpgs.worker.js",
|
||||
},
|
||||
};
|
||||
|
||||
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";
|
||||
};
|
||||
|
||||
/** Whether a subtitle needs the JS overlay renderer rather than `<track>`. */
|
||||
export const isCustomSubtitle = (subtitle: {
|
||||
mimeType?: string;
|
||||
link: string;
|
||||
}): boolean => {
|
||||
const format = getSubtitleFormat(subtitle);
|
||||
return format === "ass" || format === "pgs";
|
||||
};
|
||||
|
||||
export interface SubtitleRenderer {
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the overlay renderer for an ASS or PGS subtitle attached to `video`.
|
||||
* Returns `null` for formats that do not need an overlay. The heavy renderer
|
||||
* libraries are imported lazily so they only load when actually needed.
|
||||
*/
|
||||
export const createSubtitleRenderer = (
|
||||
video: HTMLVideoElement,
|
||||
subtitle: Subtitle,
|
||||
assets?: SubtitleAssets,
|
||||
): Promise<SubtitleRenderer | null> => {
|
||||
const format = getSubtitleFormat(subtitle);
|
||||
|
||||
if (format === "ass") {
|
||||
const jassubAssets = { ...DEFAULT_ASSETS.jassub, ...assets?.jassub };
|
||||
return import("jassub").then(({ default: JASSUB }) => {
|
||||
const jassub = new JASSUB({
|
||||
video,
|
||||
subUrl: subtitle.link,
|
||||
workerUrl: jassubAssets.workerUrl,
|
||||
wasmUrl: jassubAssets.wasmUrl,
|
||||
modernWasmUrl: jassubAssets.modernWasmUrl,
|
||||
availableFonts: { "liberation sans": jassubAssets.fontUrl },
|
||||
defaultFont: "liberation sans",
|
||||
});
|
||||
return { destroy: () => jassub.destroy() };
|
||||
});
|
||||
}
|
||||
|
||||
if (format === "pgs") {
|
||||
const pgsAssets = { ...DEFAULT_ASSETS.pgs, ...assets?.pgs };
|
||||
return import("libpgs").then(({ PgsRenderer }) => {
|
||||
const pgs = new PgsRenderer({
|
||||
video,
|
||||
subUrl: subtitle.link,
|
||||
workerUrl: pgsAssets.workerUrl,
|
||||
});
|
||||
return { destroy: () => pgs.dispose() };
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(null);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* URLs to the web subtitle renderer assets (jassub for ASS/SSA, libpgs for
|
||||
* PGS). The consuming app must host these; each field falls back to a default
|
||||
* under `/jassub` and `/pgs` when omitted. Web-only — ignored on native.
|
||||
*/
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -2,5 +2,5 @@ import { useState } from "react";
|
||||
|
||||
export const useLazyRef = <T>(init: () => T): T => {
|
||||
const [ret] = useState<T>(init);
|
||||
return ret
|
||||
return ret;
|
||||
};
|
||||
|
||||
+2
-2
@@ -1,3 +1,5 @@
|
||||
import { memo } from "react";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import {
|
||||
getHostComponent,
|
||||
type HybridViewMethods,
|
||||
@@ -7,8 +9,6 @@ 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),
|
||||
|
||||
+71
-14
@@ -1,8 +1,26 @@
|
||||
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 { useSource, VideoPlayer } from "./provider.web";
|
||||
import {
|
||||
type CSSProperties,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import type { WebOmniPlayer } from "./player.web";
|
||||
import {
|
||||
usePlayer,
|
||||
useSource,
|
||||
useSubtitleAssets,
|
||||
VideoPlayer,
|
||||
} from "./provider.web";
|
||||
import {
|
||||
createSubtitleRenderer,
|
||||
isCustomSubtitle,
|
||||
type SubtitleRenderer,
|
||||
} from "./subtitles.web";
|
||||
import type { Subtitle, VideoSrc } from "./types/source";
|
||||
import type { OmniViewProps } from "./types/view";
|
||||
|
||||
@@ -13,26 +31,61 @@ const isHls = (src: VideoSrc): boolean => {
|
||||
return /\.m3u8($|\?)/i.test(src.uri);
|
||||
};
|
||||
|
||||
const SubtitleTracks = ({ subtitles }: { subtitles: Subtitle[] }) => (
|
||||
// Only WebVTT / plain text tracks can be rendered by a native <track>; ASS and
|
||||
// PGS are drawn by the overlay renderer instead.
|
||||
const NativeSubtitleTracks = ({ subtitles }: { subtitles: Subtitle[] }) => (
|
||||
<>
|
||||
{subtitles.map((subtitle) => (
|
||||
<track
|
||||
key={subtitle.id}
|
||||
kind="subtitles"
|
||||
src={subtitle.link}
|
||||
srcLang={subtitle.language}
|
||||
label={subtitle.label ?? subtitle.language ?? subtitle.id}
|
||||
/>
|
||||
))}
|
||||
{subtitles
|
||||
.filter((subtitle) => !isCustomSubtitle(subtitle))
|
||||
.map((subtitle) => (
|
||||
<track
|
||||
key={subtitle.id}
|
||||
kind="subtitles"
|
||||
src={subtitle.link}
|
||||
srcLang={subtitle.language}
|
||||
label={subtitle.label ?? subtitle.language ?? subtitle.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
// Draws the selected ASS/PGS subtitle on a canvas over the video element.
|
||||
const SubtitleOverlay = ({ video }: { video: HTMLVideoElement | null }) => {
|
||||
const player = usePlayer() as WebOmniPlayer;
|
||||
const assets = useSubtitleAssets();
|
||||
const subtitle = useSyncExternalStore(
|
||||
player.subscribeCustomSubtitle,
|
||||
player.getCustomSubtitle,
|
||||
() => null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!video || !subtitle) return;
|
||||
let renderer: SubtitleRenderer | null = null;
|
||||
let cancelled = false;
|
||||
createSubtitleRenderer(video, subtitle, assets).then((created) => {
|
||||
if (cancelled) {
|
||||
created?.destroy();
|
||||
return;
|
||||
}
|
||||
renderer = created;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
renderer?.destroy();
|
||||
};
|
||||
}, [video, subtitle, assets]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const OmniView = ({
|
||||
style,
|
||||
autoplay,
|
||||
}: OmniViewProps & { style: CSSProperties }) => {
|
||||
const source = useSource();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [video, setVideo] = useState<HTMLVideoElement | null>(null);
|
||||
|
||||
const src = source?.src[0];
|
||||
const hls = src ? isHls(src) : false;
|
||||
@@ -59,12 +112,14 @@ export const OmniView = ({
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
};
|
||||
const subtitles = source?.subtitles ?? [];
|
||||
|
||||
return (
|
||||
<VideoPlayer.Container ref={containerRef} style={style}>
|
||||
{src &&
|
||||
(hls ? (
|
||||
<HlsJsVideo
|
||||
ref={setVideo}
|
||||
src={src.uri}
|
||||
config={config}
|
||||
autoPlay={autoplay}
|
||||
@@ -72,19 +127,21 @@ export const OmniView = ({
|
||||
crossOrigin="anonymous"
|
||||
style={mediaStyle}
|
||||
>
|
||||
<SubtitleTracks subtitles={source?.subtitles ?? []} />
|
||||
<NativeSubtitleTracks subtitles={subtitles} />
|
||||
</HlsJsVideo>
|
||||
) : (
|
||||
<Video
|
||||
ref={setVideo}
|
||||
src={src.uri}
|
||||
autoPlay={autoplay}
|
||||
playsInline
|
||||
crossOrigin="anonymous"
|
||||
style={mediaStyle}
|
||||
>
|
||||
<SubtitleTracks subtitles={source?.subtitles ?? []} />
|
||||
<NativeSubtitleTracks subtitles={subtitles} />
|
||||
</Video>
|
||||
))}
|
||||
<SubtitleOverlay video={video} />
|
||||
</VideoPlayer.Container>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user