mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-05 13:46:59 +00:00
184 lines
5.3 KiB
TypeScript
184 lines
5.3 KiB
TypeScript
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,
|
|
type RefObject,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useSyncExternalStore,
|
|
} from "react";
|
|
import { usePlayerState } from "./events";
|
|
import { getSubtitleFormat, 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.renditions;
|
|
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() });
|
|
})
|
|
.catch((e) => console.error("[omni] failed to render ass subtitle", e));
|
|
} 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() });
|
|
})
|
|
.catch((e) => console.error("[omni] failed to render pgs subtitle", e));
|
|
}
|
|
|
|
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 source = usePlayerState("source");
|
|
|
|
const isHls =
|
|
source?.src?.mimeType?.toLowerCase().includes("mpegurl") ||
|
|
source?.src?.uri.split(/[?#]/)[0]?.toLowerCase().endsWith(".m3u8") ||
|
|
false;
|
|
const Tech = isHls ? HlsJsVideo : Video;
|
|
|
|
const headersRef = useRef(source?.src?.headers);
|
|
headersRef.current = source?.src?.headers;
|
|
const castId = source?.castId;
|
|
const castData = source?.castData;
|
|
|
|
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);
|
|
}
|
|
},
|
|
},
|
|
googleCast: {
|
|
receiver: player.castOptions?.receiverApplicationId,
|
|
customData: castData ?? null,
|
|
...(castId && { src: castId }),
|
|
},
|
|
}),
|
|
[player.castOptions, castData, castId],
|
|
);
|
|
|
|
// While casting, the receiver renders subtitles (the player forwards the
|
|
// selection); skip rendering them locally on the idle <video>.
|
|
const castStatus = usePlayerState("castStatus");
|
|
|
|
return (
|
|
<VideoPlayer.Container
|
|
ref={containerRef}
|
|
style={{ position: "relative", ...style }}
|
|
>
|
|
<Tech
|
|
ref={ref}
|
|
src={source?.src.uri}
|
|
config={config}
|
|
autoPlay={autoplay}
|
|
playsInline
|
|
crossOrigin="anonymous"
|
|
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
|
>
|
|
{(source?.subtitles ?? []).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>
|
|
{castStatus !== "connected" && castStatus !== "connecting" && (
|
|
<SubtitleOverlay
|
|
video={ref}
|
|
assets={subtitleAssets}
|
|
fonts={source?.fonts}
|
|
/>
|
|
)}
|
|
</VideoPlayer.Container>
|
|
);
|
|
};
|