pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

caption keyboard shortcut + searchbar for captions + enabled toggle for keyboard + subtitle padding

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>

mrjvs 9ce0e6a0 ca2bab30

+179 -88
+51 -46
src/components/player/atoms/settings/CaptionsView.tsx
··· 1 - import { ReactNode } from "react"; 1 + import Fuse from "fuse.js"; 2 + import { ReactNode, useState } from "react"; 2 3 import { useAsync, useAsyncFn } from "react-use"; 3 4 4 - import { 5 - downloadSrt, 6 - languageIdToName, 7 - searchSubtitles, 8 - } from "@/backend/helpers/subs"; 5 + import { languageIdToName } from "@/backend/helpers/subs"; 9 6 import { FlagIcon } from "@/components/FlagIcon"; 7 + import { useCaptions } from "@/components/player/hooks/useCaptions"; 10 8 import { Menu } from "@/components/player/internals/ContextMenu"; 9 + import { Input } from "@/components/player/internals/ContextMenu/Input"; 11 10 import { SelectableLink } from "@/components/player/internals/ContextMenu/Links"; 12 11 import { useOverlayRouter } from "@/hooks/useOverlayRouter"; 13 12 import { usePlayerStore } from "@/stores/player/store"; 14 - import { useSubtitleStore } from "@/stores/subtitles"; 15 13 16 14 export function CaptionOption(props: { 17 15 countryCode?: string; ··· 48 46 } 49 47 50 48 // TODO cache like everything in this view 51 - // TODO make quick settings for caption language 52 49 // TODO fix language names, some are unknown 53 - // TODO add search bar for languages 54 50 // TODO sort languages by common usage 55 51 export function CaptionsView({ id }: { id: string }) { 56 52 const router = useOverlayRouter(id); 57 - const setCaption = usePlayerStore((s) => s.setCaption); 58 53 const lang = usePlayerStore((s) => s.caption.selected?.language); 59 - const setLanguage = useSubtitleStore((s) => s.setLanguage); 60 - const meta = usePlayerStore((s) => s.meta); 54 + const { search, download, disable } = useCaptions(); 55 + 56 + const [searchQuery, setSearchQuery] = useState(""); 61 57 62 - const req = useAsync(async () => { 63 - if (!meta) throw new Error("No meta"); 64 - return searchSubtitles(meta); 65 - }, [meta]); 58 + const req = useAsync(async () => search(), [search]); 66 59 67 60 const [downloadReq, startDownload] = useAsyncFn( 68 - async (subtitleId: string, language: string) => { 69 - const srtData = await downloadSrt(subtitleId); 70 - setCaption({ 71 - language, 72 - srtData, 73 - url: "", // TODO remove url 74 - }); 75 - setLanguage(language); 76 - }, 77 - [setCaption, setLanguage] 61 + (subtitleId: string, language: string) => download(subtitleId, language), 62 + [download] 78 63 ); 79 64 80 - function disableCaption() { 81 - setCaption(null); 82 - setLanguage(null); 83 - } 84 - 85 65 let downloadProgress: ReactNode = null; 86 66 if (downloadReq.loading) downloadProgress = <p>downloading...</p>; 87 67 else if (downloadReq.error) downloadProgress = <p>failed to download...</p>; ··· 89 69 let content: ReactNode = null; 90 70 if (req.loading) content = <p>loading...</p>; 91 71 else if (req.error) content = <p>errored!</p>; 92 - else if (req.value) 93 - content = req.value.map((v) => ( 94 - <CaptionOption 95 - key={v.id} 96 - countryCode={v.attributes.language} 97 - selected={lang === v.attributes.language} 98 - onClick={() => 99 - startDownload(v.attributes.legacy_subtitle_id, v.attributes.language) 100 - } 101 - > 102 - {languageIdToName(v.attributes.language) ?? "unknown"} 103 - </CaptionOption> 104 - )); 72 + else if (req.value) { 73 + const subs = req.value.map((v) => { 74 + const languageName = languageIdToName(v.attributes.language) ?? "unknown"; 75 + return { 76 + ...v, 77 + languageName, 78 + }; 79 + }); 80 + 81 + let results = subs; 82 + if (searchQuery.trim().length > 0) { 83 + const fuse = new Fuse(subs, { 84 + includeScore: true, 85 + keys: ["languageName"], 86 + }); 87 + 88 + results = fuse.search(searchQuery).map((res) => res.item); 89 + } 90 + 91 + content = results.map((v) => { 92 + return ( 93 + <CaptionOption 94 + key={v.id} 95 + countryCode={v.attributes.language} 96 + selected={lang === v.attributes.language} 97 + onClick={() => 98 + startDownload( 99 + v.attributes.legacy_subtitle_id, 100 + v.attributes.language 101 + ) 102 + } 103 + > 104 + {v.languageName} 105 + </CaptionOption> 106 + ); 107 + }); 108 + } 105 109 106 110 return ( 107 111 <> ··· 118 122 > 119 123 Captions 120 124 </Menu.BackLink> 121 - <Menu.Section> 125 + <Menu.Section className="pb-6"> 126 + <Input value={searchQuery} onInput={setSearchQuery} /> 122 127 {downloadProgress} 123 - <CaptionOption onClick={() => disableCaption()} selected={!lang}> 128 + <CaptionOption onClick={() => disable()} selected={!lang}> 124 129 Off 125 130 </CaptionOption> 126 131 {content}
+3 -13
src/components/player/atoms/settings/SettingsMenu.tsx
··· 3 3 import { languageIdToName } from "@/backend/helpers/subs"; 4 4 import { Toggle } from "@/components/buttons/Toggle"; 5 5 import { Icon, Icons } from "@/components/Icon"; 6 + import { useCaptions } from "@/components/player/hooks/useCaptions"; 6 7 import { Menu } from "@/components/player/internals/ContextMenu"; 7 8 import { useOverlayRouter } from "@/hooks/useOverlayRouter"; 8 9 import { usePlayerStore } from "@/stores/player/store"; ··· 13 14 export function SettingsMenu({ id }: { id: string }) { 14 15 const router = useOverlayRouter(id); 15 16 const currentQuality = usePlayerStore((s) => s.currentQuality); 16 - const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage); 17 17 const selectedCaptionLanguage = usePlayerStore( 18 18 (s) => s.caption.selected?.language 19 19 ); 20 20 const subtitlesEnabled = useSubtitleStore((s) => s.enabled); 21 - const setSubtitleLanguage = useSubtitleStore((s) => s.setLanguage); 22 21 const currentSourceId = usePlayerStore((s) => s.sourceId); 23 - const setCaption = usePlayerStore((s) => s.setCaption); 24 22 const sourceName = useMemo(() => { 25 23 if (!currentSourceId) return "..."; 26 24 return providers.getMetadata(currentSourceId)?.name ?? "..."; 27 25 }, [currentSourceId]); 28 - 29 - // TODO actually scrape subtitles to load 30 - function toggleSubtitles() { 31 - if (!subtitlesEnabled) setSubtitleLanguage(lastSelectedLanguage ?? "en"); 32 - else { 33 - setSubtitleLanguage(null); 34 - setCaption(null); 35 - } 36 - } 26 + const { toggleLastUsed } = useCaptions(); 37 27 38 28 const selectedLanguagePretty = selectedCaptionLanguage 39 29 ? languageIdToName(selectedCaptionLanguage) ?? "unknown" ··· 77 67 rightSide={ 78 68 <Toggle 79 69 enabled={subtitlesEnabled} 80 - onClick={() => toggleSubtitles()} 70 + onClick={() => toggleLastUsed().catch(() => {})} 81 71 /> 82 72 } 83 73 >
+63
src/components/player/hooks/useCaptions.ts
··· 1 + import { useCallback } from "react"; 2 + 3 + import { downloadSrt, searchSubtitles } from "@/backend/helpers/subs"; 4 + import { usePlayerStore } from "@/stores/player/store"; 5 + import { useSubtitleStore } from "@/stores/subtitles"; 6 + 7 + export function useCaptions() { 8 + const setLanguage = useSubtitleStore((s) => s.setLanguage); 9 + const enabled = useSubtitleStore((s) => s.enabled); 10 + const setCaption = usePlayerStore((s) => s.setCaption); 11 + const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage); 12 + const meta = usePlayerStore((s) => s.meta); 13 + 14 + const download = useCallback( 15 + async (subtitleId: string, language: string) => { 16 + const srtData = await downloadSrt(subtitleId); 17 + setCaption({ 18 + language, 19 + srtData, 20 + url: "", // TODO remove url 21 + }); 22 + setLanguage(language); 23 + }, 24 + [setCaption, setLanguage] 25 + ); 26 + 27 + const search = useCallback(async () => { 28 + if (!meta) throw new Error("No meta"); 29 + return searchSubtitles(meta); 30 + }, [meta]); 31 + 32 + const disable = useCallback(async () => { 33 + setCaption(null); 34 + setLanguage(null); 35 + }, [setCaption, setLanguage]); 36 + 37 + const downloadLastUsed = useCallback(async () => { 38 + const language = lastSelectedLanguage ?? "en"; 39 + const searchResult = await search(); 40 + const languageResult = searchResult.find( 41 + (v) => v.attributes.language === language 42 + ); 43 + if (!languageResult) return false; 44 + await download( 45 + languageResult.attributes.legacy_subtitle_id, 46 + languageResult.attributes.language 47 + ); 48 + return true; 49 + }, [lastSelectedLanguage, search, download]); 50 + 51 + const toggleLastUsed = useCallback(async () => { 52 + if (!enabled) await downloadLastUsed(); 53 + else disable(); 54 + }, [downloadLastUsed, disable, enabled]); 55 + 56 + return { 57 + download, 58 + search, 59 + disable, 60 + downloadLastUsed, 61 + toggleLastUsed, 62 + }; 63 + }
+21
src/components/player/internals/ContextMenu/Input.tsx
··· 1 + import { Icon, Icons } from "@/components/Icon"; 2 + 3 + export function Input(props: { 4 + value: string; 5 + onInput: (str: string) => void; 6 + }) { 7 + return ( 8 + <div className="w-full relative mb-6"> 9 + <Icon 10 + className="pointer-events-none absolute top-1/2 left-3 transform -translate-y-1/2 text-video-context-inputPlaceholder" 11 + icon={Icons.SEARCH} 12 + /> 13 + <input 14 + placeholder="Search" 15 + className="w-full py-2 px-3 pl-[calc(0.75rem+24px)] bg-video-context-inputBg rounded placeholder:text-video-context-inputPlaceholder" 16 + value={props.value} 17 + onInput={(e) => props.onInput(e.currentTarget.value)} 18 + /> 19 + </div> 20 + ); 21 + }
+11
src/components/player/internals/KeyboardEvents.tsx
··· 1 1 import { useEffect, useRef, useState } from "react"; 2 2 3 + import { useCaptions } from "@/components/player/hooks/useCaptions"; 3 4 import { useVolume } from "@/components/player/hooks/useVolume"; 4 5 import { usePlayerStore } from "@/stores/player/store"; 5 6 import { useEmpheralVolumeStore } from "@/stores/volume"; ··· 10 11 const time = usePlayerStore((s) => s.progress.time); 11 12 const { setVolume, toggleMute } = useVolume(); 12 13 14 + const { toggleLastUsed } = useCaptions(); 13 15 const setShowVolume = useEmpheralVolumeStore((s) => s.setShowVolume); 14 16 15 17 const [isRolling, setIsRolling] = useState(false); ··· 20 22 setVolume, 21 23 toggleMute, 22 24 setIsRolling, 25 + toggleLastUsed, 23 26 display, 24 27 mediaPlaying, 25 28 isRolling, ··· 31 34 setVolume, 32 35 toggleMute, 33 36 setIsRolling, 37 + toggleLastUsed, 34 38 display, 35 39 mediaPlaying, 36 40 isRolling, ··· 41 45 setVolume, 42 46 toggleMute, 43 47 setIsRolling, 48 + toggleLastUsed, 44 49 display, 45 50 mediaPlaying, 46 51 isRolling, ··· 49 54 50 55 useEffect(() => { 51 56 const keyEventHandler = (evt: KeyboardEvent) => { 57 + if (evt.target && (evt.target as HTMLInputElement).nodeName === "INPUT") 58 + return; 59 + 52 60 const k = evt.key; 53 61 54 62 // Volume ··· 82 90 dataRef.current.display?.[ 83 91 dataRef.current.mediaPlaying.isPaused ? "play" : "pause" 84 92 ](); 93 + 94 + // captions 95 + if (k === "c") dataRef.current.toggleLastUsed().catch(() => {}); // ignore errors 85 96 86 97 // Do a barrell roll! 87 98 if (k === "r") {
+30 -29
tailwind.config.js
··· 26 26 "ash-400": "#3D394D", 27 27 "ash-300": "#2C293A", 28 28 "ash-200": "#2B2836", 29 - "ash-100": "#1E1C26", 29 + "ash-100": "#1E1C26" 30 30 }, 31 31 32 32 /* fonts */ 33 33 fontFamily: { 34 - "open-sans": "'Open Sans'", 34 + "open-sans": "'Open Sans'" 35 35 }, 36 36 37 37 /* animations */ 38 38 keyframes: { 39 39 "loading-pin": { 40 40 "0%, 40%, 100%": { height: "0.5em", "background-color": "#282336" }, 41 - "20%": { height: "1em", "background-color": "white" }, 42 - }, 41 + "20%": { height: "1em", "background-color": "white" } 42 + } 43 43 }, 44 - animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }, 45 - }, 44 + animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" } 45 + } 46 46 }, 47 47 plugins: [ 48 48 require("tailwind-scrollbar"), ··· 52 52 colors: { 53 53 // Branding 54 54 pill: { 55 - background: "#1C1C36", 55 + background: "#1C1C36" 56 56 }, 57 57 58 58 // meta data for the theme itself 59 59 global: { 60 60 accentA: "#505DBD", 61 - accentB: "#3440A1", 61 + accentB: "#3440A1" 62 62 }, 63 63 64 64 // light bar 65 65 lightBar: { 66 - light: "#2A2A71", 66 + light: "#2A2A71" 67 67 }, 68 68 69 69 // Buttons 70 70 buttons: { 71 71 toggle: "#8D44D6", 72 - toggleDisabled: "#202836", 72 + toggleDisabled: "#202836" 73 73 }, 74 74 75 75 // only used for body colors/textures 76 76 background: { 77 77 main: "#0A0A10", 78 78 accentA: "#6E3B80", 79 - accentB: "#1F1F50", 79 + accentB: "#1F1F50" 80 80 }, 81 81 82 82 // typography ··· 85 85 text: "#73739D", 86 86 dimmed: "#926CAD", 87 87 divider: "#262632", 88 - secondary: "#64647B", 88 + secondary: "#64647B" 89 89 }, 90 90 91 91 // search bar ··· 94 94 focused: "#24243C", 95 95 placeholder: "#4A4A71", 96 96 icon: "#545476", 97 - text: "#FFFFFF", 97 + text: "#FFFFFF" 98 98 }, 99 99 100 100 // media cards ··· 106 106 barColor: "#4B4B63", 107 107 barFillColor: "#BA7FD6", 108 108 badge: "#151522", 109 - badgeText: "#5F5F7A", 109 + badgeText: "#5F5F7A" 110 110 }, 111 111 112 112 // video player ··· 118 118 error: "#E44F4F", 119 119 success: "#40B44B", 120 120 loading: "#B759D8", 121 - noresult: "#64647B", 121 + noresult: "#64647B" 122 122 }, 123 123 124 124 progress: { 125 125 background: "#8787A8", 126 126 preloaded: "#8787A8", 127 - watched: "#A75FC9", 127 + watched: "#A75FC9" 128 128 }, 129 129 130 130 audio: { 131 - set: "#A75FC9", 131 + set: "#A75FC9" 132 132 }, 133 133 134 134 buttons: { ··· 137 137 secondaryHover: "#1B262E", 138 138 primary: "#fff", 139 139 primaryText: "#000", 140 - primaryHover: "#dedede", 140 + primaryHover: "#dedede" 141 141 }, 142 142 143 143 context: { ··· 148 148 buttonFocus: "#202836", 149 149 flagBg: "#202836", 150 150 inputBg: "#202836", 151 + inputPlaceholder: "#374A56", 151 152 cardBorder: "#1B262E", 152 153 slider: "#8787A8", 153 154 sliderFilled: "#A75FC9", 154 155 155 156 download: { 156 157 button: "#6b298a", 157 - hover: "#7f35a1", 158 + hover: "#7f35a1" 158 159 }, 159 160 160 161 buttons: { 161 162 list: "#161C26", 162 - active: "#0D1317", 163 + active: "#0D1317" 163 164 }, 164 165 165 166 type: { 166 167 main: "#617A8A", 167 168 secondary: "#374A56", 168 - accent: "#A570FA", 169 - }, 170 - }, 171 - }, 172 - }, 173 - }, 174 - }, 175 - }), 176 - ], 169 + accent: "#A570FA" 170 + } 171 + } 172 + } 173 + } 174 + } 175 + } 176 + }) 177 + ] 177 178 };