diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index ab8c8102..f553de9d 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -1,17 +1,15 @@ -import { ReactNode } from "react"; +import Fuse from "fuse.js"; +import { ReactNode, useState } from "react"; import { useAsync, useAsyncFn } from "react-use"; -import { - downloadSrt, - languageIdToName, - searchSubtitles, -} from "@/backend/helpers/subs"; +import { languageIdToName } from "@/backend/helpers/subs"; import { FlagIcon } from "@/components/FlagIcon"; +import { useCaptions } from "@/components/player/hooks/useCaptions"; import { Menu } from "@/components/player/internals/ContextMenu"; +import { Input } from "@/components/player/internals/ContextMenu/Input"; import { SelectableLink } from "@/components/player/internals/ContextMenu/Links"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { usePlayerStore } from "@/stores/player/store"; -import { useSubtitleStore } from "@/stores/subtitles"; export function CaptionOption(props: { countryCode?: string; @@ -48,40 +46,22 @@ export function CaptionOption(props: { } // TODO cache like everything in this view -// TODO make quick settings for caption language // TODO fix language names, some are unknown -// TODO add search bar for languages // TODO sort languages by common usage 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); - const meta = usePlayerStore((s) => s.meta); + const { search, download, disable } = useCaptions(); - const req = useAsync(async () => { - if (!meta) throw new Error("No meta"); - return searchSubtitles(meta); - }, [meta]); + const [searchQuery, setSearchQuery] = useState(""); + + const req = useAsync(async () => search(), [search]); const [downloadReq, startDownload] = useAsyncFn( - async (subtitleId: string, language: string) => { - const srtData = await downloadSrt(subtitleId); - setCaption({ - language, - srtData, - url: "", // TODO remove url - }); - setLanguage(language); - }, - [setCaption, setLanguage] + (subtitleId: string, language: string) => download(subtitleId, language), + [download] ); - function disableCaption() { - setCaption(null); - setLanguage(null); - } - let downloadProgress: ReactNode = null; if (downloadReq.loading) downloadProgress =

downloading...

; else if (downloadReq.error) downloadProgress =

failed to download...

; @@ -89,19 +69,43 @@ export function CaptionsView({ id }: { id: string }) { let content: ReactNode = null; if (req.loading) content =

loading...

; else if (req.error) content =

errored!

; - else if (req.value) - content = req.value.map((v) => ( - - startDownload(v.attributes.legacy_subtitle_id, v.attributes.language) - } - > - {languageIdToName(v.attributes.language) ?? "unknown"} - - )); + else if (req.value) { + const subs = req.value.map((v) => { + const languageName = languageIdToName(v.attributes.language) ?? "unknown"; + return { + ...v, + languageName, + }; + }); + + let results = subs; + if (searchQuery.trim().length > 0) { + const fuse = new Fuse(subs, { + includeScore: true, + keys: ["languageName"], + }); + + results = fuse.search(searchQuery).map((res) => res.item); + } + + content = results.map((v) => { + return ( + + startDownload( + v.attributes.legacy_subtitle_id, + v.attributes.language + ) + } + > + {v.languageName} + + ); + }); + } return ( <> @@ -118,9 +122,10 @@ export function CaptionsView({ id }: { id: string }) { > Captions - + + {downloadProgress} - disableCaption()} selected={!lang}> + disable()} selected={!lang}> Off {content} diff --git a/src/components/player/atoms/settings/SettingsMenu.tsx b/src/components/player/atoms/settings/SettingsMenu.tsx index 57313fcf..1ff5f9c2 100644 --- a/src/components/player/atoms/settings/SettingsMenu.tsx +++ b/src/components/player/atoms/settings/SettingsMenu.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { languageIdToName } from "@/backend/helpers/subs"; import { Toggle } from "@/components/buttons/Toggle"; import { Icon, Icons } from "@/components/Icon"; +import { useCaptions } from "@/components/player/hooks/useCaptions"; import { Menu } from "@/components/player/internals/ContextMenu"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { usePlayerStore } from "@/stores/player/store"; @@ -13,27 +14,16 @@ 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]); - - // TODO actually scrape subtitles to load - function toggleSubtitles() { - if (!subtitlesEnabled) setSubtitleLanguage(lastSelectedLanguage ?? "en"); - else { - setSubtitleLanguage(null); - setCaption(null); - } - } + const { toggleLastUsed } = useCaptions(); const selectedLanguagePretty = selectedCaptionLanguage ? languageIdToName(selectedCaptionLanguage) ?? "unknown" @@ -77,7 +67,7 @@ export function SettingsMenu({ id }: { id: string }) { rightSide={ toggleSubtitles()} + onClick={() => toggleLastUsed().catch(() => {})} /> } > diff --git a/src/components/player/hooks/useCaptions.ts b/src/components/player/hooks/useCaptions.ts new file mode 100644 index 00000000..77e773d3 --- /dev/null +++ b/src/components/player/hooks/useCaptions.ts @@ -0,0 +1,63 @@ +import { useCallback } from "react"; + +import { downloadSrt, searchSubtitles } from "@/backend/helpers/subs"; +import { usePlayerStore } from "@/stores/player/store"; +import { useSubtitleStore } from "@/stores/subtitles"; + +export function useCaptions() { + const setLanguage = useSubtitleStore((s) => s.setLanguage); + const enabled = useSubtitleStore((s) => s.enabled); + const setCaption = usePlayerStore((s) => s.setCaption); + const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage); + const meta = usePlayerStore((s) => s.meta); + + const download = useCallback( + async (subtitleId: string, language: string) => { + const srtData = await downloadSrt(subtitleId); + setCaption({ + language, + srtData, + url: "", // TODO remove url + }); + setLanguage(language); + }, + [setCaption, setLanguage] + ); + + const search = useCallback(async () => { + if (!meta) throw new Error("No meta"); + return searchSubtitles(meta); + }, [meta]); + + const disable = useCallback(async () => { + setCaption(null); + setLanguage(null); + }, [setCaption, setLanguage]); + + const downloadLastUsed = useCallback(async () => { + const language = lastSelectedLanguage ?? "en"; + const searchResult = await search(); + const languageResult = searchResult.find( + (v) => v.attributes.language === language + ); + if (!languageResult) return false; + await download( + languageResult.attributes.legacy_subtitle_id, + languageResult.attributes.language + ); + return true; + }, [lastSelectedLanguage, search, download]); + + const toggleLastUsed = useCallback(async () => { + if (!enabled) await downloadLastUsed(); + else disable(); + }, [downloadLastUsed, disable, enabled]); + + return { + download, + search, + disable, + downloadLastUsed, + toggleLastUsed, + }; +} diff --git a/src/components/player/internals/ContextMenu/Input.tsx b/src/components/player/internals/ContextMenu/Input.tsx new file mode 100644 index 00000000..db404778 --- /dev/null +++ b/src/components/player/internals/ContextMenu/Input.tsx @@ -0,0 +1,21 @@ +import { Icon, Icons } from "@/components/Icon"; + +export function Input(props: { + value: string; + onInput: (str: string) => void; +}) { + return ( +
+ + props.onInput(e.currentTarget.value)} + /> +
+ ); +} diff --git a/src/components/player/internals/KeyboardEvents.tsx b/src/components/player/internals/KeyboardEvents.tsx index ed29ce60..4b3e002d 100644 --- a/src/components/player/internals/KeyboardEvents.tsx +++ b/src/components/player/internals/KeyboardEvents.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; +import { useCaptions } from "@/components/player/hooks/useCaptions"; import { useVolume } from "@/components/player/hooks/useVolume"; import { usePlayerStore } from "@/stores/player/store"; import { useEmpheralVolumeStore } from "@/stores/volume"; @@ -10,6 +11,7 @@ export function KeyboardEvents() { const time = usePlayerStore((s) => s.progress.time); const { setVolume, toggleMute } = useVolume(); + const { toggleLastUsed } = useCaptions(); const setShowVolume = useEmpheralVolumeStore((s) => s.setShowVolume); const [isRolling, setIsRolling] = useState(false); @@ -20,6 +22,7 @@ export function KeyboardEvents() { setVolume, toggleMute, setIsRolling, + toggleLastUsed, display, mediaPlaying, isRolling, @@ -31,6 +34,7 @@ export function KeyboardEvents() { setVolume, toggleMute, setIsRolling, + toggleLastUsed, display, mediaPlaying, isRolling, @@ -41,6 +45,7 @@ export function KeyboardEvents() { setVolume, toggleMute, setIsRolling, + toggleLastUsed, display, mediaPlaying, isRolling, @@ -49,6 +54,9 @@ export function KeyboardEvents() { useEffect(() => { const keyEventHandler = (evt: KeyboardEvent) => { + if (evt.target && (evt.target as HTMLInputElement).nodeName === "INPUT") + return; + const k = evt.key; // Volume @@ -83,6 +91,9 @@ export function KeyboardEvents() { dataRef.current.mediaPlaying.isPaused ? "play" : "pause" ](); + // captions + if (k === "c") dataRef.current.toggleLastUsed().catch(() => {}); // ignore errors + // Do a barrell roll! if (k === "r") { if (dataRef.current.isRolling || evt.ctrlKey || evt.metaKey) return; diff --git a/tailwind.config.js b/tailwind.config.js index 24c3e35c..2542482b 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -26,23 +26,23 @@ module.exports = { "ash-400": "#3D394D", "ash-300": "#2C293A", "ash-200": "#2B2836", - "ash-100": "#1E1C26", + "ash-100": "#1E1C26" }, /* fonts */ fontFamily: { - "open-sans": "'Open Sans'", + "open-sans": "'Open Sans'" }, /* animations */ keyframes: { "loading-pin": { "0%, 40%, 100%": { height: "0.5em", "background-color": "#282336" }, - "20%": { height: "1em", "background-color": "white" }, - }, + "20%": { height: "1em", "background-color": "white" } + } }, - animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }, - }, + animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" } + } }, plugins: [ require("tailwind-scrollbar"), @@ -52,31 +52,31 @@ module.exports = { colors: { // Branding pill: { - background: "#1C1C36", + background: "#1C1C36" }, // meta data for the theme itself global: { accentA: "#505DBD", - accentB: "#3440A1", + accentB: "#3440A1" }, // light bar lightBar: { - light: "#2A2A71", + light: "#2A2A71" }, // Buttons buttons: { toggle: "#8D44D6", - toggleDisabled: "#202836", + toggleDisabled: "#202836" }, // only used for body colors/textures background: { main: "#0A0A10", accentA: "#6E3B80", - accentB: "#1F1F50", + accentB: "#1F1F50" }, // typography @@ -85,7 +85,7 @@ module.exports = { text: "#73739D", dimmed: "#926CAD", divider: "#262632", - secondary: "#64647B", + secondary: "#64647B" }, // search bar @@ -94,7 +94,7 @@ module.exports = { focused: "#24243C", placeholder: "#4A4A71", icon: "#545476", - text: "#FFFFFF", + text: "#FFFFFF" }, // media cards @@ -106,7 +106,7 @@ module.exports = { barColor: "#4B4B63", barFillColor: "#BA7FD6", badge: "#151522", - badgeText: "#5F5F7A", + badgeText: "#5F5F7A" }, // video player @@ -118,17 +118,17 @@ module.exports = { error: "#E44F4F", success: "#40B44B", loading: "#B759D8", - noresult: "#64647B", + noresult: "#64647B" }, progress: { background: "#8787A8", preloaded: "#8787A8", - watched: "#A75FC9", + watched: "#A75FC9" }, audio: { - set: "#A75FC9", + set: "#A75FC9" }, buttons: { @@ -137,7 +137,7 @@ module.exports = { secondaryHover: "#1B262E", primary: "#fff", primaryText: "#000", - primaryHover: "#dedede", + primaryHover: "#dedede" }, context: { @@ -148,30 +148,31 @@ module.exports = { buttonFocus: "#202836", flagBg: "#202836", inputBg: "#202836", + inputPlaceholder: "#374A56", cardBorder: "#1B262E", slider: "#8787A8", sliderFilled: "#A75FC9", download: { button: "#6b298a", - hover: "#7f35a1", + hover: "#7f35a1" }, buttons: { list: "#161C26", - active: "#0D1317", + active: "#0D1317" }, type: { main: "#617A8A", secondary: "#374A56", - accent: "#A570FA", - }, - }, - }, - }, - }, - }, - }), - ], + accent: "#A570FA" + } + } + } + } + } + } + }) + ] };