20 changed files with 605 additions and 5551 deletions
@ -0,0 +1,27 @@ |
|||||||
|
declare module "node-webvtt" { |
||||||
|
interface Cue { |
||||||
|
identifier: string; |
||||||
|
start: number; |
||||||
|
end: number; |
||||||
|
text: string; |
||||||
|
styles: string; |
||||||
|
} |
||||||
|
interface Options { |
||||||
|
meta?: boolean; |
||||||
|
strict?: boolean; |
||||||
|
} |
||||||
|
type ParserError = Error; |
||||||
|
interface ParseResult { |
||||||
|
valid: boolean; |
||||||
|
strict: boolean; |
||||||
|
cues: Cue[]; |
||||||
|
errors: ParserError[]; |
||||||
|
meta?: Map<string, string>; |
||||||
|
} |
||||||
|
interface Segment { |
||||||
|
duration: number; |
||||||
|
cues: Cue[]; |
||||||
|
} |
||||||
|
function parse(text: string, options: Options): ParseResult; |
||||||
|
function segment(input: string, segmentLength?: number): Segment[]; |
||||||
|
} |
@ -0,0 +1,78 @@ |
|||||||
|
import { useStore } from "@/utils/storage"; |
||||||
|
import { createContext, ReactNode, useContext, useMemo } from "react"; |
||||||
|
import { SettingsStore } from "./store"; |
||||||
|
import { MWSettingsData } from "./types"; |
||||||
|
|
||||||
|
interface MWSettingsDataSetters { |
||||||
|
setLanguage(language: string): void; |
||||||
|
setCaptionDelay(delay: number): void; |
||||||
|
setCaptionColor(color: string): void; |
||||||
|
setCaptionFontSize(size: number): void; |
||||||
|
setCaptionBackgroundColor(backgroundColor: string): void; |
||||||
|
} |
||||||
|
type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters; |
||||||
|
const SettingsContext = createContext<MWSettingsDataWrapper>(null as any); |
||||||
|
export function SettingsProvider(props: { children: ReactNode }) { |
||||||
|
function enforceRange(min: number, value: number, max: number) { |
||||||
|
return Math.max(min, Math.min(value, max)); |
||||||
|
} |
||||||
|
const [settings, setSettings] = useStore(SettingsStore); |
||||||
|
|
||||||
|
const context: MWSettingsDataWrapper = useMemo(() => { |
||||||
|
const settingsContext: MWSettingsDataWrapper = { |
||||||
|
...settings, |
||||||
|
setLanguage(language) { |
||||||
|
setSettings((oldSettings) => { |
||||||
|
return { |
||||||
|
...oldSettings, |
||||||
|
language, |
||||||
|
}; |
||||||
|
}); |
||||||
|
}, |
||||||
|
setCaptionDelay(delay: number) { |
||||||
|
setSettings((oldSettings) => { |
||||||
|
const captionSettings = oldSettings.captionSettings; |
||||||
|
captionSettings.delay = enforceRange(-10, delay, 10); |
||||||
|
const newSettings = oldSettings; |
||||||
|
return newSettings; |
||||||
|
}); |
||||||
|
}, |
||||||
|
setCaptionColor(color) { |
||||||
|
setSettings((oldSettings) => { |
||||||
|
const style = oldSettings.captionSettings.style; |
||||||
|
style.color = color; |
||||||
|
const newSettings = oldSettings; |
||||||
|
return newSettings; |
||||||
|
}); |
||||||
|
}, |
||||||
|
setCaptionFontSize(size) { |
||||||
|
setSettings((oldSettings) => { |
||||||
|
const style = oldSettings.captionSettings.style; |
||||||
|
style.fontSize = enforceRange(10, size, 60); |
||||||
|
const newSettings = oldSettings; |
||||||
|
return newSettings; |
||||||
|
}); |
||||||
|
}, |
||||||
|
setCaptionBackgroundColor(backgroundColor) { |
||||||
|
setSettings((oldSettings) => { |
||||||
|
const style = oldSettings.captionSettings.style; |
||||||
|
style.backgroundColor = backgroundColor; |
||||||
|
const newSettings = oldSettings; |
||||||
|
return newSettings; |
||||||
|
}); |
||||||
|
}, |
||||||
|
}; |
||||||
|
return settingsContext; |
||||||
|
}, [settings, setSettings]); |
||||||
|
return ( |
||||||
|
<SettingsContext.Provider value={context}> |
||||||
|
{props.children} |
||||||
|
</SettingsContext.Provider> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function useSettings() { |
||||||
|
return useContext(SettingsContext); |
||||||
|
} |
||||||
|
|
||||||
|
export default SettingsContext; |
@ -0,0 +1,22 @@ |
|||||||
|
import { createVersionedStore } from "@/utils/storage"; |
||||||
|
import { MWSettingsData } from "./types"; |
||||||
|
|
||||||
|
export const SettingsStore = createVersionedStore<MWSettingsData>() |
||||||
|
.setKey("mw-settings") |
||||||
|
.addVersion({ |
||||||
|
version: 0, |
||||||
|
create(): MWSettingsData { |
||||||
|
return { |
||||||
|
language: "en", |
||||||
|
captionSettings: { |
||||||
|
delay: 0, |
||||||
|
style: { |
||||||
|
color: "#ffffff", |
||||||
|
fontSize: 25, |
||||||
|
backgroundColor: "#00000096", |
||||||
|
}, |
||||||
|
}, |
||||||
|
}; |
||||||
|
}, |
||||||
|
}) |
||||||
|
.build(); |
@ -0,0 +1,21 @@ |
|||||||
|
export interface CaptionStyleSettings { |
||||||
|
color: string; |
||||||
|
/** |
||||||
|
* Range is [10, 30] |
||||||
|
*/ |
||||||
|
fontSize: number; |
||||||
|
backgroundColor: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface CaptionSettings { |
||||||
|
/** |
||||||
|
* Range is [-10, 10]s |
||||||
|
*/ |
||||||
|
delay: number; |
||||||
|
style: CaptionStyleSettings; |
||||||
|
} |
||||||
|
|
||||||
|
export interface MWSettingsData { |
||||||
|
language: string; |
||||||
|
captionSettings: CaptionSettings; |
||||||
|
} |
@ -0,0 +1,92 @@ |
|||||||
|
import { Transition } from "@/components/Transition"; |
||||||
|
import { useSettings } from "@/state/settings"; |
||||||
|
import { sanitize } from "@/backend/helpers/captions"; |
||||||
|
import { parse, Cue } from "node-webvtt"; |
||||||
|
import { useRef } from "react"; |
||||||
|
import { useAsync } from "react-use"; |
||||||
|
import { useVideoPlayerDescriptor } from "../../state/hooks"; |
||||||
|
import { useProgress } from "../../state/logic/progress"; |
||||||
|
import { useSource } from "../../state/logic/source"; |
||||||
|
|
||||||
|
function CaptionCue({ text }: { text?: string }) { |
||||||
|
const { captionSettings } = useSettings(); |
||||||
|
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)]" |
||||||
|
style={{ |
||||||
|
...captionSettings.style, |
||||||
|
}} |
||||||
|
> |
||||||
|
<span |
||||||
|
// its sanitised a few lines up
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{ |
||||||
|
__html: html, |
||||||
|
}} |
||||||
|
dir="auto" |
||||||
|
/> |
||||||
|
</p> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function CaptionRendererAction({ |
||||||
|
isControlsShown, |
||||||
|
}: { |
||||||
|
isControlsShown: boolean; |
||||||
|
}) { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const source = useSource(descriptor).source; |
||||||
|
const videoTime = useProgress(descriptor).time; |
||||||
|
const { captionSettings } = useSettings(); |
||||||
|
const captions = useRef<Cue[]>([]); |
||||||
|
|
||||||
|
useAsync(async () => { |
||||||
|
const url = source?.caption?.url; |
||||||
|
if (url) { |
||||||
|
// Is there a better way?
|
||||||
|
const result = await fetch(url); |
||||||
|
// Uses UTF-8 by default
|
||||||
|
const text = await result.text(); |
||||||
|
captions.current = parse(text, { strict: false }).cues; |
||||||
|
} else { |
||||||
|
captions.current = []; |
||||||
|
} |
||||||
|
}, [source?.caption?.url]); |
||||||
|
|
||||||
|
if (!captions.current.length) return null; |
||||||
|
const isVisible = (start: number, end: number): boolean => { |
||||||
|
const delayedStart = start + captionSettings.delay; |
||||||
|
const delayedEnd = end + captionSettings.delay; |
||||||
|
return ( |
||||||
|
Math.max(0, delayedStart) <= videoTime && |
||||||
|
Math.max(0, delayedEnd) >= videoTime |
||||||
|
); |
||||||
|
}; |
||||||
|
return ( |
||||||
|
<Transition |
||||||
|
className={[ |
||||||
|
"pointer-events-none absolute flex w-full flex-col items-center transition-[bottom]", |
||||||
|
isControlsShown ? "bottom-24" : "bottom-12", |
||||||
|
].join(" ")} |
||||||
|
animation="slide-up" |
||||||
|
show |
||||||
|
> |
||||||
|
{captions.current.map( |
||||||
|
({ identifier, end, start, text }) => |
||||||
|
isVisible(start, end) && ( |
||||||
|
<CaptionCue key={identifier || `${start}-${end}`} text={text} /> |
||||||
|
) |
||||||
|
)} |
||||||
|
</Transition> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,150 @@ |
|||||||
|
import { FloatingCardView } from "@/components/popout/FloatingCard"; |
||||||
|
import { FloatingView } from "@/components/popout/FloatingView"; |
||||||
|
import { useFloatingRouter } from "@/hooks/useFloatingRouter"; |
||||||
|
import { useSettings } from "@/state/settings"; |
||||||
|
import { useTranslation } from "react-i18next"; |
||||||
|
import { ChangeEventHandler, useEffect, useRef } from "react"; |
||||||
|
import { Icon, Icons } from "@/components/Icon"; |
||||||
|
|
||||||
|
export type SliderProps = { |
||||||
|
label: string; |
||||||
|
min: number; |
||||||
|
max: number; |
||||||
|
step: number; |
||||||
|
value: number; |
||||||
|
valueDisplay?: string; |
||||||
|
onChange: ChangeEventHandler<HTMLInputElement>; |
||||||
|
}; |
||||||
|
|
||||||
|
export function Slider(props: SliderProps) { |
||||||
|
const ref = useRef<HTMLInputElement>(null); |
||||||
|
useEffect(() => { |
||||||
|
const e = ref.current as HTMLInputElement; |
||||||
|
e.style.setProperty("--value", e.value); |
||||||
|
e.style.setProperty("--min", e.min === "" ? "0" : e.min); |
||||||
|
e.style.setProperty("--max", e.max === "" ? "100" : e.max); |
||||||
|
e.addEventListener("input", () => e.style.setProperty("--value", e.value)); |
||||||
|
}, [ref]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="mb-6 flex flex-row gap-4"> |
||||||
|
<div className="flex w-full flex-col gap-2"> |
||||||
|
<label className="font-bold">{props.label}</label> |
||||||
|
<input |
||||||
|
type="range" |
||||||
|
ref={ref} |
||||||
|
className="styled-slider slider-progress" |
||||||
|
onChange={props.onChange} |
||||||
|
value={props.value} |
||||||
|
max={props.max} |
||||||
|
min={props.min} |
||||||
|
step={props.step} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div className="mt-1 aspect-[2/1] h-8 rounded-sm bg-[#1C161B] pt-1"> |
||||||
|
<div className="text-center font-bold text-white"> |
||||||
|
{props.valueDisplay ?? props.value} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function CaptionSettingsPopout(props: { |
||||||
|
router: ReturnType<typeof useFloatingRouter>; |
||||||
|
prefix: string; |
||||||
|
}) { |
||||||
|
// For now, won't add label texts to language files since options are prone to change
|
||||||
|
const { t } = useTranslation(); |
||||||
|
const { |
||||||
|
captionSettings, |
||||||
|
setCaptionBackgroundColor, |
||||||
|
setCaptionColor, |
||||||
|
setCaptionDelay, |
||||||
|
setCaptionFontSize, |
||||||
|
} = useSettings(); |
||||||
|
const colors = ["#ffffff", "#00ffff", "#ffff00"]; |
||||||
|
return ( |
||||||
|
<FloatingView {...props.router.pageProps(props.prefix)} width={375}> |
||||||
|
<FloatingCardView.Header |
||||||
|
title={t("videoPlayer.popouts.captionPreferences.title")} |
||||||
|
description={t("videoPlayer.popouts.descriptions.captionPreferences")} |
||||||
|
goBack={() => props.router.navigate("/captions")} |
||||||
|
/> |
||||||
|
<FloatingCardView.Content> |
||||||
|
<Slider |
||||||
|
label={t("videoPlayer.popouts.captionPreferences.delay")} |
||||||
|
max={10} |
||||||
|
min={-10} |
||||||
|
step={0.1} |
||||||
|
valueDisplay={`${captionSettings.delay.toFixed(1)}s`} |
||||||
|
value={captionSettings.delay} |
||||||
|
onChange={(e) => setCaptionDelay(e.target.valueAsNumber)} |
||||||
|
/> |
||||||
|
<Slider |
||||||
|
label="Size" |
||||||
|
min={14} |
||||||
|
step={1} |
||||||
|
max={60} |
||||||
|
value={captionSettings.style.fontSize} |
||||||
|
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)} |
||||||
|
/> |
||||||
|
<Slider |
||||||
|
label={t("videoPlayer.popouts.captionPreferences.opacity")} |
||||||
|
step={1} |
||||||
|
min={0} |
||||||
|
max={255} |
||||||
|
valueDisplay={`${( |
||||||
|
(parseInt( |
||||||
|
captionSettings.style.backgroundColor.substring(7, 9), |
||||||
|
16 |
||||||
|
) / |
||||||
|
255) * |
||||||
|
100 |
||||||
|
).toFixed(0)}%`}
|
||||||
|
value={parseInt( |
||||||
|
captionSettings.style.backgroundColor.substring(7, 9), |
||||||
|
16 |
||||||
|
)} |
||||||
|
onChange={(e) => |
||||||
|
setCaptionBackgroundColor( |
||||||
|
`${captionSettings.style.backgroundColor.substring( |
||||||
|
0, |
||||||
|
7 |
||||||
|
)}${e.target.valueAsNumber.toString(16)}` |
||||||
|
) |
||||||
|
} |
||||||
|
/> |
||||||
|
<div className="flex flex-row justify-between"> |
||||||
|
<label className="font-bold" htmlFor="color"> |
||||||
|
{t("videoPlayer.popouts.captionPreferences.color")} |
||||||
|
</label> |
||||||
|
<div className="flex flex-row gap-2"> |
||||||
|
{colors.map((color) => ( |
||||||
|
<div |
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded transition-[background-color,transform] duration-100 hover:bg-[#1c161b79] active:scale-110 ${ |
||||||
|
color === captionSettings.style.color ? "bg-[#1C161B]" : "" |
||||||
|
}`}
|
||||||
|
onClick={() => setCaptionColor(color)} |
||||||
|
> |
||||||
|
<div |
||||||
|
className="h-4 w-4 cursor-pointer appearance-none rounded-full" |
||||||
|
style={{ |
||||||
|
backgroundColor: color, |
||||||
|
}} |
||||||
|
/> |
||||||
|
<Icon |
||||||
|
className={[ |
||||||
|
"absolute text-xs text-[#1C161B]", |
||||||
|
color === captionSettings.style.color ? "" : "hidden", |
||||||
|
].join(" ")} |
||||||
|
icon={Icons.CHECKMARK} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</FloatingCardView.Content> |
||||||
|
</FloatingView> |
||||||
|
); |
||||||
|
} |
Loading…
Reference in new issue