mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-21 14:47:41 +01:00
caption rendering is back!
This commit is contained in:
parent
8796d5b942
commit
454fa1279b
7 changed files with 184 additions and 16 deletions
|
@ -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
86
src/components/player/base/SubtitleView.tsx
Normal file
86
src/components/player/base/SubtitleView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
36
src/components/player/utils/captions.ts
Normal file
36
src/components/player/utils/captions.ts
Normal 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[];
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in a new issue