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.

searching of subs + caching of results + sort subs by common usage + better loading state for subs + PiP added to mobile + remove useLoading

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

mrjvs 8e65db04 9ce0e6a0

+128 -101
+61 -17
src/components/player/atoms/settings/CaptionsView.tsx
··· 2 2 import { ReactNode, useState } from "react"; 3 3 import { useAsync, useAsyncFn } from "react-use"; 4 4 5 - import { languageIdToName } from "@/backend/helpers/subs"; 5 + import { SubtitleSearchItem, languageIdToName } from "@/backend/helpers/subs"; 6 6 import { FlagIcon } from "@/components/FlagIcon"; 7 7 import { useCaptions } from "@/components/player/hooks/useCaptions"; 8 8 import { Menu } from "@/components/player/internals/ContextMenu"; ··· 15 15 countryCode?: string; 16 16 children: React.ReactNode; 17 17 selected?: boolean; 18 + loading?: boolean; 18 19 onClick?: () => void; 20 + error?: React.ReactNode; 19 21 }) { 20 22 // Country code overrides 21 23 const countryOverrides: Record<string, string> = { ··· 34 36 countryCode = countryOverrides[countryCode]; 35 37 36 38 return ( 37 - <SelectableLink selected={props.selected} onClick={props.onClick}> 39 + <SelectableLink 40 + selected={props.selected} 41 + loading={props.loading} 42 + error={props.error} 43 + onClick={props.onClick} 44 + > 38 45 <span className="flex items-center"> 39 46 <span data-code={props.countryCode} className="mr-3"> 40 47 <FlagIcon countryCode={countryCode} /> ··· 45 52 ); 46 53 } 47 54 48 - // TODO cache like everything in this view 55 + function searchSubs( 56 + subs: (SubtitleSearchItem & { languageName: string })[], 57 + searchQuery: string 58 + ) { 59 + const languagesOrder = ["en", "hi", "fr", "de", "nl", "pt"].reverse(); // Reverse is neccesary, not sure why 60 + 61 + let results = subs.sort((a, b) => { 62 + if ( 63 + languagesOrder.indexOf(b.attributes.language) !== -1 || 64 + languagesOrder.indexOf(a.attributes.language) !== -1 65 + ) 66 + return ( 67 + languagesOrder.indexOf(b.attributes.language) - 68 + languagesOrder.indexOf(a.attributes.language) 69 + ); 70 + 71 + return a.languageName.localeCompare(b.languageName); 72 + }); 73 + 74 + if (searchQuery.trim().length > 0) { 75 + const fuse = new Fuse(subs, { 76 + includeScore: true, 77 + keys: ["languageName"], 78 + }); 79 + 80 + results = fuse.search(searchQuery).map((res) => res.item); 81 + } 82 + 83 + return results; 84 + } 85 + 86 + // TODO on initialize, download captions 49 87 // TODO fix language names, some are unknown 50 - // TODO sort languages by common usage 88 + // TODO delay setting for captions 51 89 export function CaptionsView({ id }: { id: string }) { 52 90 const router = useOverlayRouter(id); 53 91 const lang = usePlayerStore((s) => s.caption.selected?.language); 92 + const [currentlyDownloading, setCurrentlyDownloading] = useState< 93 + string | null 94 + >(null); 54 95 const { search, download, disable } = useCaptions(); 55 96 56 97 const [searchQuery, setSearchQuery] = useState(""); ··· 58 99 const req = useAsync(async () => search(), [search]); 59 100 60 101 const [downloadReq, startDownload] = useAsyncFn( 61 - (subtitleId: string, language: string) => download(subtitleId, language), 62 - [download] 102 + async (subtitleId: string, language: string) => { 103 + setCurrentlyDownloading(subtitleId); 104 + return download(subtitleId, language); 105 + }, 106 + [download, setCurrentlyDownloading] 63 107 ); 64 108 65 109 let downloadProgress: ReactNode = null; ··· 78 122 }; 79 123 }); 80 124 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) => { 125 + content = searchSubs(subs, searchQuery).map((v) => { 92 126 return ( 93 127 <CaptionOption 94 128 key={v.id} 95 129 countryCode={v.attributes.language} 96 130 selected={lang === v.attributes.language} 131 + loading={ 132 + v.attributes.legacy_subtitle_id === currentlyDownloading && 133 + downloadReq.loading 134 + } 135 + error={ 136 + v.attributes.legacy_subtitle_id === currentlyDownloading && 137 + downloadReq.error 138 + ? downloadReq.error 139 + : undefined 140 + } 97 141 onClick={() => 98 142 startDownload( 99 143 v.attributes.legacy_subtitle_id,
-4
src/components/player/display/base.ts
··· 80 80 (v) => v.height === qualityToHlsLevel(availableQuality) 81 81 ); 82 82 if (levelIndex !== -1) { 83 - console.log("setting level", levelIndex, availableQuality); 84 83 hls.currentLevel = levelIndex; 85 84 hls.loadLevel = levelIndex; 86 85 } 87 86 } 88 87 } else { 89 - console.log("setting to automatic"); 90 88 hls.currentLevel = -1; 91 89 hls.loadLevel = -1; 92 90 } 93 91 const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]); 94 - console.log("updating quality menu", quality); 95 92 emit("changedquality", quality); 96 93 } 97 94 ··· 117 114 hls.on(Hls.Events.LEVEL_SWITCHED, () => { 118 115 if (!hls) return; 119 116 const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]); 120 - console.log("EVENT updating quality menu", quality); 121 117 emit("changedquality", quality); 122 118 }); 123 119 }
+35 -3
src/components/player/hooks/useCaptions.ts
··· 1 1 import { useCallback } from "react"; 2 2 3 - import { downloadSrt, searchSubtitles } from "@/backend/helpers/subs"; 3 + import { 4 + SubtitleSearchItem, 5 + downloadSrt, 6 + searchSubtitles, 7 + } from "@/backend/helpers/subs"; 4 8 import { usePlayerStore } from "@/stores/player/store"; 5 9 import { useSubtitleStore } from "@/stores/subtitles"; 10 + import { SimpleCache } from "@/utils/cache"; 11 + 12 + const cacheTimeSec = 24 * 60 * 60; // 24 hours 13 + 14 + const downloadCache = new SimpleCache<string, string>(); 15 + downloadCache.setCompare((a, b) => a === b); 16 + 17 + const searchCache = new SimpleCache< 18 + { tmdbId: string; ep?: string; season?: string }, 19 + SubtitleSearchItem[] 20 + >(); 21 + searchCache.setCompare( 22 + (a, b) => a.tmdbId === b.tmdbId && a.ep === b.ep && a.season === b.season 23 + ); 6 24 7 25 export function useCaptions() { 8 26 const setLanguage = useSubtitleStore((s) => s.setLanguage); ··· 13 31 14 32 const download = useCallback( 15 33 async (subtitleId: string, language: string) => { 16 - const srtData = await downloadSrt(subtitleId); 34 + let srtData = downloadCache.get(subtitleId); 35 + if (!srtData) { 36 + srtData = await downloadSrt(subtitleId); 37 + downloadCache.set(subtitleId, srtData, cacheTimeSec); 38 + } 17 39 setCaption({ 18 40 language, 19 41 srtData, ··· 26 48 27 49 const search = useCallback(async () => { 28 50 if (!meta) throw new Error("No meta"); 29 - return searchSubtitles(meta); 51 + const key = { 52 + tmdbId: meta.tmdbId, 53 + ep: meta.episode?.tmdbId, 54 + season: meta.season?.tmdbId, 55 + }; 56 + const results = searchCache.get(key); 57 + if (results) return [...results]; 58 + 59 + const freshResults = await searchSubtitles(meta); 60 + searchCache.set(key, [...freshResults], cacheTimeSec); 61 + return freshResults; 30 62 }, [meta]); 31 63 32 64 const disable = useCallback(async () => {
+21 -7
src/components/player/internals/ContextMenu/Links.tsx
··· 2 2 import { ReactNode } from "react"; 3 3 4 4 import { Icon, Icons } from "@/components/Icon"; 5 + import { Spinner } from "@/components/layout/Spinner"; 5 6 import { Title } from "@/components/player/internals/ContextMenu/Misc"; 6 7 7 8 export function Chevron(props: { children?: React.ReactNode }) { ··· 112 113 113 114 export function SelectableLink(props: { 114 115 selected?: boolean; 116 + loading?: boolean; 115 117 onClick?: () => void; 116 118 children?: ReactNode; 117 119 disabled?: boolean; 120 + error?: ReactNode; 118 121 }) { 119 - const rightContent = ( 120 - <Icon 121 - icon={Icons.CIRCLE_CHECK} 122 - className="text-xl text-video-context-type-accent" 123 - /> 124 - ); 122 + let rightContent; 123 + if (props.selected) { 124 + rightContent = ( 125 + <Icon 126 + icon={Icons.CIRCLE_CHECK} 127 + className="text-xl text-video-context-type-accent" 128 + /> 129 + ); 130 + } 131 + if (props.error) 132 + rightContent = ( 133 + <span className="flex items-center text-video-context-error"> 134 + <Icon className="ml-2" icon={Icons.WARNING} /> 135 + </span> 136 + ); 137 + if (props.loading) rightContent = <Spinner className="text-xl" />; // should override selected and error 138 + 125 139 return ( 126 140 <Link 127 141 onClick={props.onClick} 128 142 clickable={!props.disabled} 129 - rightSide={props.selected ? rightContent : null} 143 + rightSide={rightContent} 130 144 > 131 145 <LinkTitle 132 146 textClass={classNames({
+3 -9
src/hooks/useChromecastAvailable.ts
··· 34 34 request.autoplay = true; 35 35 36 36 const session = instance.current?.getCurrentSession(); 37 - console.log("testing", session); 38 37 if (!session) return; 39 38 40 - session 41 - .loadMedia(request) 42 - .then(() => { 43 - console.log("Media is loaded"); 44 - }) 45 - .catch((e: any) => { 46 - console.error(e); 47 - }); 39 + session.loadMedia(request).catch((e: any) => { 40 + console.error(e); 41 + }); 48 42 } 49 43 50 44 function stopCast() {
-53
src/hooks/useLoading.ts
··· 1 - import React, { useMemo, useRef, useState } from "react"; 2 - 3 - export function useLoading<T extends (...args: any) => Promise<any>>( 4 - action: T 5 - ): [ 6 - (...args: Parameters<T>) => ReturnType<T> | Promise<undefined>, 7 - boolean, 8 - Error | undefined, 9 - boolean 10 - ] { 11 - const [loading, setLoading] = useState(false); 12 - const [success, setSuccess] = useState(false); 13 - const [error, setError] = useState<any | undefined>(undefined); 14 - const isMounted = useRef(true); 15 - 16 - // we want action to be memoized forever 17 - const actionMemo = useMemo(() => action, []); // eslint-disable-line react-hooks/exhaustive-deps 18 - 19 - React.useEffect(() => { 20 - isMounted.current = true; 21 - return () => { 22 - isMounted.current = false; 23 - }; 24 - }, []); 25 - 26 - const doAction = useMemo( 27 - () => 28 - async (...args: any) => { 29 - setLoading(true); 30 - setSuccess(false); 31 - setError(undefined); 32 - return new Promise<any>((resolve) => { 33 - actionMemo(...args) 34 - .then((v) => { 35 - if (!isMounted.current) return resolve(undefined); 36 - setSuccess(true); 37 - resolve(v); 38 - return null; 39 - }) 40 - .catch((err) => { 41 - if (isMounted) { 42 - setError(err); 43 - console.error("USELOADING ERROR", err); 44 - setSuccess(false); 45 - } 46 - resolve(undefined); 47 - }); 48 - }).finally(() => isMounted.current && setLoading(false)); 49 - }, 50 - [actionMemo] 51 - ); 52 - return [doAction, loading, error, success]; 53 - }
+1
src/pages/parts/player/PlayerPart.tsx
··· 89 89 <div className="grid grid-cols-[2.5rem,1fr,2.5rem] gap-3 lg:hidden"> 90 90 <div /> 91 91 <div className="flex justify-center space-x-3"> 92 + <Player.Pip /> 92 93 <Player.Episodes /> 93 94 <Player.Settings /> 94 95 </div>
+6 -8
src/pages/parts/search/SearchListPart.tsx
··· 1 1 import { useEffect, useState } from "react"; 2 2 import { useTranslation } from "react-i18next"; 3 + import { useAsyncFn } from "react-use"; 3 4 4 5 import { searchForMedia } from "@/backend/metadata/search"; 5 6 import { MWQuery } from "@/backend/metadata/types/mw"; ··· 8 9 import { SectionHeading } from "@/components/layout/SectionHeading"; 9 10 import { MediaGrid } from "@/components/media/MediaGrid"; 10 11 import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; 11 - import { useLoading } from "@/hooks/useLoading"; 12 12 import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart"; 13 13 import { MediaItem } from "@/utils/mediaTypes"; 14 14 ··· 49 49 const { t } = useTranslation(); 50 50 51 51 const [results, setResults] = useState<MediaItem[]>([]); 52 - const [runSearchQuery, loading, error] = useLoading((query: MWQuery) => 53 - searchForMedia(query) 54 - ); 52 + const [state, exec] = useAsyncFn((query: MWQuery) => searchForMedia(query)); 55 53 56 54 useEffect(() => { 57 55 async function runSearch(query: MWQuery) { 58 - const searchResults = await runSearchQuery(query); 56 + const searchResults = await exec(query); 59 57 if (!searchResults) return; 60 58 setResults(searchResults); 61 59 } 62 60 63 61 if (searchQuery !== "") runSearch({ searchQuery }); 64 - }, [searchQuery, runSearchQuery]); 62 + }, [searchQuery, exec]); 65 63 66 - if (loading) return <SearchLoadingPart />; 67 - if (error) return <SearchSuffix failed />; 64 + if (state.loading) return <SearchLoadingPart />; 65 + if (state.error) return <SearchSuffix failed />; 68 66 if (!results) return null; 69 67 70 68 return (
+1
tailwind.config.js
··· 152 152 cardBorder: "#1B262E", 153 153 slider: "#8787A8", 154 154 sliderFilled: "#A75FC9", 155 + error: "#E44F4F", 155 156 156 157 download: { 157 158 button: "#6b298a",