diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index fd449cab..ef76d8c3 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -3,6 +3,7 @@ import type { BaseItemKind, } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useAsyncDebouncer } from "@tanstack/react-pacer"; import { useQuery } from "@tanstack/react-query"; import axios from "axios"; import { Image } from "expo-image"; @@ -20,7 +21,6 @@ import { import { useTranslation } from "react-i18next"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useDebounce } from "use-debounce"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; @@ -70,7 +70,23 @@ export default function search() { const [searchType, setSearchType] = useState("Library"); const [search, setSearch] = useState(""); - const [debouncedSearch] = useDebounce(search, 500); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const abortControllerRef = useRef(null); + + const searchDebouncer = useAsyncDebouncer( + async (query: string) => { + // Cancel previous in-flight requests + abortControllerRef.current?.abort(); + abortControllerRef.current = new AbortController(); + setDebouncedSearch(query); + return query; + }, + { wait: 200 }, + ); + + useEffect(() => { + searchDebouncer.maybeExecute(search); + }, [search]); const [api] = useAtom(apiAtom); @@ -100,9 +116,11 @@ export default function search() { async ({ types, query, + signal, }: { types: BaseItemKind[]; query: string; + signal?: AbortSignal; }): Promise => { if (!api || !query) { return []; @@ -110,13 +128,16 @@ export default function search() { try { if (searchEngine === "Jellyfin") { - const searchApi = await getItemsApi(api).getItems({ - searchTerm: query, - limit: 10, - includeItemTypes: types, - recursive: true, - userId: user?.Id, - }); + const searchApi = await getItemsApi(api).getItems( + { + searchTerm: query, + limit: 10, + includeItemTypes: types, + recursive: true, + userId: user?.Id, + }, + { signal }, + ); return (searchApi.data.Items as BaseItemDto[]) || []; } @@ -145,6 +166,7 @@ export default function search() { query, searchType as "movies" | "series" | "episodes" | "actors" | "media", 10, + signal, ); const allIds: string[] = [ @@ -159,10 +181,13 @@ export default function search() { return []; } - const itemsResponse = await getItemsApi(api).getItems({ - ids: allIds, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - }); + const itemsResponse = await getItemsApi(api).getItems( + { + ids: allIds, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }, + { signal }, + ); return (itemsResponse.data.Items as BaseItemDto[]) || []; } @@ -178,7 +203,7 @@ export default function search() { .map((type) => encodeURIComponent(type)) .join("&includeItemTypes=")}`; - const response1 = await axios.get(url); + const response1 = await axios.get(url, { signal }); const ids = response1.data.ids; @@ -186,13 +211,20 @@ export default function search() { return []; } - const response2 = await getItemsApi(api).getItems({ - ids, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - }); + const response2 = await getItemsApi(api).getItems( + { + ids, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }, + { signal }, + ); return (response2.data.Items as BaseItemDto[]) || []; - } catch (_error) { + } catch (error) { + // Silently handle aborted requests + if (error instanceof Error && error.name === "AbortError") { + return []; + } return []; } }, @@ -204,25 +236,34 @@ export default function search() { async ({ types, query, + signal, }: { types: BaseItemKind[]; query: string; + signal?: AbortSignal; }): Promise => { if (!api || !query) { return []; } try { - const searchApi = await getItemsApi(api).getItems({ - searchTerm: query, - limit: 10, - includeItemTypes: types, - recursive: true, - userId: user?.Id, - }); + const searchApi = await getItemsApi(api).getItems( + { + searchTerm: query, + limit: 10, + includeItemTypes: types, + recursive: true, + userId: user?.Id, + }, + { signal }, + ); return (searchApi.data.Items as BaseItemDto[]) || []; - } catch (_error) { + } catch (error) { + // Silently handle aborted requests + if (error instanceof Error && error.name === "AbortError") { + return []; + } return []; } }, @@ -275,6 +316,7 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["Movie"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -285,6 +327,7 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["Series"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -295,6 +338,7 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["Episode"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -305,6 +349,7 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["BoxSet"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -315,6 +360,7 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["Person"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -326,6 +372,7 @@ export default function search() { jellyfinSearchFn({ query: debouncedSearch, types: ["MusicArtist"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -336,6 +383,7 @@ export default function search() { jellyfinSearchFn({ query: debouncedSearch, types: ["MusicAlbum"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -346,6 +394,7 @@ export default function search() { jellyfinSearchFn({ query: debouncedSearch, types: ["Audio"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -356,6 +405,7 @@ export default function search() { jellyfinSearchFn({ query: debouncedSearch, types: ["Playlist"], + signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); diff --git a/bun.lock b/bun.lock index 61d212fb..ecc78ccc 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@react-navigation/native": "^7.0.14", "@shopify/flash-list": "2.0.2", "@tanstack/query-sync-storage-persister": "^5.90.18", + "@tanstack/react-pacer": "^0.19.1", "@tanstack/react-query": "5.90.12", "@tanstack/react-query-persist-client": "^5.90.18", "axios": "^1.7.9", @@ -589,16 +590,26 @@ "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="], + + "@tanstack/pacer": ["@tanstack/pacer@0.17.1", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-52GytGu07L73lNCWB1N02NWBp/tzK2jZ20U8sFInXyiq2KHtHxbXaN1Qw/MR1REqFIKgEy5DOBNZRjuSy5zaRg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="], "@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.91.15", "", { "dependencies": { "@tanstack/query-core": "5.90.16" } }, "sha512-vnPSfQVo41EKJN8v20nkhWNZPyB1dMJIy5icOvCGzcCJzsmRefYY1owtr63ICOcjOiPPTuNEfPsdjdBhkzYnmA=="], "@tanstack/query-sync-storage-persister": ["@tanstack/query-sync-storage-persister@5.90.18", "", { "dependencies": { "@tanstack/query-core": "5.90.16", "@tanstack/query-persist-client-core": "5.91.15" } }, "sha512-tKngFopz/TuAe7LBDg7IOhWPh9blxdQ6QG/vVL2dFzRmlPNcSo4WdCSONqSDioJkcyTwh1YCSlcikmJ1WnSb3Q=="], + "@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="], + "@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="], "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="], + "@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="], + + "@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/package.json b/package.json index b561af1b..202e60dd 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@react-navigation/native": "^7.0.14", "@shopify/flash-list": "2.0.2", "@tanstack/query-sync-storage-persister": "^5.90.18", + "@tanstack/react-pacer": "^0.19.1", "@tanstack/react-query": "5.90.12", "@tanstack/react-query-persist-client": "^5.90.18", "axios": "^1.7.9", diff --git a/utils/streamystats/api.ts b/utils/streamystats/api.ts index 5ec409ff..2ffc2c56 100644 --- a/utils/streamystats/api.ts +++ b/utils/streamystats/api.ts @@ -38,6 +38,7 @@ export const createStreamystatsApi = (config: StreamystatsApiConfig) => { const search = async ( params: StreamystatsSearchParams, + signal?: AbortSignal, ): Promise< StreamystatsSearchIdsResponse | StreamystatsSearchFullResponse > => { @@ -55,7 +56,7 @@ export const createStreamystatsApi = (config: StreamystatsApiConfig) => { } const url = `${baseUrl}/api/search?${queryParams.toString()}`; - const response = await axios.get(url, { headers }); + const response = await axios.get(url, { headers, signal }); return response.data; }; @@ -64,13 +65,17 @@ export const createStreamystatsApi = (config: StreamystatsApiConfig) => { query: string, type?: StreamystatsSearchParams["type"], limit?: number, + signal?: AbortSignal, ): Promise => { - return search({ - q: query, - format: "ids", - type, - limit, - }) as Promise; + return search( + { + q: query, + format: "ids", + type, + limit, + }, + signal, + ) as Promise; }; const searchFull = async (