mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-27 06:31:50 +01:00
caption rendering is back!
This commit is contained in:
parent
8796d5b942
commit
454fa1279b
@ -7,4 +7,5 @@ export * from "./base/BlackOverlay";
|
||||
export * from "./base/BackLink";
|
||||
export * from "./base/LeftSideControls";
|
||||
export * from "./base/CenterMobileControls";
|
||||
export * from "./base/SubtitleView";
|
||||
export * from "./internals/BookmarkButton";
|
||||
|
File diff suppressed because one or more lines are too long
86
src/components/player/base/SubtitleView.tsx
Normal file
86
src/components/player/base/SubtitleView.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import classNames from "classnames";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
captionIsVisible,
|
||||
makeQueId,
|
||||
parseSubtitles,
|
||||
sanitize,
|
||||
} from "@/components/player/utils/captions";
|
||||
import { Transition } from "@/components/Transition";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
|
||||
export function CaptionCue({ text }: { text?: string }) {
|
||||
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
|
||||
|
||||
// https://www.w3.org/TR/webvtt1/#dom-construction-rules
|
||||
// added a <br /> for newlines
|
||||
const html = sanitize(textWithNewlines, {
|
||||
ALLOWED_TAGS: ["c", "b", "i", "u", "span", "ruby", "rt", "br"],
|
||||
ADD_TAGS: ["v", "lang"],
|
||||
ALLOWED_ATTR: ["title", "lang"],
|
||||
});
|
||||
|
||||
return (
|
||||
<p className="pointer-events-none mb-1 select-none rounded px-4 py-1 text-center [text-shadow:0_2px_4px_rgba(0,0,0,0.5)]">
|
||||
<span
|
||||
// its sanitised a few lines up
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: html,
|
||||
}}
|
||||
dir="auto"
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubtitleRenderer() {
|
||||
const videoTime = usePlayerStore((s) => s.progress.time);
|
||||
const srtData = usePlayerStore((s) => s.caption.selected?.srtData);
|
||||
|
||||
const parsedCaptions = useMemo(
|
||||
() => (srtData ? parseSubtitles(srtData) : []),
|
||||
[srtData]
|
||||
);
|
||||
|
||||
const visibileCaptions = useMemo(
|
||||
() =>
|
||||
parsedCaptions.filter(({ start, end }) =>
|
||||
captionIsVisible(start, end, 0, videoTime)
|
||||
),
|
||||
[parsedCaptions, videoTime]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{visibileCaptions.map(({ start, end, content }, i) => (
|
||||
<CaptionCue key={makeQueId(i, start, end)} text={content} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubtitleView(props: { controlsShown: boolean }) {
|
||||
const caption = usePlayerStore((s) => s.caption.selected);
|
||||
const captionAsTrack = usePlayerStore((s) => s.caption.asTrack);
|
||||
|
||||
if (captionAsTrack || !caption) return null;
|
||||
|
||||
return (
|
||||
<Transition
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
animation="slide-up"
|
||||
show
|
||||
>
|
||||
<div
|
||||
className={classNames([
|
||||
"text-white absolute flex w-full flex-col items-center transition-[bottom]",
|
||||
props.controlsShown ? "bottom-24" : "bottom-12",
|
||||
])}
|
||||
>
|
||||
<SubtitleRenderer />
|
||||
</div>
|
||||
</Transition>
|
||||
);
|
||||
}
|
36
src/components/player/utils/captions.ts
Normal file
36
src/components/player/utils/captions.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { convert, detect, parse } from "subsrt-ts";
|
||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||
|
||||
export type CaptionCueType = ContentCaption;
|
||||
export const sanitize = DOMPurify.sanitize;
|
||||
|
||||
export function captionIsVisible(
|
||||
start: number,
|
||||
end: number,
|
||||
delay: number,
|
||||
currentTime: number
|
||||
) {
|
||||
const delayedStart = start / 1000 + delay;
|
||||
const delayedEnd = end / 1000 + delay;
|
||||
return (
|
||||
Math.max(0, delayedStart) <= currentTime &&
|
||||
Math.max(0, delayedEnd) >= currentTime
|
||||
);
|
||||
}
|
||||
|
||||
export function makeQueId(index: number, start: number, end: number): string {
|
||||
return `${index}-${start}-${end}`;
|
||||
}
|
||||
|
||||
export function parseSubtitles(text: string): CaptionCueType[] {
|
||||
const textTrimmed = text.trim();
|
||||
if (textTrimmed === "") {
|
||||
throw new Error("Given text is empty");
|
||||
}
|
||||
const vtt = convert(textTrimmed, "vtt");
|
||||
if (detect(vtt) === "") {
|
||||
throw new Error("Invalid subtitle format");
|
||||
}
|
||||
return parse(vtt).filter((cue) => cue.type === "caption") as CaptionCueType[];
|
||||
}
|
@ -9,16 +9,6 @@ export default function DeveloperPage() {
|
||||
<Navigation />
|
||||
<ThinContainer classNames="flex flex-col space-y-4">
|
||||
<Title className="mb-8">Developer tools</Title>
|
||||
<ArrowLink
|
||||
to="/dev/providers"
|
||||
direction="right"
|
||||
linkText="Provider tester"
|
||||
/>
|
||||
<ArrowLink
|
||||
to="/dev/embeds"
|
||||
direction="right"
|
||||
linkText="Embed scraper tester"
|
||||
/>
|
||||
<ArrowLink to="/dev/video" direction="right" linkText="Video tester" />
|
||||
<ArrowLink to="/dev/test" direction="right" linkText="Test page" />
|
||||
</ThinContainer>
|
||||
|
@ -21,6 +21,7 @@ export function PlayerPart(props: PlayerPartProps) {
|
||||
<Player.Container onLoad={props.onLoad}>
|
||||
{props.children}
|
||||
<Player.BlackOverlay show={showTargets} />
|
||||
<Player.SubtitleView controlsShown={showTargets} />
|
||||
|
||||
{status === "playing" ? (
|
||||
<Player.CenterControls>
|
||||
|
@ -35,16 +35,27 @@ export interface PlayerMeta {
|
||||
};
|
||||
}
|
||||
|
||||
export interface Caption {
|
||||
language: string;
|
||||
url?: string;
|
||||
srtData: string;
|
||||
}
|
||||
|
||||
export interface SourceSlice {
|
||||
status: PlayerStatus;
|
||||
source: SourceSliceSource | null;
|
||||
qualities: SourceQuality[];
|
||||
currentQuality: SourceQuality | null;
|
||||
caption: {
|
||||
selected: Caption | null;
|
||||
asTrack: boolean;
|
||||
};
|
||||
meta: PlayerMeta | null;
|
||||
setStatus(status: PlayerStatus): void;
|
||||
setSource(stream: SourceSliceSource, startAt: number): void;
|
||||
switchQuality(quality: SourceQuality): void;
|
||||
setMeta(meta: PlayerMeta): void;
|
||||
setCaption(caption: Caption | null): void;
|
||||
}
|
||||
|
||||
export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia {
|
||||
@ -76,6 +87,10 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
||||
currentQuality: null,
|
||||
status: playerStatus.IDLE,
|
||||
meta: null,
|
||||
caption: {
|
||||
selected: null,
|
||||
asTrack: false,
|
||||
},
|
||||
setStatus(status: PlayerStatus) {
|
||||
set((s) => {
|
||||
s.status = status;
|
||||
@ -86,6 +101,11 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
||||
s.meta = meta;
|
||||
});
|
||||
},
|
||||
setCaption(caption) {
|
||||
set((s) => {
|
||||
s.caption.selected = caption;
|
||||
});
|
||||
},
|
||||
setSource(stream: SourceSliceSource, startAt: number) {
|
||||
let qualities: string[] = [];
|
||||
if (stream.type === "file") qualities = Object.keys(stream.qualities);
|
||||
|
Loading…
Reference in New Issue
Block a user