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.

Merge pull request #69 from ztpn/subs-icon

Add a shortcut to subtitle settings in the bottomControls and update ui

authored by

Cooper and committed by
GitHub
1e79f649 b44bb511

+435 -295
+3 -1
src/assets/locales/en.json
··· 372 372 "customChoice": "Drop or upload file", 373 373 "customizeLabel": "Customize", 374 374 "offChoice": "Off", 375 + "SourceChoice": "Source Captions", 375 376 "OpenSubtitlesChoice": "OpenSubtitles", 376 377 "settings": { 377 378 "backlink": "Custom subtitles", ··· 382 383 "unknownLanguage": "Unknown", 383 384 "dropSubtitleFile": "Drop subtitle file here! >_<", 384 385 "scrapeButton": "Scrape subtitles", 385 - "empty": "There are no provided subtitles for this." 386 + "empty": "There are no provided subtitles for this.", 387 + "notFound": "None of the available options match your query" 386 388 } 387 389 }, 388 390 "metadata": {
+28
src/components/player/atoms/Captions.tsx
··· 1 + import { useEffect } from "react"; 2 + 3 + import { Icons } from "@/components/Icon"; 4 + import { OverlayAnchor } from "@/components/overlays/OverlayAnchor"; 5 + import { VideoPlayerButton } from "@/components/player/internals/Button"; 6 + import { useOverlayRouter } from "@/hooks/useOverlayRouter"; 7 + import { usePlayerStore } from "@/stores/player/store"; 8 + 9 + export function Captions() { 10 + const router = useOverlayRouter("settings"); 11 + const setHasOpenOverlay = usePlayerStore((s) => s.setHasOpenOverlay); 12 + 13 + useEffect(() => { 14 + setHasOpenOverlay(router.isRouterActive); 15 + }, [setHasOpenOverlay, router.isRouterActive]); 16 + 17 + return ( 18 + <OverlayAnchor id={router.id}> 19 + <VideoPlayerButton 20 + onClick={() => { 21 + router.open(); 22 + router.navigate("/captionsOverlay"); 23 + }} 24 + icon={Icons.CAPTIONS} 25 + /> 26 + </OverlayAnchor> 27 + ); 28 + }
+46 -1
src/components/player/atoms/Settings.tsx
··· 18 18 import { CaptionSettingsView } from "./settings/CaptionSettingsView"; 19 19 import { CaptionsView } from "./settings/CaptionsView"; 20 20 import { DownloadRoutes } from "./settings/Downloads"; 21 - import { OpenSubtitlesCaptionView } from "./settings/Opensubtitles"; 21 + import { OpenSubtitlesCaptionView } from "./settings/OpensubtitlesCaptionsView"; 22 22 import { PlaybackSettingsView } from "./settings/PlaybackSettingsView"; 23 23 import { QualityView } from "./settings/QualityView"; 24 24 import { SettingsMenu } from "./settings/SettingsMenu"; 25 + import SourceCaptionsView from "./settings/SourceCaptionsView"; 25 26 26 27 function SettingsOverlay({ id }: { id: string }) { 27 28 const [chosenSourceId, setChosenSourceId] = useState<string | null>(null); ··· 55 56 </OverlayPage> 56 57 <OverlayPage id={id} path="/captions" width={343} height={431}> 57 58 <Menu.CardWithScrollable> 59 + <CaptionsView id={id} backLink /> 60 + </Menu.CardWithScrollable> 61 + </OverlayPage> 62 + {/* This is used by the captions shortcut in bottomControls of player */} 63 + <OverlayPage id={id} path="/captionsOverlay" width={343} height={431}> 64 + <Menu.CardWithScrollable> 58 65 <CaptionsView id={id} /> 59 66 </Menu.CardWithScrollable> 60 67 </OverlayPage> ··· 68 75 <OpenSubtitlesCaptionView id={id} /> 69 76 </Menu.Card> 70 77 </OverlayPage> 78 + {/* This is used by the captions shortcut in bottomControls of player */} 79 + <OverlayPage 80 + id={id} 81 + path="/captions/opensubtitlesOverlay" 82 + width={343} 83 + height={431} 84 + > 85 + <Menu.Card> 86 + <OpenSubtitlesCaptionView id={id} overlayBackLink /> 87 + </Menu.Card> 88 + </OverlayPage> 89 + <OverlayPage id={id} path="/captions/source" width={343} height={431}> 90 + <Menu.Card> 91 + <SourceCaptionsView id={id} /> 92 + </Menu.Card> 93 + </OverlayPage> 94 + {/* This is used by the captions shortcut in bottomControls of player */} 95 + <OverlayPage 96 + id={id} 97 + path="/captions/sourceOverlay" 98 + width={343} 99 + height={431} 100 + > 101 + <Menu.Card> 102 + <SourceCaptionsView id={id} overlayBackLink /> 103 + </Menu.Card> 104 + </OverlayPage> 71 105 <OverlayPage id={id} path="/captions/settings" width={343} height={450}> 72 106 <Menu.Card> 73 107 <CaptionSettingsView id={id} /> 108 + </Menu.Card> 109 + </OverlayPage> 110 + {/* This is used by the captions shortcut in bottomControls of player */} 111 + <OverlayPage 112 + id={id} 113 + path="/captions/settingsOverlay" 114 + width={343} 115 + height={450} 116 + > 117 + <Menu.Card> 118 + <CaptionSettingsView id={id} overlayBackLink /> 74 119 </Menu.Card> 75 120 </OverlayPage> 76 121 <OverlayPage id={id} path="/source" width={343} height={431}>
+1
src/components/player/atoms/index.ts
··· 16 16 export * from "./NextEpisodeButton"; 17 17 export * from "./Chromecast"; 18 18 export * from "./CastingNotification"; 19 + export * from "./Captions";
+12 -2
src/components/player/atoms/settings/CaptionSettingsView.tsx
··· 216 216 217 217 export const colors = ["#ffffff", "#b0b0b0", "#80b1fa", "#e2e535"]; 218 218 219 - export function CaptionSettingsView({ id }: { id: string }) { 219 + export function CaptionSettingsView({ 220 + id, 221 + overlayBackLink, 222 + }: { 223 + id: string; 224 + overlayBackLink?: boolean; 225 + }) { 220 226 const { t } = useTranslation(); 221 227 const router = useOverlayRouter(id); 222 228 const styling = useSubtitleStore((s) => s.styling); ··· 228 234 229 235 return ( 230 236 <> 231 - <Menu.BackLink onClick={() => router.navigate("/captions")}> 237 + <Menu.BackLink 238 + onClick={() => 239 + router.navigate(overlayBackLink ? "/captionsOverlay" : "/captions") 240 + } 241 + > 232 242 {t("player.menus.subtitles.settings.backlink")} 233 243 </Menu.BackLink> 234 244 <Menu.Section className="space-y-6 pb-5">
+78 -124
src/components/player/atoms/settings/CaptionsView.tsx
··· 1 1 import classNames from "classnames"; 2 - import Fuse from "fuse.js"; 3 - import { type DragEvent, useMemo, useRef, useState } from "react"; 2 + import { type DragEvent, useRef, useState } from "react"; 4 3 import { useTranslation } from "react-i18next"; 5 - import { useAsyncFn } from "react-use"; 6 4 import { convert } from "subsrt-ts"; 7 5 8 6 import { subtitleTypeList } from "@/backend/helpers/subs"; ··· 11 9 import { Icon, Icons } from "@/components/Icon"; 12 10 import { useCaptions } from "@/components/player/hooks/useCaptions"; 13 11 import { Menu } from "@/components/player/internals/ContextMenu"; 14 - import { Input } from "@/components/player/internals/ContextMenu/Input"; 15 12 import { SelectableLink } from "@/components/player/internals/ContextMenu/Links"; 16 13 import { useOverlayRouter } from "@/hooks/useOverlayRouter"; 17 - import { CaptionListItem } from "@/stores/player/slices/source"; 18 14 import { usePlayerStore } from "@/stores/player/store"; 19 15 import { useSubtitleStore } from "@/stores/subtitles"; 20 - import { 21 - getPrettyLanguageNameFromLocale, 22 - sortLangCodes, 23 - } from "@/utils/language"; 16 + import { getPrettyLanguageNameFromLocale } from "@/utils/language"; 24 17 25 18 export function CaptionOption(props: { 26 19 countryCode?: string; ··· 29 22 loading?: boolean; 30 23 onClick?: () => void; 31 24 error?: React.ReactNode; 32 - chevron?: boolean; 33 25 }) { 34 26 return ( 35 27 <SelectableLink ··· 37 29 loading={props.loading} 38 30 error={props.error} 39 31 onClick={props.onClick} 40 - chevron={props.chevron} 41 32 > 42 33 <span 43 34 data-active-link={props.selected ? true : undefined} ··· 52 43 ); 53 44 } 54 45 55 - function CustomCaptionOption() { 46 + export function CustomCaptionOption() { 56 47 const { t } = useTranslation(); 57 48 const lang = usePlayerStore((s) => s.caption.selected?.language); 58 49 const setCaption = usePlayerStore((s) => s.setCaption); ··· 91 82 ); 92 83 } 93 84 94 - function useSubtitleList(subs: CaptionListItem[], searchQuery: string) { 95 - const { t: translate } = useTranslation(); 96 - const unknownChoice = translate("player.menus.subtitles.unknownLanguage"); 97 - return useMemo(() => { 98 - const input = subs 99 - .map((t) => ({ 100 - ...t, 101 - languageName: 102 - getPrettyLanguageNameFromLocale(t.language) ?? unknownChoice, 103 - })) 104 - .filter((x) => !x.opensubtitles); 105 - const sorted = sortLangCodes(input.map((t) => t.language)); 106 - let results = input.sort((a, b) => { 107 - return sorted.indexOf(a.language) - sorted.indexOf(b.language); 108 - }); 109 - 110 - if (searchQuery.trim().length > 0) { 111 - const fuse = new Fuse(input, { 112 - includeScore: true, 113 - keys: ["languageName"], 114 - }); 115 - 116 - results = fuse.search(searchQuery).map((res) => res.item); 117 - } 118 - 119 - return results; 120 - }, [subs, searchQuery, unknownChoice]); 121 - } 122 - 123 - export function CaptionsView({ id }: { id: string }) { 85 + export function CaptionsView({ 86 + id, 87 + backLink, 88 + }: { 89 + id: string; 90 + backLink?: true; 91 + }) { 124 92 const { t } = useTranslation(); 125 93 const router = useOverlayRouter(id); 126 94 const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id); 127 - const [currentlyDownloading, setCurrentlyDownloading] = useState< 128 - string | null 129 - >(null); 130 - const { selectCaptionById, disable } = useCaptions(); 131 - const captionList = usePlayerStore((s) => s.captionList); 132 - const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList); 95 + const { disable } = useCaptions(); 133 96 const [dragging, setDragging] = useState(false); 134 97 const setCaption = usePlayerStore((s) => s.setCaption); 98 + const selectedCaptionLanguage = usePlayerStore( 99 + (s) => s.caption.selected?.language, 100 + ); 135 101 136 102 function onDrop(event: DragEvent<HTMLDivElement>) { 137 103 const files = event.dataTransfer.files; ··· 159 125 reader.readAsText(firstFile); 160 126 } 161 127 162 - const captions = useMemo( 163 - () => 164 - captionList.length !== 0 ? captionList : getHlsCaptionList?.() ?? [], 165 - [captionList, getHlsCaptionList], 166 - ); 167 - 168 - const [searchQuery, setSearchQuery] = useState(""); 169 - const subtitleList = useSubtitleList(captions, searchQuery); 170 - 171 - const [downloadReq, startDownload] = useAsyncFn( 172 - async (captionId: string) => { 173 - setCurrentlyDownloading(captionId); 174 - return selectCaptionById(captionId); 175 - }, 176 - [selectCaptionById, setCurrentlyDownloading], 177 - ); 178 - 179 - const content = subtitleList.map((v) => { 180 - return ( 181 - <CaptionOption 182 - // key must use index to prevent url collisions 183 - key={v.id} 184 - countryCode={v.language} 185 - selected={v.id === selectedCaptionId} 186 - loading={v.id === currentlyDownloading && downloadReq.loading} 187 - error={ 188 - v.id === currentlyDownloading && downloadReq.error 189 - ? downloadReq.error.toString() 190 - : undefined 191 - } 192 - onClick={() => startDownload(v.id)} 193 - > 194 - {v.languageName} 195 - </CaptionOption> 196 - ); 197 - }); 128 + const selectedLanguagePretty = selectedCaptionLanguage 129 + ? getPrettyLanguageNameFromLocale(selectedCaptionLanguage) ?? 130 + t("player.menus.subtitles.unknownLanguage") 131 + : undefined; 198 132 199 133 return ( 200 134 <> ··· 213 147 </div> 214 148 </div> 215 149 216 - <Menu.BackLink 217 - onClick={() => router.navigate("/")} 218 - rightSide={ 219 - <button 220 - type="button" 221 - onClick={() => router.navigate("/captions/settings")} 222 - className="-mr-2 -my-1 px-2 p-[0.4em] rounded tabbable hover:bg-video-context-light hover:bg-opacity-10" 223 - > 224 - {t("player.menus.subtitles.customizeLabel")} 225 - </button> 226 - } 227 - > 228 - {t("player.menus.subtitles.title")} 229 - </Menu.BackLink> 150 + {backLink ? ( 151 + <Menu.BackLink 152 + onClick={() => router.navigate("/")} 153 + rightSide={ 154 + <button 155 + type="button" 156 + onClick={() => router.navigate("/captions/settings")} 157 + className="-mr-2 -my-1 px-2 p-[0.4em] rounded tabbable hover:bg-video-context-light hover:bg-opacity-10" 158 + > 159 + {t("player.menus.subtitles.customizeLabel")} 160 + </button> 161 + } 162 + > 163 + {t("player.menus.subtitles.title")} 164 + </Menu.BackLink> 165 + ) : ( 166 + <Menu.Title 167 + rightSide={ 168 + <button 169 + type="button" 170 + onClick={() => router.navigate("/captions/settingsOverlay")} 171 + className="-mr-2 -my-1 px-2 p-[0.4em] rounded tabbable hover:bg-video-context-light hover:bg-opacity-10" 172 + > 173 + {t("player.menus.subtitles.customizeLabel")} 174 + </button> 175 + } 176 + > 177 + {t("player.menus.subtitles.title")} 178 + </Menu.Title> 179 + )} 230 180 </div> 231 181 <FileDropHandler 232 182 className={`transition duration-300 ${dragging ? "opacity-20" : ""}`} ··· 235 185 }} 236 186 onDrop={(event) => onDrop(event)} 237 187 > 238 - <div className="mt-3 flex flex-row gap-2"> 239 - <Input value={searchQuery} onInput={setSearchQuery} /> 240 - <button 241 - type="button" 242 - onClick={() => router.navigate("/captions/opensubtitles")} 243 - className="p-[0.5em] rounded tabbable hover:bg-video-context-hoverColor hover:bg-opacity-50" 244 - > 245 - <Icon icon={Icons.WEB} /> 246 - </button> 247 - </div> 248 188 <Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3"> 249 189 <CaptionOption 250 190 onClick={() => disable()} ··· 253 193 {t("player.menus.subtitles.offChoice")} 254 194 </CaptionOption> 255 195 <CustomCaptionOption /> 256 - {content.length === 0 ? ( 257 - <div className="p-4 rounded-xl bg-video-context-light bg-opacity-10 font-medium text-center"> 258 - <div className="flex flex-col items-center justify-center gap-3"> 259 - {t("player.menus.subtitles.empty")} 260 - <button 261 - type="button" 262 - onClick={() => router.navigate("/captions/opensubtitles")} 263 - className="p-1 w-3/4 rounded tabbable duration-200 bg-opacity-10 bg-video-context-light hover:bg-opacity-20" 264 - > 265 - {t("player.menus.subtitles.scrapeButton")} 266 - </button> 267 - </div> 268 - </div> 269 - ) : ( 270 - content 271 - )} 196 + <Menu.ChevronLink 197 + onClick={() => 198 + router.navigate( 199 + backLink ? "/captions/source" : "/captions/sourceOverlay", 200 + ) 201 + } 202 + rightText={ 203 + useSubtitleStore((s) => s.isOpenSubtitles) 204 + ? "" 205 + : selectedLanguagePretty 206 + } 207 + > 208 + {t("player.menus.subtitles.SourceChoice")} 209 + </Menu.ChevronLink> 210 + <Menu.ChevronLink 211 + onClick={() => 212 + router.navigate( 213 + backLink 214 + ? "/captions/opensubtitles" 215 + : "/captions/opensubtitlesOverlay", 216 + ) 217 + } 218 + rightText={ 219 + useSubtitleStore((s) => s.isOpenSubtitles) 220 + ? selectedLanguagePretty 221 + : "" 222 + } 223 + > 224 + {t("player.menus.subtitles.OpenSubtitlesChoice")} 225 + </Menu.ChevronLink> 272 226 </Menu.ScrollToActiveSection> 273 227 </FileDropHandler> 274 228 </>
-141
src/components/player/atoms/settings/Opensubtitles.tsx
··· 1 - import Fuse from "fuse.js"; 2 - import { useMemo, useState } from "react"; 3 - import { useTranslation } from "react-i18next"; 4 - import { useAsyncFn } from "react-use"; 5 - 6 - import { FlagIcon } from "@/components/FlagIcon"; 7 - import { useCaptions } from "@/components/player/hooks/useCaptions"; 8 - import { Menu } from "@/components/player/internals/ContextMenu"; 9 - import { Input } from "@/components/player/internals/ContextMenu/Input"; 10 - import { SelectableLink } from "@/components/player/internals/ContextMenu/Links"; 11 - import { useOverlayRouter } from "@/hooks/useOverlayRouter"; 12 - import { CaptionListItem } from "@/stores/player/slices/source"; 13 - import { usePlayerStore } from "@/stores/player/store"; 14 - import { 15 - getPrettyLanguageNameFromLocale, 16 - sortLangCodes, 17 - } from "@/utils/language"; 18 - 19 - export function CaptionOption(props: { 20 - countryCode?: string; 21 - children: React.ReactNode; 22 - selected?: boolean; 23 - loading?: boolean; 24 - onClick?: () => void; 25 - error?: React.ReactNode; 26 - }) { 27 - return ( 28 - <SelectableLink 29 - selected={props.selected} 30 - loading={props.loading} 31 - error={props.error} 32 - onClick={props.onClick} 33 - > 34 - <span 35 - data-active-link={props.selected ? true : undefined} 36 - className="flex items-center" 37 - > 38 - <span data-code={props.countryCode} className="mr-3 inline-flex"> 39 - <FlagIcon langCode={props.countryCode} /> 40 - </span> 41 - <span>{props.children}</span> 42 - </span> 43 - </SelectableLink> 44 - ); 45 - } 46 - 47 - function useSubtitleList(subs: CaptionListItem[], searchQuery: string) { 48 - const { t: translate } = useTranslation(); 49 - const unknownChoice = translate("player.menus.subtitles.unknownLanguage"); 50 - return useMemo(() => { 51 - const input = subs 52 - .map((t) => ({ 53 - ...t, 54 - languageName: 55 - getPrettyLanguageNameFromLocale(t.language) ?? unknownChoice, 56 - })) 57 - .filter((x) => x.opensubtitles); 58 - const sorted = sortLangCodes(input.map((t) => t.language)); 59 - let results = input.sort((a, b) => { 60 - return sorted.indexOf(a.language) - sorted.indexOf(b.language); 61 - }); 62 - 63 - if (searchQuery.trim().length > 0) { 64 - const fuse = new Fuse(input, { 65 - includeScore: true, 66 - keys: ["languageName"], 67 - }); 68 - 69 - results = fuse.search(searchQuery).map((res) => res.item); 70 - } 71 - 72 - return results; 73 - }, [subs, searchQuery, unknownChoice]); 74 - } 75 - 76 - export function OpenSubtitlesCaptionView({ id }: { id: string }) { 77 - const { t } = useTranslation(); 78 - const router = useOverlayRouter(id); 79 - const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id); 80 - const [currentlyDownloading, setCurrentlyDownloading] = useState< 81 - string | null 82 - >(null); 83 - const { selectCaptionById } = useCaptions(); 84 - const captionList = usePlayerStore((s) => s.captionList); 85 - const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList); 86 - 87 - const captions = useMemo( 88 - () => 89 - captionList.length !== 0 ? captionList : getHlsCaptionList?.() ?? [], 90 - [captionList, getHlsCaptionList], 91 - ); 92 - 93 - const [searchQuery, setSearchQuery] = useState(""); 94 - const subtitleList = useSubtitleList(captions, searchQuery); 95 - 96 - const [downloadReq, startDownload] = useAsyncFn( 97 - async (captionId: string) => { 98 - setCurrentlyDownloading(captionId); 99 - return selectCaptionById(captionId); 100 - }, 101 - [selectCaptionById, setCurrentlyDownloading], 102 - ); 103 - 104 - const content = subtitleList.map((v) => { 105 - return ( 106 - <CaptionOption 107 - // key must use index to prevent url collisions 108 - key={v.id} 109 - countryCode={v.language} 110 - selected={v.id === selectedCaptionId} 111 - loading={v.id === currentlyDownloading && downloadReq.loading} 112 - error={ 113 - v.id === currentlyDownloading && downloadReq.error 114 - ? downloadReq.error.toString() 115 - : undefined 116 - } 117 - onClick={() => startDownload(v.id)} 118 - > 119 - {v.languageName} 120 - </CaptionOption> 121 - ); 122 - }); 123 - 124 - return ( 125 - <> 126 - <div> 127 - <Menu.BackLink onClick={() => router.navigate("/captions")}> 128 - {t("player.menus.subtitles.OpenSubtitlesChoice")} 129 - </Menu.BackLink> 130 - </div> 131 - <div className="mt-3"> 132 - <Input value={searchQuery} onInput={setSearchQuery} /> 133 - </div> 134 - <Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3"> 135 - {content} 136 - </Menu.ScrollToActiveSection> 137 - </> 138 - ); 139 - } 140 - 141 - export default OpenSubtitlesCaptionView;
+104
src/components/player/atoms/settings/OpensubtitlesCaptionsView.tsx
··· 1 + import { useMemo, useState } from "react"; 2 + import { useTranslation } from "react-i18next"; 3 + import { useAsyncFn } from "react-use"; 4 + 5 + import { useCaptions } from "@/components/player/hooks/useCaptions"; 6 + import { Menu } from "@/components/player/internals/ContextMenu"; 7 + import { Input } from "@/components/player/internals/ContextMenu/Input"; 8 + import { useOverlayRouter } from "@/hooks/useOverlayRouter"; 9 + import { usePlayerStore } from "@/stores/player/store"; 10 + 11 + import { CaptionOption } from "./CaptionsView"; 12 + import { useSubtitleList } from "./SourceCaptionsView"; 13 + 14 + export function OpenSubtitlesCaptionView({ 15 + id, 16 + overlayBackLink, 17 + }: { 18 + id: string; 19 + overlayBackLink?: true; 20 + }) { 21 + const { t } = useTranslation(); 22 + const router = useOverlayRouter(id); 23 + const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id); 24 + const [currentlyDownloading, setCurrentlyDownloading] = useState< 25 + string | null 26 + >(null); 27 + const { selectCaptionById } = useCaptions(); 28 + const captionList = usePlayerStore((s) => s.captionList); 29 + const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList); 30 + 31 + const captions = useMemo( 32 + () => 33 + captionList.length !== 0 ? captionList : getHlsCaptionList?.() ?? [], 34 + [captionList, getHlsCaptionList], 35 + ); 36 + 37 + const [searchQuery, setSearchQuery] = useState(""); 38 + const subtitleList = useSubtitleList( 39 + captions.filter((x) => x.opensubtitles), 40 + searchQuery, 41 + ); 42 + 43 + const [downloadReq, startDownload] = useAsyncFn( 44 + async (captionId: string) => { 45 + setCurrentlyDownloading(captionId); 46 + return selectCaptionById(captionId); 47 + }, 48 + [selectCaptionById, setCurrentlyDownloading], 49 + ); 50 + 51 + const content = subtitleList.length 52 + ? subtitleList.map((v) => { 53 + return ( 54 + <CaptionOption 55 + // key must use index to prevent url collisions 56 + key={v.id} 57 + countryCode={v.language} 58 + selected={v.id === selectedCaptionId} 59 + loading={v.id === currentlyDownloading && downloadReq.loading} 60 + error={ 61 + v.id === currentlyDownloading && downloadReq.error 62 + ? downloadReq.error.toString() 63 + : undefined 64 + } 65 + onClick={() => startDownload(v.id)} 66 + > 67 + {v.languageName} 68 + </CaptionOption> 69 + ); 70 + }) 71 + : t("player.menus.subtitles.notFound"); 72 + 73 + return ( 74 + <> 75 + <div> 76 + <Menu.BackLink 77 + onClick={() => 78 + router.navigate(overlayBackLink ? "/captionsOverlay" : "/captions") 79 + } 80 + > 81 + {t("player.menus.subtitles.OpenSubtitlesChoice")} 82 + </Menu.BackLink> 83 + </div> 84 + {captionList.filter((x) => x.opensubtitles).length ? ( 85 + <div className="mt-3"> 86 + <Input value={searchQuery} onInput={setSearchQuery} /> 87 + </div> 88 + ) : null} 89 + <Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3"> 90 + {!captionList.filter((x) => x.opensubtitles).length ? ( 91 + <div className="p-4 rounded-xl bg-video-context-light bg-opacity-10 font-medium text-center"> 92 + <div className="flex flex-col items-center justify-center gap-3"> 93 + {t("player.menus.subtitles.empty")} 94 + </div> 95 + </div> 96 + ) : ( 97 + <div className="text-center">{content}</div> 98 + )} 99 + </Menu.ScrollToActiveSection> 100 + </> 101 + ); 102 + } 103 + 104 + export default OpenSubtitlesCaptionView;
+149
src/components/player/atoms/settings/SourceCaptionsView.tsx
··· 1 + import Fuse from "fuse.js"; 2 + import { useMemo, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 4 + import { useAsyncFn } from "react-use"; 5 + 6 + import { useCaptions } from "@/components/player/hooks/useCaptions"; 7 + import { Menu } from "@/components/player/internals/ContextMenu"; 8 + import { Input } from "@/components/player/internals/ContextMenu/Input"; 9 + import { useOverlayRouter } from "@/hooks/useOverlayRouter"; 10 + import { CaptionListItem } from "@/stores/player/slices/source"; 11 + import { usePlayerStore } from "@/stores/player/store"; 12 + import { 13 + getPrettyLanguageNameFromLocale, 14 + sortLangCodes, 15 + } from "@/utils/language"; 16 + 17 + import { CaptionOption } from "./CaptionsView"; 18 + 19 + export function useSubtitleList(subs: CaptionListItem[], searchQuery: string) { 20 + const { t: translate } = useTranslation(); 21 + const unknownChoice = translate("player.menus.subtitles.unknownLanguage"); 22 + return useMemo(() => { 23 + const input = subs.map((t) => ({ 24 + ...t, 25 + languageName: 26 + getPrettyLanguageNameFromLocale(t.language) ?? unknownChoice, 27 + })); 28 + const sorted = sortLangCodes(input.map((t) => t.language)); 29 + let results = input.sort((a, b) => { 30 + return sorted.indexOf(a.language) - sorted.indexOf(b.language); 31 + }); 32 + 33 + if (searchQuery.trim().length > 0) { 34 + const fuse = new Fuse(input, { 35 + includeScore: true, 36 + keys: ["languageName"], 37 + }); 38 + 39 + results = fuse.search(searchQuery).map((res) => res.item); 40 + } 41 + 42 + return results; 43 + }, [subs, searchQuery, unknownChoice]); 44 + } 45 + 46 + export function SourceCaptionsView({ 47 + id, 48 + overlayBackLink, 49 + }: { 50 + id: string; 51 + overlayBackLink?: true; 52 + }) { 53 + const { t } = useTranslation(); 54 + const router = useOverlayRouter(id); 55 + const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id); 56 + const [currentlyDownloading, setCurrentlyDownloading] = useState< 57 + string | null 58 + >(null); 59 + const { selectCaptionById } = useCaptions(); 60 + const captionList = usePlayerStore((s) => s.captionList); 61 + const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList); 62 + 63 + const captions = useMemo( 64 + () => 65 + captionList.length !== 0 ? captionList : getHlsCaptionList?.() ?? [], 66 + [captionList, getHlsCaptionList], 67 + ); 68 + 69 + const [searchQuery, setSearchQuery] = useState(""); 70 + const subtitleList = useSubtitleList( 71 + captions.filter((x) => !x.opensubtitles), 72 + searchQuery, 73 + ); 74 + 75 + const [downloadReq, startDownload] = useAsyncFn( 76 + async (captionId: string) => { 77 + setCurrentlyDownloading(captionId); 78 + return selectCaptionById(captionId); 79 + }, 80 + [selectCaptionById, setCurrentlyDownloading], 81 + ); 82 + 83 + const content = subtitleList.length 84 + ? subtitleList.map((v) => { 85 + return ( 86 + <CaptionOption 87 + // key must use index to prevent url collisions 88 + key={v.id} 89 + countryCode={v.language} 90 + selected={v.id === selectedCaptionId} 91 + loading={v.id === currentlyDownloading && downloadReq.loading} 92 + error={ 93 + v.id === currentlyDownloading && downloadReq.error 94 + ? downloadReq.error.toString() 95 + : undefined 96 + } 97 + onClick={() => startDownload(v.id)} 98 + > 99 + {v.languageName} 100 + </CaptionOption> 101 + ); 102 + }) 103 + : t("player.menus.subtitles.notFound"); 104 + 105 + return ( 106 + <> 107 + <div> 108 + <Menu.BackLink 109 + onClick={() => 110 + router.navigate(overlayBackLink ? "/captionsOverlay" : "/captions") 111 + } 112 + > 113 + {t("player.menus.subtitles.SourceChoice")} 114 + </Menu.BackLink> 115 + </div> 116 + {captionList.filter((x) => !x.opensubtitles).length ? ( 117 + <div className="mt-3"> 118 + <Input value={searchQuery} onInput={setSearchQuery} /> 119 + </div> 120 + ) : null} 121 + <Menu.ScrollToActiveSection className="!pt-1 mt-2 pb-3"> 122 + {!captionList.filter((x) => !x.opensubtitles).length ? ( 123 + <div className="p-4 rounded-xl bg-video-context-light bg-opacity-10 font-medium text-center"> 124 + <div className="flex flex-col items-center justify-center gap-3"> 125 + {t("player.menus.subtitles.empty")} 126 + <button 127 + type="button" 128 + onClick={() => 129 + router.navigate( 130 + overlayBackLink 131 + ? "/captions/opensubtitlesOverlay" 132 + : "/captions/opensubtitles", 133 + ) 134 + } 135 + className="p-1 w-3/4 rounded tabbable duration-200 bg-opacity-10 bg-video-context-light hover:bg-opacity-20" 136 + > 137 + {t("player.menus.subtitles.scrapeButton")} 138 + </button> 139 + </div> 140 + </div> 141 + ) : ( 142 + <div className="text-center">{content}</div> 143 + )} 144 + </Menu.ScrollToActiveSection> 145 + </> 146 + ); 147 + } 148 + 149 + export default SourceCaptionsView;
+4 -24
src/components/player/internals/ContextMenu/Links.tsx
··· 123 123 children?: ReactNode; 124 124 disabled?: boolean; 125 125 error?: ReactNode; 126 - chevron?: boolean; 127 126 }) { 128 127 let rightContent; 129 128 if (props.selected) { 130 - if (props.chevron) { 131 - rightContent = ( 132 - <span className="flex items-center"> 133 - <Icon 134 - icon={Icons.CIRCLE_CHECK} 135 - className="text-xl text-video-context-type-accent" 136 - /> 137 - <Icon 138 - className="text-white text-xl ml-1 -mr-1.5" 139 - icon={Icons.CHEVRON_RIGHT} 140 - /> 141 - </span> 142 - ); 143 - } else { 144 - rightContent = ( 145 - <Icon 146 - icon={Icons.CIRCLE_CHECK} 147 - className="text-xl text-video-context-type-accent" 148 - /> 149 - ); 150 - } 151 - } else if (props.chevron) { 152 129 rightContent = ( 153 - <Icon className="text-xl ml-1 -mr-1.5" icon={Icons.CHEVRON_RIGHT} /> 130 + <Icon 131 + icon={Icons.CIRCLE_CHECK} 132 + className="text-xl text-video-context-type-accent" 133 + /> 154 134 ); 155 135 } 156 136 if (props.error)
+10 -2
src/pages/parts/player/PlayerPart.tsx
··· 111 111 ) : null} 112 112 {status === playerStatus.PLAYBACK_ERROR || 113 113 status === playerStatus.PLAYING ? ( 114 - <Player.Settings /> 114 + <> 115 + <Player.Captions /> 116 + <Player.Settings /> 117 + </> 115 118 ) : null} 116 119 <Player.Fullscreen /> 117 120 </div> ··· 121 124 <div className="flex justify-center space-x-3"> 122 125 {status === playerStatus.PLAYING ? <Player.Pip /> : null} 123 126 <Player.Episodes /> 124 - {status === playerStatus.PLAYING ? <Player.Settings /> : null} 127 + {status === playerStatus.PLAYING ? ( 128 + <> 129 + <Player.Captions /> 130 + <Player.Settings /> 131 + </> 132 + ) : null} 125 133 </div> 126 134 <div> 127 135 <Player.Fullscreen />