126 lines
3.3 KiB
TypeScript
126 lines
3.3 KiB
TypeScript
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";
|
|
import { SubtitleStyling, useSubtitleStore } from "@/stores/subtitles";
|
|
|
|
export function CaptionCue({
|
|
text,
|
|
styling,
|
|
overrideCasing,
|
|
}: {
|
|
text?: string;
|
|
styling: SubtitleStyling;
|
|
overrideCasing: boolean;
|
|
}) {
|
|
const wordOverrides: Record<string, string> = {
|
|
i: "I",
|
|
};
|
|
|
|
let textToUse = text;
|
|
if (overrideCasing && text) {
|
|
textToUse = text.slice(0, 1) + text.slice(1).toLowerCase();
|
|
}
|
|
|
|
const textWithNewlines = (textToUse || "")
|
|
.split(" ")
|
|
.map((word) => wordOverrides[word] ?? word)
|
|
.join(" ")
|
|
.replaceAll(/ i'/g, " I'")
|
|
.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)]"
|
|
style={{
|
|
color: styling.color,
|
|
fontSize: `${(1.5 * styling.size).toFixed(2)}rem`,
|
|
backgroundColor: `rgba(0,0,0,${styling.backgroundOpacity.toFixed(2)})`,
|
|
}}
|
|
>
|
|
<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 language = usePlayerStore((s) => s.caption.selected?.language);
|
|
const styling = useSubtitleStore((s) => s.styling);
|
|
const overrideCasing = useSubtitleStore((s) => s.overrideCasing);
|
|
const delay = useSubtitleStore((s) => s.delay);
|
|
|
|
const parsedCaptions = useMemo(
|
|
() => (srtData ? parseSubtitles(srtData, language) : []),
|
|
[srtData, language]
|
|
);
|
|
|
|
const visibileCaptions = useMemo(
|
|
() =>
|
|
parsedCaptions.filter(({ start, end }) =>
|
|
captionIsVisible(start, end, delay, videoTime)
|
|
),
|
|
[parsedCaptions, videoTime, delay]
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
{visibileCaptions.map(({ start, end, content }, i) => (
|
|
<CaptionCue
|
|
key={makeQueId(i, start, end)}
|
|
text={content}
|
|
styling={styling}
|
|
overrideCasing={overrideCasing}
|
|
/>
|
|
))}
|
|
</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>
|
|
);
|
|
}
|