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/.gitignore b/example/.gitignore index de99955..ba02bc7 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -73,3 +73,4 @@ yarn-error.log !.yarn/releases !.yarn/sdks !.yarn/versions +dist diff --git a/example/webpack.config.js b/example/webpack.config.js index d6e6ccd..23de047 100644 --- a/example/webpack.config.js +++ b/example/webpack.config.js @@ -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: [ 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/events.web.tsx b/src/events.web.tsx index 20bf3d2..762646d 100644 --- a/src/events.web.tsx +++ b/src/events.web.tsx @@ -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), 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 6d5f24d..e029aef 100644 --- a/src/player.web.tsx +++ b/src/player.web.tsx @@ -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 `` 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 ). + 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 []; diff --git a/src/provider.tsx b/src/provider.tsx index d7e9561..7b5c149 100755 --- a/src/provider.tsx +++ b/src/provider.tsx @@ -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)); diff --git a/src/provider.web.tsx b/src/provider.web.tsx index 1ad4ba5..b11f7d7 100644 --- a/src/provider.web.tsx +++ b/src/provider.web.tsx @@ -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(null!); const SourceCtx = createContext(null); +const SubtitleAssetsCtx = createContext(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 ( - - {children} - + + + {children} + + ); }; @@ -94,3 +101,4 @@ const PlayerInitializer = ({ export const usePlayer = () => useContext(PlayerCtx); export const useSource = () => useContext(SourceCtx); +export const useSubtitleAssets = () => useContext(SubtitleAssetsCtx); diff --git a/src/subtitles.web.ts b/src/subtitles.web.ts new file mode 100644 index 0000000..38fe322 --- /dev/null +++ b/src/subtitles.web.ts @@ -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 `` 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 ``. */ +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 => { + 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); +}; diff --git a/src/types/subtitles.ts b/src/types/subtitles.ts new file mode 100644 index 0000000..57010d6 --- /dev/null +++ b/src/types/subtitles.ts @@ -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; + }; +} 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..d490369 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,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("OmniView", () => OmniConfig), diff --git a/src/view.web.tsx b/src/view.web.tsx index 276a751..9548802 100644 --- a/src/view.web.tsx +++ b/src/view.web.tsx @@ -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 ; ASS and +// PGS are drawn by the overlay renderer instead. +const NativeSubtitleTracks = ({ subtitles }: { subtitles: Subtitle[] }) => ( <> - {subtitles.map((subtitle) => ( - - ))} + {subtitles + .filter((subtitle) => !isCustomSubtitle(subtitle)) + .map((subtitle) => ( + + ))} ); +// 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(null); + const [video, setVideo] = useState(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 ( {src && (hls ? ( - + ) : ( ))} + ); };