movie-web/src/components/player/base/SubtitleView.tsx

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