7 changed files with 184 additions and 16 deletions
File diff suppressed because one or more lines are too long
@ -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> |
||||||
|
); |
||||||
|
} |
||||||
@ -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[]; |
||||||
|
} |
||||||
Loading…
Reference in new issue