mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +01:00
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:
parent
9ce0e6a099
commit
8e65db04a3
9 changed files with 128 additions and 101 deletions
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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 = (
|
||||
<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({
|
||||
|
|
|
@ -34,15 +34,9 @@ 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) => {
|
||||
session.loadMedia(request).catch((e: any) => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -152,6 +152,7 @@ module.exports = {
|
|||
cardBorder: "#1B262E",
|
||||
slider: "#8787A8",
|
||||
sliderFilled: "#A75FC9",
|
||||
error: "#E44F4F",
|
||||
|
||||
download: {
|
||||
button: "#6b298a",
|
||||
|
|
Loading…
Reference in a new issue