caption rendering is back!

This commit is contained in:
mrjvs 2023-10-18 14:30:52 +02:00
parent 8796d5b942
commit 454fa1279b
7 changed files with 184 additions and 16 deletions

View File

@ -7,4 +7,5 @@ export * from "./base/BlackOverlay";
export * from "./base/BackLink"; export * from "./base/BackLink";
export * from "./base/LeftSideControls"; export * from "./base/LeftSideControls";
export * from "./base/CenterMobileControls"; export * from "./base/CenterMobileControls";
export * from "./base/SubtitleView";
export * from "./internals/BookmarkButton"; export * from "./internals/BookmarkButton";

File diff suppressed because one or more lines are too long

View 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>
);
}

View 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[];
}

View File

@ -9,16 +9,6 @@ export default function DeveloperPage() {
<Navigation /> <Navigation />
<ThinContainer classNames="flex flex-col space-y-4"> <ThinContainer classNames="flex flex-col space-y-4">
<Title className="mb-8">Developer tools</Title> <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/video" direction="right" linkText="Video tester" />
<ArrowLink to="/dev/test" direction="right" linkText="Test page" /> <ArrowLink to="/dev/test" direction="right" linkText="Test page" />
</ThinContainer> </ThinContainer>

View File

@ -21,6 +21,7 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.Container onLoad={props.onLoad}> <Player.Container onLoad={props.onLoad}>
{props.children} {props.children}
<Player.BlackOverlay show={showTargets} /> <Player.BlackOverlay show={showTargets} />
<Player.SubtitleView controlsShown={showTargets} />
{status === "playing" ? ( {status === "playing" ? (
<Player.CenterControls> <Player.CenterControls>

View File

@ -35,16 +35,27 @@ export interface PlayerMeta {
}; };
} }
export interface Caption {
language: string;
url?: string;
srtData: string;
}
export interface SourceSlice { export interface SourceSlice {
status: PlayerStatus; status: PlayerStatus;
source: SourceSliceSource | null; source: SourceSliceSource | null;
qualities: SourceQuality[]; qualities: SourceQuality[];
currentQuality: SourceQuality | null; currentQuality: SourceQuality | null;
caption: {
selected: Caption | null;
asTrack: boolean;
};
meta: PlayerMeta | null; meta: PlayerMeta | null;
setStatus(status: PlayerStatus): void; setStatus(status: PlayerStatus): void;
setSource(stream: SourceSliceSource, startAt: number): void; setSource(stream: SourceSliceSource, startAt: number): void;
switchQuality(quality: SourceQuality): void; switchQuality(quality: SourceQuality): void;
setMeta(meta: PlayerMeta): void; setMeta(meta: PlayerMeta): void;
setCaption(caption: Caption | null): void;
} }
export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia { export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia {
@ -76,6 +87,10 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
currentQuality: null, currentQuality: null,
status: playerStatus.IDLE, status: playerStatus.IDLE,
meta: null, meta: null,
caption: {
selected: null,
asTrack: false,
},
setStatus(status: PlayerStatus) { setStatus(status: PlayerStatus) {
set((s) => { set((s) => {
s.status = status; s.status = status;
@ -86,6 +101,11 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
s.meta = meta; s.meta = meta;
}); });
}, },
setCaption(caption) {
set((s) => {
s.caption.selected = caption;
});
},
setSource(stream: SourceSliceSource, startAt: number) { setSource(stream: SourceSliceSource, startAt: number) {
let qualities: string[] = []; let qualities: string[] = [];
if (stream.type === "file") qualities = Object.keys(stream.qualities); if (stream.type === "file") qualities = Object.keys(stream.qualities);