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>
This commit is contained in:
mrjvs 2023-10-22 22:07:45 +02:00
parent 9ce0e6a099
commit 8e65db04a3
9 changed files with 128 additions and 101 deletions

View File

@ -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<string, string> = {
@ -34,7 +36,12 @@ export function CaptionOption(props: {
countryCode = countryOverrides[countryCode];
return (
<SelectableLink selected={props.selected} onClick={props.onClick}>
<SelectableLink
selected={props.selected}
loading={props.loading}
error={props.error}
onClick={props.onClick}
>
<span className="flex items-center">
<span data-code={props.countryCode} className="mr-3">
<FlagIcon countryCode={countryCode} />
@ -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 (
<CaptionOption
key={v.id}
countryCode={v.attributes.language}
selected={lang === v.attributes.language}
loading={
v.attributes.legacy_subtitle_id === currentlyDownloading &&
downloadReq.loading
}
error={
v.attributes.legacy_subtitle_id === currentlyDownloading &&
downloadReq.error
? downloadReq.error
: undefined
}
onClick={() =>
startDownload(
v.attributes.legacy_subtitle_id,

View File

@ -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);
});
}

View File

@ -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<string, string>();
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 () => {

View File

@ -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 = (
<Icon
icon={Icons.CIRCLE_CHECK}
className="text-xl text-video-context-type-accent"
/>
);
let rightContent;
if (props.selected) {
rightContent = (
<Icon
icon={Icons.CIRCLE_CHECK}
className="text-xl text-video-context-type-accent"
/>
);
}
if (props.error)
rightContent = (
<span className="flex items-center text-video-context-error">
<Icon className="ml-2" icon={Icons.WARNING} />
</span>
);
if (props.loading) rightContent = <Spinner className="text-xl" />; // should override selected and error
return (
<Link
onClick={props.onClick}
clickable={!props.disabled}
rightSide={props.selected ? rightContent : null}
rightSide={rightContent}
>
<LinkTitle
textClass={classNames({

View File

@ -34,17 +34,11 @@ export function useChromecast() {
request.autoplay = true;
const session = instance.current?.getCurrentSession();
console.log("testing", session);
if (!session) return;
session
.loadMedia(request)
.then(() => {
console.log("Media is loaded");
})
.catch((e: any) => {
console.error(e);
});
session.loadMedia(request).catch((e: any) => {
console.error(e);
});
}
function stopCast() {

View File

@ -1,53 +0,0 @@
import React, { useMemo, useRef, useState } from "react";
export function useLoading<T extends (...args: any) => Promise<any>>(
action: T
): [
(...args: Parameters<T>) => ReturnType<T> | Promise<undefined>,
boolean,
Error | undefined,
boolean
] {
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<any | undefined>(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<any>((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];
}

View File

@ -89,6 +89,7 @@ export function PlayerPart(props: PlayerPartProps) {
<div className="grid grid-cols-[2.5rem,1fr,2.5rem] gap-3 lg:hidden">
<div />
<div className="flex justify-center space-x-3">
<Player.Pip />
<Player.Episodes />
<Player.Settings />
</div>

View File

@ -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<MediaItem[]>([]);
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 <SearchLoadingPart />;
if (error) return <SearchSuffix failed />;
if (state.loading) return <SearchLoadingPart />;
if (state.error) return <SearchSuffix failed />;
if (!results) return null;
return (

View File

@ -152,6 +152,7 @@ module.exports = {
cardBorder: "#1B262E",
slider: "#8787A8",
sliderFilled: "#A75FC9",
error: "#E44F4F",
download: {
button: "#6b298a",