20 changed files with 605 additions and 5551 deletions
@ -0,0 +1,27 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1 @@
|
||||
export * from "./context"; |
@ -0,0 +1,22 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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