From 1491a117b4c54f0f7b64402600288a2260de858c Mon Sep 17 00:00:00 2001 From: mrjvs Date: Thu, 19 Oct 2023 16:05:05 +0200 Subject: [PATCH] subtitle customization Co-authored-by: Jip Frijlink --- src/components/player/atoms/Settings.tsx | 2 +- .../atoms/settings/CaptionSettingsView.tsx | 160 +++++++++++++++++- .../player/atoms/settings/CaptionsView.tsx | 18 +- .../player/atoms/settings/SettingsMenu.tsx | 30 +++- src/components/player/base/SubtitleView.tsx | 25 ++- src/stores/subtitles/index.ts | 62 +++++++ tailwind.config.js | 3 + 7 files changed, 281 insertions(+), 19 deletions(-) create mode 100644 src/stores/subtitles/index.ts diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index c2e7a6ee..6237f341 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -49,7 +49,7 @@ function SettingsOverlay({ id }: { id: string }) { - + diff --git a/src/components/player/atoms/settings/CaptionSettingsView.tsx b/src/components/player/atoms/settings/CaptionSettingsView.tsx index 012d85fc..9a620492 100644 --- a/src/components/player/atoms/settings/CaptionSettingsView.tsx +++ b/src/components/player/atoms/settings/CaptionSettingsView.tsx @@ -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(null); + const ref = useRef(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 ( +
+ {props.label} +
+
+
+
+ {/* Actual progress bar */} +
+
+
+
+
+
+
+ {isFocused ? ( + { + (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) + } + /> + ) : ( + + )} +
+
+
+ ); +} + +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 ( <> router.navigate("/captions")}> Custom captions - + + `${s}%`} + onChange={(v) => updateStyling({ size: v / 100 })} + value={styling.size * 100} + /> + updateStyling({ backgroundOpacity: v / 100 })} + value={styling.backgroundOpacity * 100} + textTransformer={(s) => `${s}%`} + />
Color
- {}} color="#FFFFFF" active /> - {}} color="#80B1FA" /> - {}} color="#E2E535" /> + {colors.map((v) => ( + updateStyling({ color: v })} + color={v} + active={styling.color === v} + /> + ))}
diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 13f73bcf..ec0a0f97 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -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 - disableCaption()}>Off + disableCaption()} selected={!lang}> + Off + {langs.map((v) => ( updateCaption()} + onClick={() => updateCaption(v.lang)} > {v.title} diff --git a/src/components/player/atoms/settings/SettingsMenu.tsx b/src/components/player/atoms/settings/SettingsMenu.tsx index 1e3178e3..f9d88e24 100644 --- a/src/components/player/atoms/settings/SettingsMenu.tsx +++ b/src/components/player/atoms/settings/SettingsMenu.tsx @@ -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 }) { Enable Captions - toggleBool()} /> + toggleSubtitles()} + /> router.navigate("/captions")}> Caption settings - English + + {selectedCaptionLanguage ?? ""} + Playback settings diff --git a/src/components/player/base/SubtitleView.tsx b/src/components/player/base/SubtitleView.tsx index 858f17aa..81463326 100644 --- a/src/components/player/base/SubtitleView.tsx +++ b/src/components/player/base/SubtitleView.tsx @@ -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, "
"); // https://www.w3.org/TR/webvtt1/#dom-construction-rules @@ -22,7 +29,14 @@ export function CaptionCue({ text }: { text?: string }) { }); return ( -

+

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 (

{visibileCaptions.map(({ start, end, content }, i) => ( - + ))}
); diff --git a/src/stores/subtitles/index.ts b/src/stores/subtitles/index.ts new file mode 100644 index 00000000..40787e42 --- /dev/null +++ b/src/stores/subtitles/index.ts @@ -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): void; + setLanguage(language: string | null): void; +} + +// TODO add migration from previous stored settings +export const useSubtitleStore = create( + persist( + immer((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", + } + ) +); diff --git a/tailwind.config.js b/tailwind.config.js index d68a6aa6..b5a0e701 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -137,7 +137,10 @@ module.exports = { border: "#141D23", buttonFocus: "#202836", flagBg: "#202836", + inputBg: "#202836", cardBorder: "#1B262E", + slider: "#8787A8", + sliderFilled: "#A75FC9", type: { main: "#617A8A",