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) => ( + + ))} )} + ); };