mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-16 02:44:51 +00:00
Add jassub/libpgs support on the web
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
export { useEvent, usePlayerState } from "./events";
|
||||
export { OmniProvider, usePlayer } from "./provider";
|
||||
export type { SubtitleAssets } from "./types/subtitles";
|
||||
export { OmniView } from "./view";
|
||||
|
||||
+88
-17
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ import { useState } from "react";
|
||||
|
||||
export const useLazyRef = <T>(init: () => T): T => {
|
||||
const [ret] = useState<T>(init);
|
||||
return ret
|
||||
return ret;
|
||||
};
|
||||
|
||||
+12
-6
@@ -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<Props, HybridViewMethods>("OmniView", () => OmniConfig),
|
||||
);
|
||||
|
||||
export const OmniView = memo((props: OmniViewProps & { style: ViewStyle }) => {
|
||||
const player = usePlayer() as OmniPlayer;
|
||||
return <NativeView player={player} {...props} />;
|
||||
});
|
||||
export const OmniView = memo(
|
||||
({
|
||||
// Web-only; not forwarded to the native view.
|
||||
subtitleAssets: _subtitleAssets,
|
||||
...props
|
||||
}: OmniViewProps & { style: ViewStyle }) => {
|
||||
const player = usePlayer() as OmniPlayer;
|
||||
return <NativeView player={player} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
+107
-25
@@ -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) => (
|
||||
<track
|
||||
key={subtitle.id}
|
||||
kind="subtitles"
|
||||
src={subtitle.link}
|
||||
srcLang={subtitle.language}
|
||||
label={subtitle.label ?? subtitle.language ?? subtitle.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
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;
|
||||
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<HTMLDivElement>(null);
|
||||
const ref = useRef<HTMLVideoElement>(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 (
|
||||
<VideoPlayer.Container ref={containerRef} style={style}>
|
||||
<VideoPlayer.Container
|
||||
ref={containerRef}
|
||||
style={{ position: "relative", ...style }}
|
||||
>
|
||||
{src && (
|
||||
<Tech
|
||||
ref={ref}
|
||||
src={src.uri}
|
||||
config={config}
|
||||
autoPlay={autoplay}
|
||||
playsInline
|
||||
crossOrigin="anonymous"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
>
|
||||
<SubtitleTracks subtitles={player.source?.subtitles ?? []} />
|
||||
{(player.source?.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}
|
||||
/>
|
||||
))}
|
||||
</Tech>
|
||||
)}
|
||||
<SubtitleOverlay
|
||||
video={ref}
|
||||
assets={subtitleAssets}
|
||||
fonts={player.source?.fonts}
|
||||
/>
|
||||
</VideoPlayer.Container>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user