7 changed files with 184 additions and 16 deletions
File diff suppressed because one or more lines are too long
@ -0,0 +1,86 @@
@@ -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 @@
@@ -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