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 { ReactNode, useState } from "react";
|
||||||
import { useAsync, useAsyncFn } from "react-use";
|
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 { FlagIcon } from "@/components/FlagIcon";
|
||||||
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
||||||
import { Menu } from "@/components/player/internals/ContextMenu";
|
import { Menu } from "@/components/player/internals/ContextMenu";
|
||||||
|
@ -15,7 +15,9 @@ export function CaptionOption(props: {
|
||||||
countryCode?: string;
|
countryCode?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
error?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
// Country code overrides
|
// Country code overrides
|
||||||
const countryOverrides: Record<string, string> = {
|
const countryOverrides: Record<string, string> = {
|
||||||
|
@ -34,7 +36,12 @@ export function CaptionOption(props: {
|
||||||
countryCode = countryOverrides[countryCode];
|
countryCode = countryOverrides[countryCode];
|
||||||
|
|
||||||
return (
|
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 className="flex items-center">
|
||||||
<span data-code={props.countryCode} className="mr-3">
|
<span data-code={props.countryCode} className="mr-3">
|
||||||
<FlagIcon countryCode={countryCode} />
|
<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 fix language names, some are unknown
|
||||||
// TODO sort languages by common usage
|
// TODO delay setting for captions
|
||||||
export function CaptionsView({ id }: { id: string }) {
|
export function CaptionsView({ id }: { id: string }) {
|
||||||
const router = useOverlayRouter(id);
|
const router = useOverlayRouter(id);
|
||||||
const lang = usePlayerStore((s) => s.caption.selected?.language);
|
const lang = usePlayerStore((s) => s.caption.selected?.language);
|
||||||
|
const [currentlyDownloading, setCurrentlyDownloading] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
const { search, download, disable } = useCaptions();
|
const { search, download, disable } = useCaptions();
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
@ -58,8 +99,11 @@ export function CaptionsView({ id }: { id: string }) {
|
||||||
const req = useAsync(async () => search(), [search]);
|
const req = useAsync(async () => search(), [search]);
|
||||||
|
|
||||||
const [downloadReq, startDownload] = useAsyncFn(
|
const [downloadReq, startDownload] = useAsyncFn(
|
||||||
(subtitleId: string, language: string) => download(subtitleId, language),
|
async (subtitleId: string, language: string) => {
|
||||||
[download]
|
setCurrentlyDownloading(subtitleId);
|
||||||
|
return download(subtitleId, language);
|
||||||
|
},
|
||||||
|
[download, setCurrentlyDownloading]
|
||||||
);
|
);
|
||||||
|
|
||||||
let downloadProgress: ReactNode = null;
|
let downloadProgress: ReactNode = null;
|
||||||
|
@ -78,22 +122,22 @@ export function CaptionsView({ id }: { id: string }) {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let results = subs;
|
content = searchSubs(subs, searchQuery).map((v) => {
|
||||||
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) => {
|
|
||||||
return (
|
return (
|
||||||
<CaptionOption
|
<CaptionOption
|
||||||
key={v.id}
|
key={v.id}
|
||||||
countryCode={v.attributes.language}
|
countryCode={v.attributes.language}
|
||||||
selected={lang === 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={() =>
|
onClick={() =>
|
||||||
startDownload(
|
startDownload(
|
||||||
v.attributes.legacy_subtitle_id,
|
v.attributes.legacy_subtitle_id,
|
||||||
|
|
|
@ -80,18 +80,15 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
||||||
(v) => v.height === qualityToHlsLevel(availableQuality)
|
(v) => v.height === qualityToHlsLevel(availableQuality)
|
||||||
);
|
);
|
||||||
if (levelIndex !== -1) {
|
if (levelIndex !== -1) {
|
||||||
console.log("setting level", levelIndex, availableQuality);
|
|
||||||
hls.currentLevel = levelIndex;
|
hls.currentLevel = levelIndex;
|
||||||
hls.loadLevel = levelIndex;
|
hls.loadLevel = levelIndex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("setting to automatic");
|
|
||||||
hls.currentLevel = -1;
|
hls.currentLevel = -1;
|
||||||
hls.loadLevel = -1;
|
hls.loadLevel = -1;
|
||||||
}
|
}
|
||||||
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
|
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
|
||||||
console.log("updating quality menu", quality);
|
|
||||||
emit("changedquality", quality);
|
emit("changedquality", quality);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -117,7 +114,6 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
||||||
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
|
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
|
||||||
if (!hls) return;
|
if (!hls) return;
|
||||||
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
|
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
|
||||||
console.log("EVENT updating quality menu", quality);
|
|
||||||
emit("changedquality", quality);
|
emit("changedquality", quality);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,26 @@
|
||||||
import { useCallback } from "react";
|
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 { usePlayerStore } from "@/stores/player/store";
|
||||||
import { useSubtitleStore } from "@/stores/subtitles";
|
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() {
|
export function useCaptions() {
|
||||||
const setLanguage = useSubtitleStore((s) => s.setLanguage);
|
const setLanguage = useSubtitleStore((s) => s.setLanguage);
|
||||||
|
@ -13,7 +31,11 @@ export function useCaptions() {
|
||||||
|
|
||||||
const download = useCallback(
|
const download = useCallback(
|
||||||
async (subtitleId: string, language: string) => {
|
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({
|
setCaption({
|
||||||
language,
|
language,
|
||||||
srtData,
|
srtData,
|
||||||
|
@ -26,7 +48,17 @@ export function useCaptions() {
|
||||||
|
|
||||||
const search = useCallback(async () => {
|
const search = useCallback(async () => {
|
||||||
if (!meta) throw new Error("No meta");
|
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]);
|
}, [meta]);
|
||||||
|
|
||||||
const disable = useCallback(async () => {
|
const disable = useCallback(async () => {
|
||||||
|
|
|
@ -2,6 +2,7 @@ import classNames from "classnames";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
import { Spinner } from "@/components/layout/Spinner";
|
||||||
import { Title } from "@/components/player/internals/ContextMenu/Misc";
|
import { Title } from "@/components/player/internals/ContextMenu/Misc";
|
||||||
|
|
||||||
export function Chevron(props: { children?: React.ReactNode }) {
|
export function Chevron(props: { children?: React.ReactNode }) {
|
||||||
|
@ -112,21 +113,34 @@ export function ChevronLink(props: {
|
||||||
|
|
||||||
export function SelectableLink(props: {
|
export function SelectableLink(props: {
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
error?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const rightContent = (
|
let rightContent;
|
||||||
<Icon
|
if (props.selected) {
|
||||||
icon={Icons.CIRCLE_CHECK}
|
rightContent = (
|
||||||
className="text-xl text-video-context-type-accent"
|
<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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
clickable={!props.disabled}
|
clickable={!props.disabled}
|
||||||
rightSide={props.selected ? rightContent : null}
|
rightSide={rightContent}
|
||||||
>
|
>
|
||||||
<LinkTitle
|
<LinkTitle
|
||||||
textClass={classNames({
|
textClass={classNames({
|
||||||
|
|
|
@ -34,17 +34,11 @@ export function useChromecast() {
|
||||||
request.autoplay = true;
|
request.autoplay = true;
|
||||||
|
|
||||||
const session = instance.current?.getCurrentSession();
|
const session = instance.current?.getCurrentSession();
|
||||||
console.log("testing", session);
|
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
session
|
session.loadMedia(request).catch((e: any) => {
|
||||||
.loadMedia(request)
|
console.error(e);
|
||||||
.then(() => {
|
});
|
||||||
console.log("Media is loaded");
|
|
||||||
})
|
|
||||||
.catch((e: any) => {
|
|
||||||
console.error(e);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopCast() {
|
function stopCast() {
|
||||||
|
|
|
@ -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 className="grid grid-cols-[2.5rem,1fr,2.5rem] gap-3 lg:hidden">
|
||||||
<div />
|
<div />
|
||||||
<div className="flex justify-center space-x-3">
|
<div className="flex justify-center space-x-3">
|
||||||
|
<Player.Pip />
|
||||||
<Player.Episodes />
|
<Player.Episodes />
|
||||||
<Player.Settings />
|
<Player.Settings />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncFn } from "react-use";
|
||||||
|
|
||||||
import { searchForMedia } from "@/backend/metadata/search";
|
import { searchForMedia } from "@/backend/metadata/search";
|
||||||
import { MWQuery } from "@/backend/metadata/types/mw";
|
import { MWQuery } from "@/backend/metadata/types/mw";
|
||||||
|
@ -8,7 +9,6 @@ import { Icons } from "@/components/Icon";
|
||||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||||
import { MediaGrid } from "@/components/media/MediaGrid";
|
import { MediaGrid } from "@/components/media/MediaGrid";
|
||||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
|
||||||
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart";
|
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart";
|
||||||
import { MediaItem } from "@/utils/mediaTypes";
|
import { MediaItem } from "@/utils/mediaTypes";
|
||||||
|
|
||||||
|
@ -49,22 +49,20 @@ export function SearchListPart({ searchQuery }: { searchQuery: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [results, setResults] = useState<MediaItem[]>([]);
|
const [results, setResults] = useState<MediaItem[]>([]);
|
||||||
const [runSearchQuery, loading, error] = useLoading((query: MWQuery) =>
|
const [state, exec] = useAsyncFn((query: MWQuery) => searchForMedia(query));
|
||||||
searchForMedia(query)
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function runSearch(query: MWQuery) {
|
async function runSearch(query: MWQuery) {
|
||||||
const searchResults = await runSearchQuery(query);
|
const searchResults = await exec(query);
|
||||||
if (!searchResults) return;
|
if (!searchResults) return;
|
||||||
setResults(searchResults);
|
setResults(searchResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchQuery !== "") runSearch({ searchQuery });
|
if (searchQuery !== "") runSearch({ searchQuery });
|
||||||
}, [searchQuery, runSearchQuery]);
|
}, [searchQuery, exec]);
|
||||||
|
|
||||||
if (loading) return <SearchLoadingPart />;
|
if (state.loading) return <SearchLoadingPart />;
|
||||||
if (error) return <SearchSuffix failed />;
|
if (state.error) return <SearchSuffix failed />;
|
||||||
if (!results) return null;
|
if (!results) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -152,6 +152,7 @@ module.exports = {
|
||||||
cardBorder: "#1B262E",
|
cardBorder: "#1B262E",
|
||||||
slider: "#8787A8",
|
slider: "#8787A8",
|
||||||
sliderFilled: "#A75FC9",
|
sliderFilled: "#A75FC9",
|
||||||
|
error: "#E44F4F",
|
||||||
|
|
||||||
download: {
|
download: {
|
||||||
button: "#6b298a",
|
button: "#6b298a",
|
||||||
|
|
Loading…
Reference in a new issue