mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-11 00:05:09 +01:00
subtitle customization
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
parent
f6bbec8907
commit
1491a117b4
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
62
src/stores/subtitles/index.ts
Normal file
62
src/stores/subtitles/index.ts
Normal 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",
|
||||
}
|
||||
)
|
||||
);
|
@ -137,7 +137,10 @@ module.exports = {
|
||||
border: "#141D23",
|
||||
buttonFocus: "#202836",
|
||||
flagBg: "#202836",
|
||||
inputBg: "#202836",
|
||||
cardBorder: "#1B262E",
|
||||
slider: "#8787A8",
|
||||
sliderFilled: "#A75FC9",
|
||||
|
||||
type: {
|
||||
main: "#617A8A",
|
||||
|
Loading…
Reference in New Issue
Block a user