subtitle customization

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-10-19 16:05:05 +02:00
parent f6bbec8907
commit 1491a117b4
7 changed files with 281 additions and 19 deletions

View File

@ -49,7 +49,7 @@ function SettingsOverlay({ id }: { id: string }) {
<CaptionsView id={id} />
</Context.Card>
</OverlayPage>
<OverlayPage id={id} path="/captions/settings" width={343} height={431}>
<OverlayPage id={id} path="/captions/settings" width={343} height={310}>
<Context.Card>
<CaptionSettingsView id={id} />
</Context.Card>

View File

@ -1,8 +1,11 @@
import classNames from "classnames";
import { useCallback, useEffect, useRef, useState } from "react";
import { Icon, Icons } from "@/components/Icon";
import { Context } from "@/components/player/internals/ContextUtils";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { useProgressBar } from "@/hooks/useProgressBar";
import { useSubtitleStore } from "@/stores/subtitles";
export function ColorOption(props: {
color: string;
@ -29,21 +32,170 @@ export function ColorOption(props: {
);
}
function CaptionSetting(props: {
textTransformer?: (s: string) => string;
value: number;
onChange?: (val: number) => void;
max: number;
label: string;
min: number;
}) {
const inputRef = useRef<HTMLInputElement | null>(null);
const ref = useRef<HTMLDivElement>(null);
// 200 - 100 150 - 100
const currentPercentage = (props.value - props.min) / (props.max - props.min);
const commit = useCallback(
(percentage) => {
const range = props.max - props.min;
const newPercentage = Math.min(Math.max(percentage, 0), 1);
props.onChange?.(props.min + range * newPercentage);
},
[props]
);
const { dragging, dragPercentage, dragMouseDown } = useProgressBar(
ref,
commit,
true
);
const [isFocused, setIsFocused] = useState(false);
const [inputValue, setInputValue] = useState("");
useEffect(() => {
function listener(e: KeyboardEvent) {
if (e.key === "Enter" && isFocused) {
inputRef.current?.blur();
}
}
window.addEventListener("keydown", listener);
return () => {
window.removeEventListener("keydown", listener);
};
}, [isFocused]);
const inputClasses =
"px-3 py-1 bg-video-context-inputBg rounded w-20 text-left text-white cursor-text";
const textTransformer = props.textTransformer ?? ((s) => s);
return (
<div>
<Context.FieldTitle>{props.label}</Context.FieldTitle>
<div className="grid items-center grid-cols-[1fr,auto] gap-4">
<div ref={ref}>
<div
className="group/progress w-full h-8 flex items-center cursor-pointer"
onMouseDown={dragMouseDown}
onTouchStart={dragMouseDown}
>
<div
className={[
"relative w-full h-1 bg-video-context-slider bg-opacity-25 rounded-full transition-[height] duration-100 group-hover/progress:h-1.5",
dragging ? "!h-1.5" : "",
].join(" ")}
>
{/* Actual progress bar */}
<div
className="absolute top-0 left-0 h-full rounded-full bg-video-context-sliderFilled flex justify-end items-center"
style={{
width: `${
Math.max(
0,
Math.min(
1,
dragging ? dragPercentage / 100 : currentPercentage
)
) * 100
}%`,
}}
>
<div
className={[
"w-[1rem] min-w-[1rem] h-[1rem] border-[4px] border-video-context-sliderFilled rounded-full transform translate-x-1/2 bg-white transition-[transform] duration-100",
].join(" ")}
/>
</div>
</div>
</div>
</div>
<div>
{isFocused ? (
<input
className={inputClasses}
value={inputValue}
autoFocus
onFocus={(e) => {
(e.target as HTMLInputElement).select();
}}
onBlur={(e) => {
setIsFocused(false);
const num = Number((e.target as HTMLInputElement).value);
if (!Number.isNaN(num)) props.onChange?.(Math.round(num));
}}
ref={inputRef}
onChange={(e) =>
setInputValue((e.target as HTMLInputElement).value)
}
/>
) : (
<button
className={inputClasses}
onClick={() => {
setInputValue(Math.floor(props.value).toString());
setIsFocused(true);
}}
type="button"
tabIndex={0}
>
{textTransformer(Math.floor(props.value).toString())}
</button>
)}
</div>
</div>
</div>
);
}
const colors = ["#ffffff", "#80b1fa", "#e2e535"];
export function CaptionSettingsView({ id }: { id: string }) {
const router = useOverlayRouter(id);
const styling = useSubtitleStore((s) => s.styling);
const updateStyling = useSubtitleStore((s) => s.updateStyling);
return (
<>
<Context.BackLink onClick={() => router.navigate("/captions")}>
Custom captions
</Context.BackLink>
<Context.Section>
<Context.Section className="space-y-6">
<CaptionSetting
label="Text size"
max={200}
min={10}
textTransformer={(s) => `${s}%`}
onChange={(v) => updateStyling({ size: v / 100 })}
value={styling.size * 100}
/>
<CaptionSetting
label="Background opacity"
max={100}
min={0}
onChange={(v) => updateStyling({ backgroundOpacity: v / 100 })}
value={styling.backgroundOpacity * 100}
textTransformer={(s) => `${s}%`}
/>
<div className="flex justify-between items-center">
<Context.FieldTitle>Color</Context.FieldTitle>
<div className="flex justify-center items-center">
<ColorOption onClick={() => {}} color="#FFFFFF" active />
<ColorOption onClick={() => {}} color="#80B1FA" />
<ColorOption onClick={() => {}} color="#E2E535" />
{colors.map((v) => (
<ColorOption
onClick={() => updateStyling({ color: v })}
color={v}
active={styling.color === v}
/>
))}
</div>
</div>
</Context.Section>

View File

@ -6,6 +6,7 @@ import { Context } from "@/components/player/internals/ContextUtils";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { Caption } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { useSubtitleStore } from "@/stores/subtitles";
const source: Caption = {
language: "nl",
@ -51,13 +52,20 @@ export function CaptionsView({ id }: { id: string }) {
const router = useOverlayRouter(id);
const setCaption = usePlayerStore((s) => s.setCaption);
const lang = usePlayerStore((s) => s.caption.selected?.language);
const setLanguage = useSubtitleStore((s) => s.setLanguage);
function updateCaption() {
setCaption(source);
function updateCaption(language: string) {
setCaption({
language,
srtData: source.srtData,
url: source.url,
});
setLanguage(language);
}
function disableCaption() {
setCaption(null);
setLanguage(null);
}
const langs = [
@ -81,13 +89,15 @@ export function CaptionsView({ id }: { id: string }) {
Captions
</Context.BackLink>
<Context.Section>
<CaptionOption onClick={() => disableCaption()}>Off</CaptionOption>
<CaptionOption onClick={() => disableCaption()} selected={!lang}>
Off
</CaptionOption>
{langs.map((v) => (
<CaptionOption
key={v.lang}
countryCode={v.lang}
selected={lang === v.lang}
onClick={() => updateCaption()}
onClick={() => updateCaption(v.lang)}
>
{v.title}
</CaptionOption>

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { Toggle } from "@/components/buttons/Toggle";
import { Icons } from "@/components/Icon";
@ -6,21 +6,32 @@ import { Context } from "@/components/player/internals/ContextUtils";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
import { qualityToString } from "@/stores/player/utils/qualities";
import { useSubtitleStore } from "@/stores/subtitles";
import { providers } from "@/utils/providers";
export function SettingsMenu({ id }: { id: string }) {
const router = useOverlayRouter(id);
const currentQuality = usePlayerStore((s) => s.currentQuality);
const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage);
const selectedCaptionLanguage = usePlayerStore(
(s) => s.caption.selected?.language
);
const subtitlesEnabled = useSubtitleStore((s) => s.enabled);
const setSubtitleLanguage = useSubtitleStore((s) => s.setLanguage);
const currentSourceId = usePlayerStore((s) => s.sourceId);
const setCaption = usePlayerStore((s) => s.setCaption);
const sourceName = useMemo(() => {
if (!currentSourceId) return "...";
return providers.getMetadata(currentSourceId)?.name ?? "...";
}, [currentSourceId]);
const [tmpBool, setTmpBool] = useState(false);
function toggleBool() {
setTmpBool(!tmpBool);
// TODO actually scrape subtitles to load
function toggleSubtitles() {
if (!subtitlesEnabled) setSubtitleLanguage(lastSelectedLanguage ?? "en");
else {
setSubtitleLanguage(null);
setCaption(null);
}
}
return (
@ -47,11 +58,16 @@ export function SettingsMenu({ id }: { id: string }) {
<Context.Section>
<Context.Link>
<Context.LinkTitle>Enable Captions</Context.LinkTitle>
<Toggle enabled={tmpBool} onClick={() => toggleBool()} />
<Toggle
enabled={subtitlesEnabled}
onClick={() => toggleSubtitles()}
/>
</Context.Link>
<Context.Link onClick={() => router.navigate("/captions")}>
<Context.LinkTitle>Caption settings</Context.LinkTitle>
<Context.LinkChevron>English</Context.LinkChevron>
<Context.LinkChevron>
{selectedCaptionLanguage ?? ""}
</Context.LinkChevron>
</Context.Link>
<Context.Link>
<Context.LinkTitle>Playback settings</Context.LinkTitle>

View File

@ -9,8 +9,15 @@ import {
} from "@/components/player/utils/captions";
import { Transition } from "@/components/Transition";
import { usePlayerStore } from "@/stores/player/store";
import { SubtitleStyling, useSubtitleStore } from "@/stores/subtitles";
export function CaptionCue({ text }: { text?: string }) {
export function CaptionCue({
text,
styling,
}: {
text?: string;
styling: SubtitleStyling;
}) {
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
// https://www.w3.org/TR/webvtt1/#dom-construction-rules
@ -22,7 +29,14 @@ export function CaptionCue({ text }: { text?: string }) {
});
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)]">
<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={{
color: styling.color,
fontSize: `${(1.5 * styling.size).toFixed(2)}rem`,
backgroundColor: `rgba(0,0,0,${styling.backgroundOpacity.toFixed(2)})`,
}}
>
<span
// its sanitised a few lines up
// eslint-disable-next-line react/no-danger
@ -38,6 +52,7 @@ export function CaptionCue({ text }: { text?: string }) {
export function SubtitleRenderer() {
const videoTime = usePlayerStore((s) => s.progress.time);
const srtData = usePlayerStore((s) => s.caption.selected?.srtData);
const styling = useSubtitleStore((s) => s.styling);
const parsedCaptions = useMemo(
() => (srtData ? parseSubtitles(srtData) : []),
@ -55,7 +70,11 @@ export function SubtitleRenderer() {
return (
<div>
{visibileCaptions.map(({ start, end, content }, i) => (
<CaptionCue key={makeQueId(i, start, end)} text={content} />
<CaptionCue
key={makeQueId(i, start, end)}
text={content}
styling={styling}
/>
))}
</div>
);

View File

@ -0,0 +1,62 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
export interface SubtitleStyling {
/**
* Text color of subtitles, hex string
*/
color: string;
/**
* size percentage, ranges between 0 and 2
*/
size: number;
/**
* background opacity, ranges between 0 and 1
*/
backgroundOpacity: number;
}
export interface SubtitleStore {
enabled: boolean;
lastSelectedLanguage: string | null;
styling: SubtitleStyling;
updateStyling(newStyling: Partial<SubtitleStyling>): void;
setLanguage(language: string | null): void;
}
// TODO add migration from previous stored settings
export const useSubtitleStore = create(
persist(
immer<SubtitleStore>((set) => ({
enabled: false,
lastSelectedLanguage: null,
styling: {
color: "#ffffff",
backgroundOpacity: 0.5,
size: 1,
},
updateStyling(newStyling) {
set((s) => {
if (newStyling.backgroundOpacity !== undefined)
s.styling.backgroundOpacity = newStyling.backgroundOpacity;
if (newStyling.color !== undefined)
s.styling.color = newStyling.color.toLowerCase();
if (newStyling.size !== undefined)
s.styling.size = Math.min(2, Math.max(0.1, newStyling.size));
});
},
setLanguage(lang) {
set((s) => {
s.enabled = !!lang;
if (lang) s.lastSelectedLanguage = lang;
});
},
})),
{
name: "__MW::subtitles",
}
)
);

View File

@ -137,7 +137,10 @@ module.exports = {
border: "#141D23",
buttonFocus: "#202836",
flagBg: "#202836",
inputBg: "#202836",
cardBorder: "#1B262E",
slider: "#8787A8",
sliderFilled: "#A75FC9",
type: {
main: "#617A8A",