diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index f553de9d..d9f308f4 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -2,7 +2,7 @@ import Fuse from "fuse.js"; import { ReactNode, useState } from "react"; import { useAsync, useAsyncFn } from "react-use"; -import { languageIdToName } from "@/backend/helpers/subs"; +import { SubtitleSearchItem, languageIdToName } from "@/backend/helpers/subs"; import { FlagIcon } from "@/components/FlagIcon"; import { useCaptions } from "@/components/player/hooks/useCaptions"; import { Menu } from "@/components/player/internals/ContextMenu"; @@ -15,7 +15,9 @@ export function CaptionOption(props: { countryCode?: string; children: React.ReactNode; selected?: boolean; + loading?: boolean; onClick?: () => void; + error?: React.ReactNode; }) { // Country code overrides const countryOverrides: Record = { @@ -34,7 +36,12 @@ export function CaptionOption(props: { countryCode = countryOverrides[countryCode]; return ( - + @@ -45,12 +52,46 @@ export function CaptionOption(props: { ); } -// TODO cache like everything in this view +function searchSubs( + subs: (SubtitleSearchItem & { languageName: string })[], + searchQuery: string +) { + const languagesOrder = ["en", "hi", "fr", "de", "nl", "pt"].reverse(); // Reverse is neccesary, not sure why + + let results = subs.sort((a, b) => { + if ( + languagesOrder.indexOf(b.attributes.language) !== -1 || + languagesOrder.indexOf(a.attributes.language) !== -1 + ) + return ( + languagesOrder.indexOf(b.attributes.language) - + languagesOrder.indexOf(a.attributes.language) + ); + + return a.languageName.localeCompare(b.languageName); + }); + + if (searchQuery.trim().length > 0) { + const fuse = new Fuse(subs, { + includeScore: true, + keys: ["languageName"], + }); + + results = fuse.search(searchQuery).map((res) => res.item); + } + + return results; +} + +// TODO on initialize, download captions // TODO fix language names, some are unknown -// TODO sort languages by common usage +// TODO delay setting for captions export function CaptionsView({ id }: { id: string }) { const router = useOverlayRouter(id); const lang = usePlayerStore((s) => s.caption.selected?.language); + const [currentlyDownloading, setCurrentlyDownloading] = useState< + string | null + >(null); const { search, download, disable } = useCaptions(); const [searchQuery, setSearchQuery] = useState(""); @@ -58,8 +99,11 @@ export function CaptionsView({ id }: { id: string }) { const req = useAsync(async () => search(), [search]); const [downloadReq, startDownload] = useAsyncFn( - (subtitleId: string, language: string) => download(subtitleId, language), - [download] + async (subtitleId: string, language: string) => { + setCurrentlyDownloading(subtitleId); + return download(subtitleId, language); + }, + [download, setCurrentlyDownloading] ); let downloadProgress: ReactNode = null; @@ -78,22 +122,22 @@ export function CaptionsView({ id }: { id: string }) { }; }); - 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) => { + content = searchSubs(subs, searchQuery).map((v) => { return ( startDownload( v.attributes.legacy_subtitle_id, diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts index fac32153..085f28c2 100644 --- a/src/components/player/display/base.ts +++ b/src/components/player/display/base.ts @@ -80,18 +80,15 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { (v) => v.height === qualityToHlsLevel(availableQuality) ); if (levelIndex !== -1) { - console.log("setting level", levelIndex, availableQuality); hls.currentLevel = levelIndex; hls.loadLevel = levelIndex; } } } else { - console.log("setting to automatic"); hls.currentLevel = -1; hls.loadLevel = -1; } const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]); - console.log("updating quality menu", quality); emit("changedquality", quality); } @@ -117,7 +114,6 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { hls.on(Hls.Events.LEVEL_SWITCHED, () => { if (!hls) return; const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]); - console.log("EVENT updating quality menu", quality); emit("changedquality", quality); }); } diff --git a/src/components/player/hooks/useCaptions.ts b/src/components/player/hooks/useCaptions.ts index 77e773d3..b27deaa5 100644 --- a/src/components/player/hooks/useCaptions.ts +++ b/src/components/player/hooks/useCaptions.ts @@ -1,8 +1,26 @@ import { useCallback } from "react"; -import { downloadSrt, searchSubtitles } from "@/backend/helpers/subs"; +import { + SubtitleSearchItem, + downloadSrt, + searchSubtitles, +} from "@/backend/helpers/subs"; import { usePlayerStore } from "@/stores/player/store"; import { useSubtitleStore } from "@/stores/subtitles"; +import { SimpleCache } from "@/utils/cache"; + +const cacheTimeSec = 24 * 60 * 60; // 24 hours + +const downloadCache = new SimpleCache(); +downloadCache.setCompare((a, b) => a === b); + +const searchCache = new SimpleCache< + { tmdbId: string; ep?: string; season?: string }, + SubtitleSearchItem[] +>(); +searchCache.setCompare( + (a, b) => a.tmdbId === b.tmdbId && a.ep === b.ep && a.season === b.season +); export function useCaptions() { const setLanguage = useSubtitleStore((s) => s.setLanguage); @@ -13,7 +31,11 @@ export function useCaptions() { const download = useCallback( async (subtitleId: string, language: string) => { - const srtData = await downloadSrt(subtitleId); + let srtData = downloadCache.get(subtitleId); + if (!srtData) { + srtData = await downloadSrt(subtitleId); + downloadCache.set(subtitleId, srtData, cacheTimeSec); + } setCaption({ language, srtData, @@ -26,7 +48,17 @@ export function useCaptions() { const search = useCallback(async () => { if (!meta) throw new Error("No meta"); - return searchSubtitles(meta); + const key = { + tmdbId: meta.tmdbId, + ep: meta.episode?.tmdbId, + season: meta.season?.tmdbId, + }; + const results = searchCache.get(key); + if (results) return [...results]; + + const freshResults = await searchSubtitles(meta); + searchCache.set(key, [...freshResults], cacheTimeSec); + return freshResults; }, [meta]); const disable = useCallback(async () => { diff --git a/src/components/player/internals/ContextMenu/Links.tsx b/src/components/player/internals/ContextMenu/Links.tsx index 363a864b..e799d143 100644 --- a/src/components/player/internals/ContextMenu/Links.tsx +++ b/src/components/player/internals/ContextMenu/Links.tsx @@ -2,6 +2,7 @@ import classNames from "classnames"; import { ReactNode } from "react"; import { Icon, Icons } from "@/components/Icon"; +import { Spinner } from "@/components/layout/Spinner"; import { Title } from "@/components/player/internals/ContextMenu/Misc"; export function Chevron(props: { children?: React.ReactNode }) { @@ -112,21 +113,34 @@ export function ChevronLink(props: { export function SelectableLink(props: { selected?: boolean; + loading?: boolean; onClick?: () => void; children?: ReactNode; disabled?: boolean; + error?: ReactNode; }) { - const rightContent = ( - - ); + let rightContent; + if (props.selected) { + rightContent = ( + + ); + } + if (props.error) + rightContent = ( + + + + ); + if (props.loading) rightContent = ; // should override selected and error + return ( { - console.log("Media is loaded"); - }) - .catch((e: any) => { - console.error(e); - }); + session.loadMedia(request).catch((e: any) => { + console.error(e); + }); } function stopCast() { diff --git a/src/hooks/useLoading.ts b/src/hooks/useLoading.ts deleted file mode 100644 index 987456db..00000000 --- a/src/hooks/useLoading.ts +++ /dev/null @@ -1,53 +0,0 @@ -import React, { useMemo, useRef, useState } from "react"; - -export function useLoading Promise>( - action: T -): [ - (...args: Parameters) => ReturnType | Promise, - boolean, - Error | undefined, - boolean -] { - const [loading, setLoading] = useState(false); - const [success, setSuccess] = useState(false); - const [error, setError] = useState(undefined); - const isMounted = useRef(true); - - // we want action to be memoized forever - const actionMemo = useMemo(() => action, []); // eslint-disable-line react-hooks/exhaustive-deps - - React.useEffect(() => { - isMounted.current = true; - return () => { - isMounted.current = false; - }; - }, []); - - const doAction = useMemo( - () => - async (...args: any) => { - setLoading(true); - setSuccess(false); - setError(undefined); - return new Promise((resolve) => { - actionMemo(...args) - .then((v) => { - if (!isMounted.current) return resolve(undefined); - setSuccess(true); - resolve(v); - return null; - }) - .catch((err) => { - if (isMounted) { - setError(err); - console.error("USELOADING ERROR", err); - setSuccess(false); - } - resolve(undefined); - }); - }).finally(() => isMounted.current && setLoading(false)); - }, - [actionMemo] - ); - return [doAction, loading, error, success]; -} diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index 0585f502..45221b10 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -89,6 +89,7 @@ export function PlayerPart(props: PlayerPartProps) {
+
diff --git a/src/pages/parts/search/SearchListPart.tsx b/src/pages/parts/search/SearchListPart.tsx index a82ac23e..a4be8231 100644 --- a/src/pages/parts/search/SearchListPart.tsx +++ b/src/pages/parts/search/SearchListPart.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useAsyncFn } from "react-use"; import { searchForMedia } from "@/backend/metadata/search"; import { MWQuery } from "@/backend/metadata/types/mw"; @@ -8,7 +9,6 @@ import { Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; import { MediaGrid } from "@/components/media/MediaGrid"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; -import { useLoading } from "@/hooks/useLoading"; import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart"; import { MediaItem } from "@/utils/mediaTypes"; @@ -49,22 +49,20 @@ export function SearchListPart({ searchQuery }: { searchQuery: string }) { const { t } = useTranslation(); const [results, setResults] = useState([]); - const [runSearchQuery, loading, error] = useLoading((query: MWQuery) => - searchForMedia(query) - ); + const [state, exec] = useAsyncFn((query: MWQuery) => searchForMedia(query)); useEffect(() => { async function runSearch(query: MWQuery) { - const searchResults = await runSearchQuery(query); + const searchResults = await exec(query); if (!searchResults) return; setResults(searchResults); } if (searchQuery !== "") runSearch({ searchQuery }); - }, [searchQuery, runSearchQuery]); + }, [searchQuery, exec]); - if (loading) return ; - if (error) return ; + if (state.loading) return ; + if (state.error) return ; if (!results) return null; return ( diff --git a/tailwind.config.js b/tailwind.config.js index 2542482b..2ed17141 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -152,6 +152,7 @@ module.exports = { cardBorder: "#1B262E", slider: "#8787A8", sliderFilled: "#A75FC9", + error: "#E44F4F", download: { button: "#6b298a",