mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-08 10:01:58 +01:00
fix: resolve merge conflict in useSeerr.ts - keep improved BCP 47 locale logic
This commit is contained in:
86
hooks/useAppRouter.ts
Normal file
86
hooks/useAppRouter.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
|
||||
/**
|
||||
* Drop-in replacement for expo-router's useRouter that automatically
|
||||
* preserves offline state across navigation.
|
||||
*
|
||||
* - For object-form navigation, automatically adds offline=true when in offline context
|
||||
* - For string URLs, passes through unchanged (caller handles offline param)
|
||||
*
|
||||
* @example
|
||||
* import useRouter from "@/hooks/useAppRouter";
|
||||
*
|
||||
* const router = useRouter();
|
||||
* router.push({ pathname: "/items/page", params: { id: item.Id } }); // offline added automatically
|
||||
*/
|
||||
export function useAppRouter() {
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
|
||||
const push = useCallback(
|
||||
(href: Parameters<typeof router.push>[0]) => {
|
||||
if (typeof href === "string") {
|
||||
router.push(href as any);
|
||||
} else {
|
||||
const callerParams = (href.params ?? {}) as Record<string, unknown>;
|
||||
const hasExplicitOffline = "offline" in callerParams;
|
||||
router.push({
|
||||
...href,
|
||||
params: {
|
||||
// Only add offline if caller hasn't explicitly set it
|
||||
...(isOffline && !hasExplicitOffline && { offline: "true" }),
|
||||
...callerParams,
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
},
|
||||
[router, isOffline],
|
||||
);
|
||||
|
||||
const replace = useCallback(
|
||||
(href: Parameters<typeof router.replace>[0]) => {
|
||||
if (typeof href === "string") {
|
||||
router.replace(href as any);
|
||||
} else {
|
||||
const callerParams = (href.params ?? {}) as Record<string, unknown>;
|
||||
const hasExplicitOffline = "offline" in callerParams;
|
||||
router.replace({
|
||||
...href,
|
||||
params: {
|
||||
// Only add offline if caller hasn't explicitly set it
|
||||
...(isOffline && !hasExplicitOffline && { offline: "true" }),
|
||||
...callerParams,
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
},
|
||||
[router, isOffline],
|
||||
);
|
||||
|
||||
const setParams = useCallback(
|
||||
(params: Parameters<typeof router.setParams>[0]) => {
|
||||
const callerParams = (params ?? {}) as Record<string, unknown>;
|
||||
const hasExplicitOffline = "offline" in callerParams;
|
||||
router.setParams({
|
||||
// Only add offline if caller hasn't explicitly set it
|
||||
...(isOffline && !hasExplicitOffline && { offline: "true" }),
|
||||
...callerParams,
|
||||
});
|
||||
},
|
||||
[router, isOffline],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
...router,
|
||||
push,
|
||||
replace,
|
||||
setParams,
|
||||
}),
|
||||
[router, push, replace, setParams],
|
||||
);
|
||||
}
|
||||
|
||||
export default useAppRouter;
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
|
||||
export const useControlsVisibility = (timeout = 3000) => {
|
||||
const opacity = useSharedValue(1);
|
||||
|
||||
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const showControls = useCallback(() => {
|
||||
opacity.value = 1;
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
hideControlsTimerRef.current = setTimeout(() => {
|
||||
opacity.value = 0;
|
||||
}, timeout);
|
||||
}, [timeout]);
|
||||
|
||||
const hideControls = useCallback(() => {
|
||||
opacity.value = 0;
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { opacity, showControls, hideControls };
|
||||
};
|
||||
@@ -5,29 +5,32 @@ import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
/**
|
||||
* Custom hook to handle skipping credits in a media player.
|
||||
* The player reports time values in milliseconds.
|
||||
*/
|
||||
export const useCreditSkipper = (
|
||||
itemId: string,
|
||||
currentTime: number,
|
||||
seek: (time: number) => void,
|
||||
seek: (ms: number) => void,
|
||||
play: () => void,
|
||||
isVlc = false,
|
||||
isOffline = false,
|
||||
api: Api | null = null,
|
||||
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||
totalDuration?: number,
|
||||
) => {
|
||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
if (isVlc) {
|
||||
currentTime = msToSeconds(currentTime);
|
||||
}
|
||||
// Convert ms to seconds for comparison with timestamps
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
|
||||
const totalDurationInSeconds =
|
||||
totalDuration != null ? msToSeconds(totalDuration) : undefined;
|
||||
|
||||
// Regular function (not useCallback) to match useIntroSkipper pattern
|
||||
const wrappedSeek = (seconds: number) => {
|
||||
if (isVlc) {
|
||||
seek(secondsToMs(seconds));
|
||||
return;
|
||||
}
|
||||
seek(seconds);
|
||||
seek(secondsToMs(seconds));
|
||||
};
|
||||
|
||||
const { data: segments } = useSegments(
|
||||
@@ -38,27 +41,69 @@ export const useCreditSkipper = (
|
||||
);
|
||||
const creditTimestamps = segments?.creditSegments?.[0];
|
||||
|
||||
// Determine if there's content after credits (credits don't extend to video end)
|
||||
// Use a 5-second buffer to account for timing discrepancies
|
||||
const hasContentAfterCredits = (() => {
|
||||
if (
|
||||
!creditTimestamps ||
|
||||
totalDurationInSeconds == null ||
|
||||
!Number.isFinite(totalDurationInSeconds)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const creditsEndToVideoEnd =
|
||||
totalDurationInSeconds - creditTimestamps.endTime;
|
||||
// If credits end more than 5 seconds before video ends, there's content after
|
||||
return creditsEndToVideoEnd > 5;
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (creditTimestamps) {
|
||||
setShowSkipCreditButton(
|
||||
currentTime > creditTimestamps.startTime &&
|
||||
currentTime < creditTimestamps.endTime,
|
||||
);
|
||||
const shouldShow =
|
||||
currentTimeSeconds > creditTimestamps.startTime &&
|
||||
currentTimeSeconds < creditTimestamps.endTime;
|
||||
|
||||
setShowSkipCreditButton(shouldShow);
|
||||
} else {
|
||||
// Reset button state when no credit timestamps exist
|
||||
if (showSkipCreditButton) {
|
||||
setShowSkipCreditButton(false);
|
||||
}
|
||||
}
|
||||
}, [creditTimestamps, currentTime]);
|
||||
}, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]);
|
||||
|
||||
const skipCredit = useCallback(() => {
|
||||
if (!creditTimestamps) return;
|
||||
|
||||
try {
|
||||
lightHapticFeedback();
|
||||
wrappedSeek(creditTimestamps.endTime);
|
||||
|
||||
// Calculate the target seek position
|
||||
let seekTarget = creditTimestamps.endTime;
|
||||
|
||||
// If we have total duration, ensure we don't seek past the end of the video.
|
||||
// Some media sources report credit end times that exceed the actual video duration,
|
||||
// which causes the player to pause/stop when seeking past the end.
|
||||
// Leave a small buffer (2 seconds) to trigger the natural end-of-video flow
|
||||
// (next episode countdown, etc.) instead of an abrupt pause.
|
||||
if (totalDurationInSeconds && seekTarget >= totalDurationInSeconds) {
|
||||
seekTarget = Math.max(0, totalDurationInSeconds - 2);
|
||||
}
|
||||
|
||||
wrappedSeek(seekTarget);
|
||||
setTimeout(() => {
|
||||
play();
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
console.error("Error skipping credit", error);
|
||||
console.error("[CREDIT_SKIPPER] Error skipping credit", error);
|
||||
}
|
||||
}, [creditTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
||||
}, [
|
||||
creditTimestamps,
|
||||
lightHapticFeedback,
|
||||
wrappedSeek,
|
||||
play,
|
||||
totalDurationInSeconds,
|
||||
]);
|
||||
|
||||
return { showSkipCreditButton, skipCredit };
|
||||
return { showSkipCreditButton, skipCredit, hasContentAfterCredits };
|
||||
};
|
||||
|
||||
@@ -1,51 +1,23 @@
|
||||
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
|
||||
// Used only for initial play settings.
|
||||
const useDefaultPlaySettings = (
|
||||
item: BaseItemDto,
|
||||
settings: Settings | null,
|
||||
) => {
|
||||
const playSettings = useMemo(() => {
|
||||
// 1. Get first media source
|
||||
const mediaSource = item.MediaSources?.[0];
|
||||
|
||||
// 2. Get default or preferred audio
|
||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) =>
|
||||
x.Type === "Audio" &&
|
||||
x.Language ===
|
||||
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName,
|
||||
)?.Index;
|
||||
|
||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Type === "Audio",
|
||||
)?.Index;
|
||||
|
||||
// 4. Get default bitrate from settings or fallback to max
|
||||
let bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
||||
// value undefined seems to get lost in settings. This is just a failsafe
|
||||
if (bitrate.key === BITRATES[0].key) {
|
||||
bitrate = BITRATES[0];
|
||||
}
|
||||
/**
|
||||
* React hook wrapper for getDefaultPlaySettings.
|
||||
* Used in UI components for initial playback (no previous track state).
|
||||
*/
|
||||
const useDefaultPlaySettings = (item: BaseItemDto, settings: Settings | null) =>
|
||||
useMemo(() => {
|
||||
const { mediaSource, audioIndex, subtitleIndex, bitrate } =
|
||||
getDefaultPlaySettings(item, settings);
|
||||
|
||||
return {
|
||||
defaultAudioIndex:
|
||||
preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex ?? undefined,
|
||||
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
||||
defaultMediaSource: mediaSource ?? undefined,
|
||||
defaultBitrate: bitrate ?? undefined,
|
||||
defaultMediaSource: mediaSource,
|
||||
defaultAudioIndex: audioIndex,
|
||||
defaultSubtitleIndex: subtitleIndex,
|
||||
defaultBitrate: bitrate,
|
||||
};
|
||||
}, [
|
||||
item.MediaSources,
|
||||
settings?.defaultAudioLanguage,
|
||||
settings?.defaultSubtitleLanguage,
|
||||
]);
|
||||
|
||||
return playSettings;
|
||||
};
|
||||
}, [item, settings]);
|
||||
|
||||
export default useDefaultPlaySettings;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export const useDownloadedFileOpener = () => {
|
||||
const router = useRouter();
|
||||
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||
|
||||
const openFile = useCallback(
|
||||
async (item: BaseItemDto) => {
|
||||
if (!item.Id) {
|
||||
writeToLog("ERROR", "Attempted to open a file without an ID.");
|
||||
console.error("Attempted to open a file without an ID.");
|
||||
return;
|
||||
}
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
try {
|
||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error opening file", error);
|
||||
console.error("Error opening file:", error);
|
||||
}
|
||||
},
|
||||
[setOfflineSettings, setPlayUrl, router],
|
||||
);
|
||||
|
||||
return { openFile };
|
||||
};
|
||||
@@ -1,109 +1,140 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Shared atom to store favorite status across all components
|
||||
// Maps itemId -> isFavorite
|
||||
const favoritesAtom = atom<Record<string, boolean>>({});
|
||||
|
||||
export const useFavorite = (item: BaseItemDto) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const type = "item";
|
||||
const [isFavorite, setIsFavorite] = useState(item.UserData?.IsFavorite);
|
||||
const [favorites, setFavorites] = useAtom(favoritesAtom);
|
||||
|
||||
const itemId = item.Id ?? "";
|
||||
|
||||
// Get current favorite status from shared state, falling back to item data
|
||||
const isFavorite = itemId
|
||||
? (favorites[itemId] ?? item.UserData?.IsFavorite)
|
||||
: item.UserData?.IsFavorite;
|
||||
|
||||
// Update shared state when item data changes
|
||||
useEffect(() => {
|
||||
if (itemId && item.UserData?.IsFavorite !== undefined) {
|
||||
setFavorites((prev) => ({
|
||||
...prev,
|
||||
[itemId]: item.UserData!.IsFavorite!,
|
||||
}));
|
||||
}
|
||||
}, [itemId, item.UserData?.IsFavorite, setFavorites]);
|
||||
|
||||
// Helper to update favorite status in shared state
|
||||
const setIsFavorite = useCallback(
|
||||
(value: boolean | undefined) => {
|
||||
if (itemId && value !== undefined) {
|
||||
setFavorites((prev) => ({ ...prev, [itemId]: value }));
|
||||
}
|
||||
},
|
||||
[itemId, setFavorites],
|
||||
);
|
||||
|
||||
// Use refs to avoid stale closure issues in mutationFn
|
||||
const itemRef = useRef(item);
|
||||
const apiRef = useRef(api);
|
||||
const userRef = useRef(user);
|
||||
|
||||
// Keep refs updated
|
||||
useEffect(() => {
|
||||
itemRef.current = item;
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFavorite(item.UserData?.IsFavorite);
|
||||
}, [item.UserData?.IsFavorite]);
|
||||
apiRef.current = api;
|
||||
}, [api]);
|
||||
|
||||
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
|
||||
queryClient.setQueryData<BaseItemDto | undefined>(
|
||||
[type, item.Id],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
...newData,
|
||||
UserData: { ...old.UserData, ...newData.UserData },
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
useEffect(() => {
|
||||
userRef.current = user;
|
||||
}, [user]);
|
||||
|
||||
const markFavoriteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (api && user) {
|
||||
await getUserLibraryApi(api).markFavoriteItem({
|
||||
userId: user.Id,
|
||||
itemId: item.Id!,
|
||||
});
|
||||
}
|
||||
const itemQueryKeyPrefix = useMemo(
|
||||
() => ["item", item.Id] as const,
|
||||
[item.Id],
|
||||
);
|
||||
|
||||
const updateItemInQueries = useCallback(
|
||||
(newData: Partial<BaseItemDto>) => {
|
||||
queryClient.setQueriesData<BaseItemDto | null | undefined>(
|
||||
{ queryKey: itemQueryKeyPrefix },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
...newData,
|
||||
UserData: { ...old.UserData, ...newData.UserData },
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
||||
type,
|
||||
item.Id,
|
||||
]);
|
||||
updateItemInQueries({ UserData: { IsFavorite: true } });
|
||||
[itemQueryKeyPrefix, queryClient],
|
||||
);
|
||||
|
||||
return { previousItem };
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousItem) {
|
||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
||||
const favoriteMutation = useMutation({
|
||||
mutationFn: async (nextIsFavorite: boolean) => {
|
||||
const currentApi = apiRef.current;
|
||||
const currentUser = userRef.current;
|
||||
const currentItem = itemRef.current;
|
||||
|
||||
if (!currentApi || !currentUser?.Id || !currentItem?.Id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the same endpoint format as the web client:
|
||||
// POST /Users/{userId}/FavoriteItems/{itemId} - add favorite
|
||||
// DELETE /Users/{userId}/FavoriteItems/{itemId} - remove favorite
|
||||
const path = `/Users/${currentUser.Id}/FavoriteItems/${currentItem.Id}`;
|
||||
|
||||
const response = nextIsFavorite
|
||||
? await currentApi.post(path, {}, {})
|
||||
: await currentApi.delete(path, {});
|
||||
return response.data;
|
||||
},
|
||||
onMutate: async (nextIsFavorite: boolean) => {
|
||||
await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix });
|
||||
|
||||
const previousIsFavorite = isFavorite;
|
||||
const previousQueries = queryClient.getQueriesData<BaseItemDto | null>({
|
||||
queryKey: itemQueryKeyPrefix,
|
||||
});
|
||||
|
||||
setIsFavorite(nextIsFavorite);
|
||||
updateItemInQueries({ UserData: { IsFavorite: nextIsFavorite } });
|
||||
|
||||
return { previousIsFavorite, previousQueries };
|
||||
},
|
||||
onError: (_err, _nextIsFavorite, context) => {
|
||||
if (context?.previousQueries) {
|
||||
for (const [queryKey, data] of context.previousQueries) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
setIsFavorite(context?.previousIsFavorite);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
||||
queryClient.invalidateQueries({ queryKey: itemQueryKeyPrefix });
|
||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
||||
setIsFavorite(true);
|
||||
},
|
||||
});
|
||||
|
||||
const unmarkFavoriteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (api && user) {
|
||||
await getUserLibraryApi(api).unmarkFavoriteItem({
|
||||
userId: user.Id,
|
||||
itemId: item.Id!,
|
||||
});
|
||||
}
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
||||
type,
|
||||
item.Id,
|
||||
]);
|
||||
updateItemInQueries({ UserData: { IsFavorite: false } });
|
||||
|
||||
return { previousItem };
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousItem) {
|
||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
||||
setIsFavorite(false);
|
||||
},
|
||||
});
|
||||
|
||||
const toggleFavorite = () => {
|
||||
if (isFavorite) {
|
||||
unmarkFavoriteMutation.mutate();
|
||||
} else {
|
||||
markFavoriteMutation.mutate();
|
||||
}
|
||||
};
|
||||
const toggleFavorite = useCallback(() => {
|
||||
favoriteMutation.mutate(!isFavorite);
|
||||
}, [favoriteMutation, isFavorite]);
|
||||
|
||||
return {
|
||||
isFavorite,
|
||||
toggleFavorite,
|
||||
markFavoriteMutation,
|
||||
unmarkFavoriteMutation,
|
||||
favoriteMutation,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type * as ImageColorsType from "react-native-image-colors";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Conditionally import react-native-image-colors only on non-TV platforms
|
||||
const ImageColors = Platform.isTV
|
||||
? null
|
||||
: (require("react-native-image-colors") as typeof ImageColorsType);
|
||||
|
||||
import {
|
||||
adjustToNearBlack,
|
||||
calculateTextColor,
|
||||
isCloseToBlack,
|
||||
itemThemeColorAtom,
|
||||
} from "@/utils/atoms/primaryColor";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
/**
|
||||
* Custom hook to extract and manage image colors for a given item.
|
||||
*
|
||||
* @param item - The BaseItemDto object representing the item.
|
||||
* @param disabled - A boolean flag to disable color extraction.
|
||||
*
|
||||
*/
|
||||
export const useImageColors = ({
|
||||
item,
|
||||
url,
|
||||
disabled,
|
||||
}: {
|
||||
item?: BaseItemDto | null;
|
||||
url?: string | null;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
const source = useMemo(() => {
|
||||
if (!api) return;
|
||||
if (url) return { uri: url };
|
||||
if (item)
|
||||
return getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 80,
|
||||
width: 300,
|
||||
});
|
||||
return null;
|
||||
}, [api, item, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTv) return;
|
||||
if (disabled) return;
|
||||
if (source?.uri) {
|
||||
const _primary = storage.getString(`${source.uri}-primary`);
|
||||
const _text = storage.getString(`${source.uri}-text`);
|
||||
|
||||
if (_primary && _text) {
|
||||
setPrimaryColor({
|
||||
primary: _primary,
|
||||
text: _text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract colors from the image
|
||||
if (!ImageColors?.getColors) return;
|
||||
|
||||
ImageColors.getColors(source.uri, {
|
||||
fallback: "#fff",
|
||||
cache: false,
|
||||
})
|
||||
.then((colors: ImageColorsType.ImageColorsResult) => {
|
||||
let primary = "#fff";
|
||||
let text = "#000";
|
||||
let backup = "#fff";
|
||||
|
||||
// Select the appropriate color based on the platform
|
||||
if (colors.platform === "android") {
|
||||
primary = colors.dominant;
|
||||
backup = colors.vibrant;
|
||||
} else if (colors.platform === "ios") {
|
||||
primary = colors.detail;
|
||||
backup = colors.primary;
|
||||
}
|
||||
|
||||
// Adjust the primary color if it's too close to black
|
||||
if (primary && isCloseToBlack(primary)) {
|
||||
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||
primary = adjustToNearBlack(primary);
|
||||
}
|
||||
|
||||
// Calculate the text color based on the primary color
|
||||
if (primary) text = calculateTextColor(primary);
|
||||
|
||||
setPrimaryColor({
|
||||
primary,
|
||||
text,
|
||||
});
|
||||
|
||||
// Cache the colors in storage
|
||||
if (source.uri && primary) {
|
||||
storage.set(`${source.uri}-primary`, primary);
|
||||
storage.set(`${source.uri}-text`, text);
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("Error getting colors", error);
|
||||
});
|
||||
}
|
||||
}, [isTv, source?.uri, setPrimaryColor, disabled]);
|
||||
|
||||
if (isTv) return;
|
||||
};
|
||||
@@ -7,31 +7,26 @@ import { useHaptic } from "./useHaptic";
|
||||
|
||||
/**
|
||||
* Custom hook to handle skipping intros in a media player.
|
||||
* MPV player uses milliseconds for time.
|
||||
*
|
||||
* @param {number} currentTime - The current playback time in seconds.
|
||||
* @param {number} currentTime - The current playback time in milliseconds.
|
||||
*/
|
||||
export const useIntroSkipper = (
|
||||
itemId: string,
|
||||
currentTime: number,
|
||||
seek: (ticks: number) => void,
|
||||
seek: (ms: number) => void,
|
||||
play: () => void,
|
||||
isVlc = false,
|
||||
isOffline = false,
|
||||
api: Api | null = null,
|
||||
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||
) => {
|
||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||
if (isVlc) {
|
||||
currentTime = msToSeconds(currentTime);
|
||||
}
|
||||
// Convert ms to seconds for comparison with timestamps
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const wrappedSeek = (seconds: number) => {
|
||||
if (isVlc) {
|
||||
seek(secondsToMs(seconds));
|
||||
return;
|
||||
}
|
||||
seek(seconds);
|
||||
seek(secondsToMs(seconds));
|
||||
};
|
||||
|
||||
const { data: segments } = useSegments(
|
||||
@@ -45,8 +40,8 @@ export const useIntroSkipper = (
|
||||
useEffect(() => {
|
||||
if (introTimestamps) {
|
||||
const shouldShow =
|
||||
currentTime > introTimestamps.startTime &&
|
||||
currentTime < introTimestamps.endTime;
|
||||
currentTimeSeconds > introTimestamps.startTime &&
|
||||
currentTimeSeconds < introTimestamps.endTime;
|
||||
|
||||
setShowSkipButton(shouldShow);
|
||||
} else {
|
||||
@@ -54,7 +49,7 @@ export const useIntroSkipper = (
|
||||
setShowSkipButton(false);
|
||||
}
|
||||
}
|
||||
}, [introTimestamps, currentTime, showSkipButton]);
|
||||
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
|
||||
|
||||
const skipIntro = useCallback(() => {
|
||||
if (!introTimestamps) return;
|
||||
|
||||
37
hooks/useItemPeopleQuery.ts
Normal file
37
hooks/useItemPeopleQuery.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {
|
||||
BaseItemPerson,
|
||||
ItemFields,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export const useItemPeopleQuery = (
|
||||
itemId: string | undefined,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
return useQuery<BaseItemPerson[]>({
|
||||
queryKey: ["item", itemId, "people"],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !itemId) return [];
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
ids: [itemId],
|
||||
userId: user.Id,
|
||||
fields: ["People" satisfies ItemFields],
|
||||
});
|
||||
|
||||
const people = response.data.Items?.[0]?.People;
|
||||
return Array.isArray(people) ? people : [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!itemId && enabled,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
@@ -1,25 +1,77 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
import { usePlaybackManager } from "./usePlaybackManager";
|
||||
import { useInvalidatePlaybackProgressCache } from "./useRevalidatePlaybackProgressCache";
|
||||
|
||||
export const useMarkAsPlayed = (items: BaseItemDto[]) => {
|
||||
const queryClient = useQueryClient();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const { markItemPlayed, markItemUnplayed } = usePlaybackManager();
|
||||
const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const toggle = async (played: boolean) => {
|
||||
lightHapticFeedback();
|
||||
// Process all items
|
||||
await Promise.all(
|
||||
items.map((item) => {
|
||||
if (!item.Id) return Promise.resolve();
|
||||
return played ? markItemPlayed(item.Id) : markItemUnplayed(item.Id);
|
||||
}),
|
||||
);
|
||||
const toggle = useCallback(
|
||||
async (played: boolean) => {
|
||||
lightHapticFeedback();
|
||||
|
||||
await invalidatePlaybackProgressCache();
|
||||
};
|
||||
const itemIds = items.map((item) => item.Id).filter(Boolean) as string[];
|
||||
|
||||
const previousQueriesByItemId = itemIds.map((itemId) => ({
|
||||
itemId,
|
||||
queries: queryClient.getQueriesData<BaseItemDto | null>({
|
||||
queryKey: ["item", itemId],
|
||||
}),
|
||||
}));
|
||||
|
||||
for (const itemId of itemIds) {
|
||||
queryClient.setQueriesData<BaseItemDto | null | undefined>(
|
||||
{ queryKey: ["item", itemId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
UserData: {
|
||||
...old.UserData,
|
||||
Played: played,
|
||||
PlaybackPositionTicks: 0,
|
||||
PlayedPercentage: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Process all items
|
||||
try {
|
||||
await Promise.all(
|
||||
items.map((item) => {
|
||||
if (!item.Id) return Promise.resolve();
|
||||
return played ? markItemPlayed(item.Id) : markItemUnplayed(item.Id);
|
||||
}),
|
||||
);
|
||||
} catch (_error) {
|
||||
for (const { queries } of previousQueriesByItemId) {
|
||||
for (const [queryKey, data] of queries) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await invalidatePlaybackProgressCache();
|
||||
for (const itemId of itemIds) {
|
||||
queryClient.invalidateQueries({ queryKey: ["item", itemId] });
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
invalidatePlaybackProgressCache,
|
||||
items,
|
||||
lightHapticFeedback,
|
||||
markItemPlayed,
|
||||
markItemUnplayed,
|
||||
queryClient,
|
||||
],
|
||||
);
|
||||
|
||||
return toggle;
|
||||
};
|
||||
|
||||
161
hooks/useMusicCast.ts
Normal file
161
hooks/useMusicCast.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useCallback } from "react";
|
||||
import CastContext, {
|
||||
CastState,
|
||||
MediaStreamType,
|
||||
PlayServicesState,
|
||||
useCastState,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { getAudioContentType } from "@/utils/jellyfin/audio/getAudioContentType";
|
||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||
|
||||
interface UseMusicCastOptions {
|
||||
api: Api | null;
|
||||
userId: string | undefined;
|
||||
}
|
||||
|
||||
interface CastQueueOptions {
|
||||
queue: BaseItemDto[];
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for casting music to Chromecast with full queue support
|
||||
*/
|
||||
export const useMusicCast = ({ api, userId }: UseMusicCastOptions) => {
|
||||
const client = useRemoteMediaClient();
|
||||
const castState = useCastState();
|
||||
|
||||
const isConnected = castState === CastState.CONNECTED;
|
||||
|
||||
/**
|
||||
* Get album art URL for a track
|
||||
*/
|
||||
const getAlbumArtUrl = useCallback(
|
||||
(track: BaseItemDto): string | undefined => {
|
||||
if (!api) return undefined;
|
||||
const albumId = track.AlbumId || track.ParentId;
|
||||
if (albumId) {
|
||||
return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`;
|
||||
}
|
||||
return `${api.basePath}/Items/${track.Id}/Images/Primary?maxHeight=600&maxWidth=600`;
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
/**
|
||||
* Cast a queue of tracks to Chromecast
|
||||
* Uses native queue support for seamless track transitions
|
||||
*/
|
||||
const castQueue = useCallback(
|
||||
async ({ queue, startIndex }: CastQueueOptions): Promise<boolean> => {
|
||||
if (!client || !api || !userId) {
|
||||
console.warn("Cannot cast: missing client, api, or userId");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check Play Services state (Android)
|
||||
const state = await CastContext.getPlayServicesState();
|
||||
if (state && state !== PlayServicesState.SUCCESS) {
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build queue items - limit to 100 tracks due to Cast SDK message size limit
|
||||
const queueToSend = queue.slice(0, 100);
|
||||
const queueItems = await Promise.all(
|
||||
queueToSend.map(async (track) => {
|
||||
const streamResult = await getAudioStreamUrl(
|
||||
api,
|
||||
userId,
|
||||
track.Id!,
|
||||
);
|
||||
if (!streamResult) {
|
||||
throw new Error(
|
||||
`Failed to get stream URL for track: ${track.Name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = getAudioContentType(
|
||||
streamResult.mediaSource?.Container,
|
||||
);
|
||||
|
||||
// Calculate stream duration in seconds from runtime ticks
|
||||
const streamDurationSeconds = track.RunTimeTicks
|
||||
? track.RunTimeTicks / 10000000
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
mediaInfo: {
|
||||
contentId: track.Id,
|
||||
contentUrl: streamResult.url,
|
||||
contentType,
|
||||
streamType: MediaStreamType.BUFFERED,
|
||||
streamDuration: streamDurationSeconds,
|
||||
metadata: {
|
||||
type: "musicTrack" as const,
|
||||
title: track.Name || "Unknown Track",
|
||||
artist: track.AlbumArtist || track.Artists?.join(", ") || "",
|
||||
albumName: track.Album || "",
|
||||
images: getAlbumArtUrl(track)
|
||||
? [{ url: getAlbumArtUrl(track)! }]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
autoplay: true,
|
||||
preloadTime: 10, // Preload 10 seconds before track ends
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Load media with queue
|
||||
await client.loadMedia({
|
||||
queueData: {
|
||||
items: queueItems,
|
||||
startIndex: Math.min(startIndex, queueItems.length - 1),
|
||||
},
|
||||
});
|
||||
|
||||
// Show expanded controls
|
||||
CastContext.showExpandedControls();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to cast music queue:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[client, api, userId, getAlbumArtUrl],
|
||||
);
|
||||
|
||||
/**
|
||||
* Cast a single track to Chromecast
|
||||
*/
|
||||
const castTrack = useCallback(
|
||||
async (track: BaseItemDto): Promise<boolean> => {
|
||||
return castQueue({ queue: [track], startIndex: 0 });
|
||||
},
|
||||
[castQueue],
|
||||
);
|
||||
|
||||
/**
|
||||
* Stop casting and disconnect
|
||||
*/
|
||||
const stopCasting = useCallback(async () => {
|
||||
if (client) {
|
||||
await client.stop();
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
return {
|
||||
client,
|
||||
isConnected,
|
||||
castState,
|
||||
castQueue,
|
||||
castTrack,
|
||||
stopCasting,
|
||||
};
|
||||
};
|
||||
61
hooks/useNetworkAwareQueryClient.ts
Normal file
61
hooks/useNetworkAwareQueryClient.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
InvalidateQueryFilters,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
} from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { invalidateQueriesWhenOnline } from "@/utils/query/networkAwareInvalidate";
|
||||
|
||||
type NetworkAwareQueryClient = QueryClient & {
|
||||
forceInvalidateQueries: QueryClient["invalidateQueries"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a queryClient wrapper with network-aware invalidation.
|
||||
* Use this instead of useQueryClient when you need to invalidate queries.
|
||||
*
|
||||
* - invalidateQueries: Only invalidates when online (preserves offline cache)
|
||||
* - forceInvalidateQueries: Always invalidates (use sparingly)
|
||||
*/
|
||||
export function useNetworkAwareQueryClient(): NetworkAwareQueryClient {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const networkAwareInvalidate = useCallback(
|
||||
<TTaggedQueryKey extends QueryKey = QueryKey>(
|
||||
filters?: InvalidateQueryFilters<TTaggedQueryKey>,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<void> => {
|
||||
if (!filters) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return invalidateQueriesWhenOnline(queryClient, filters, options);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
// Use a Proxy to wrap the queryClient and override invalidateQueries.
|
||||
// Object.create doesn't work because QueryClient uses private fields (#)
|
||||
// which can only be accessed on the exact instance they were defined on.
|
||||
const forceInvalidate = queryClient.invalidateQueries.bind(queryClient);
|
||||
|
||||
return new Proxy(queryClient, {
|
||||
get(target, prop) {
|
||||
if (prop === "invalidateQueries") {
|
||||
return networkAwareInvalidate;
|
||||
}
|
||||
if (prop === "forceInvalidateQueries") {
|
||||
return forceInvalidate;
|
||||
}
|
||||
const value = Reflect.get(target, prop, target);
|
||||
// Bind methods to the original target to preserve private field access
|
||||
if (typeof value === "function") {
|
||||
return value.bind(target);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
}) as NetworkAwareQueryClient;
|
||||
}, [queryClient, networkAwareInvalidate]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OrientationChangeEvent } from "expo-screen-orientation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
addOrientationChangeListener,
|
||||
@@ -21,6 +21,8 @@ const orientationToOrientationLock = (
|
||||
return OrientationLock.LANDSCAPE_RIGHT;
|
||||
case Orientation.PORTRAIT_UP:
|
||||
return OrientationLock.PORTRAIT_UP;
|
||||
case Orientation.UNKNOWN:
|
||||
return OrientationLock.LANDSCAPE;
|
||||
default:
|
||||
return OrientationLock.PORTRAIT_UP;
|
||||
}
|
||||
@@ -53,27 +55,28 @@ export const useOrientation = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const lockOrientation = async (
|
||||
lock: (typeof OrientationLock)[keyof typeof OrientationLock],
|
||||
) => {
|
||||
if (Platform.isTV) return;
|
||||
const lockOrientation = useCallback(
|
||||
async (lock: (typeof OrientationLock)[keyof typeof OrientationLock]) => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
if (lock === OrientationLock.DEFAULT) {
|
||||
await unlockAsync();
|
||||
} else {
|
||||
await lockAsync(lock);
|
||||
}
|
||||
};
|
||||
if (lock === OrientationLock.DEFAULT) {
|
||||
await unlockAsync();
|
||||
} else {
|
||||
await lockAsync(lock);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unlockOrientationFn = async () => {
|
||||
const unlockOrientation = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
await unlockAsync();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
orientation,
|
||||
setOrientation,
|
||||
lockOrientation,
|
||||
unlockOrientation: unlockOrientationFn,
|
||||
unlockOrientation,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
PlaybackProgressInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
@@ -69,6 +69,7 @@ export const usePlaybackManager = ({
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { isConnected } = useNetworkStatus();
|
||||
const queryClient = useQueryClient();
|
||||
const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } =
|
||||
useDownload();
|
||||
|
||||
@@ -186,6 +187,9 @@ export const usePlaybackManager = ({
|
||||
},
|
||||
},
|
||||
});
|
||||
// Force invalidate queries so they refetch from updated local database
|
||||
queryClient.invalidateQueries({ queryKey: ["item", itemId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["episodes"] });
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
@@ -226,6 +230,9 @@ export const usePlaybackManager = ({
|
||||
},
|
||||
},
|
||||
});
|
||||
// Force invalidate queries so they refetch from updated local database
|
||||
queryClient.invalidateQueries({ queryKey: ["item", itemId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["episodes"] });
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
@@ -237,6 +244,7 @@ export const usePlaybackManager = ({
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to mark item as played on server", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -267,6 +275,9 @@ export const usePlaybackManager = ({
|
||||
},
|
||||
},
|
||||
});
|
||||
// Force invalidate queries so they refetch from updated local database
|
||||
queryClient.invalidateQueries({ queryKey: ["item", itemId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["episodes"] });
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
@@ -278,6 +289,7 @@ export const usePlaybackManager = ({
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to mark item as unplayed on server", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
45
hooks/usePlaybackSpeed.ts
Normal file
45
hooks/usePlaybackSpeed.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
|
||||
/**
|
||||
* Determines the appropriate playback speed for a media item based on a three-tier priority system:
|
||||
* 1. Media-specific speed (highest priority)
|
||||
* 2. Series-specific speed (medium priority)
|
||||
* 3. Default speed (lowest priority)
|
||||
*/
|
||||
const usePlaybackSpeed = (
|
||||
item: BaseItemDto | null,
|
||||
settings: Settings | null,
|
||||
): { readonly playbackSpeed: number } => {
|
||||
const playbackSpeed = useMemo(() => {
|
||||
if (!settings || !item) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Start with the lowest priority: default playback speed
|
||||
let selectedPlaybackSpeed = settings.defaultPlaybackSpeed;
|
||||
|
||||
// Second priority: use what is set for Series if it is a Series
|
||||
if (item.SeriesId && settings.playbackSpeedPerShow[item.SeriesId]) {
|
||||
selectedPlaybackSpeed = settings.playbackSpeedPerShow[item.SeriesId];
|
||||
}
|
||||
|
||||
// Highest priority: use what is set for Media if it is set
|
||||
if (item.Id && settings.playbackSpeedPerMedia[item.Id] !== undefined) {
|
||||
selectedPlaybackSpeed = settings.playbackSpeedPerMedia[item.Id];
|
||||
}
|
||||
|
||||
return selectedPlaybackSpeed;
|
||||
}, [
|
||||
item?.Id,
|
||||
item?.SeriesId,
|
||||
settings?.defaultPlaybackSpeed,
|
||||
settings?.playbackSpeedPerMedia,
|
||||
settings?.playbackSpeedPerShow,
|
||||
]);
|
||||
|
||||
return { playbackSpeed };
|
||||
};
|
||||
|
||||
export default usePlaybackSpeed;
|
||||
194
hooks/usePlaylistMutations.ts
Normal file
194
hooks/usePlaylistMutations.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { getLibraryApi, getPlaylistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner-native";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
/**
|
||||
* Hook to create a new playlist
|
||||
*/
|
||||
export const useCreatePlaylist = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
name,
|
||||
trackIds,
|
||||
}: {
|
||||
name: string;
|
||||
trackIds?: string[];
|
||||
}): Promise<string | undefined> => {
|
||||
if (!api || !user?.Id) {
|
||||
throw new Error("API not configured");
|
||||
}
|
||||
|
||||
const response = await getPlaylistsApi(api).createPlaylist({
|
||||
createPlaylistDto: {
|
||||
Name: name,
|
||||
Ids: trackIds,
|
||||
UserId: user.Id,
|
||||
MediaType: "Audio",
|
||||
},
|
||||
});
|
||||
|
||||
return response.data.Id;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlists"],
|
||||
refetchType: "all",
|
||||
});
|
||||
toast.success(t("music.playlists.created"));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || t("music.playlists.failed_to_create"));
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to add a track to a playlist
|
||||
*/
|
||||
export const useAddToPlaylist = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
playlistId,
|
||||
trackIds,
|
||||
}: {
|
||||
playlistId: string;
|
||||
trackIds: string[];
|
||||
playlistName?: string;
|
||||
}): Promise<void> => {
|
||||
if (!api || !user?.Id) {
|
||||
throw new Error("API not configured");
|
||||
}
|
||||
|
||||
await getPlaylistsApi(api).addItemToPlaylist({
|
||||
playlistId,
|
||||
ids: trackIds,
|
||||
userId: user.Id,
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlists"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlist", variables.playlistId],
|
||||
});
|
||||
if (variables.playlistName) {
|
||||
toast.success(
|
||||
t("music.playlists.added_to", { name: variables.playlistName }),
|
||||
);
|
||||
} else {
|
||||
toast.success(t("music.playlists.added"));
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || t("music.playlists.failed_to_add"));
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to remove a track from a playlist
|
||||
*/
|
||||
export const useRemoveFromPlaylist = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
playlistId,
|
||||
entryIds,
|
||||
}: {
|
||||
playlistId: string;
|
||||
entryIds: string[];
|
||||
playlistName?: string;
|
||||
}): Promise<void> => {
|
||||
if (!api) {
|
||||
throw new Error("API not configured");
|
||||
}
|
||||
|
||||
await getPlaylistsApi(api).removeItemFromPlaylist({
|
||||
playlistId,
|
||||
entryIds,
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlists"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlist", variables.playlistId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlist-tracks", variables.playlistId],
|
||||
});
|
||||
if (variables.playlistName) {
|
||||
toast.success(
|
||||
t("music.playlists.removed_from", { name: variables.playlistName }),
|
||||
);
|
||||
} else {
|
||||
toast.success(t("music.playlists.removed"));
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || t("music.playlists.failed_to_remove"));
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a playlist
|
||||
*/
|
||||
export const useDeletePlaylist = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
playlistId,
|
||||
}: {
|
||||
playlistId: string;
|
||||
}): Promise<void> => {
|
||||
if (!api) {
|
||||
throw new Error("API not configured");
|
||||
}
|
||||
|
||||
await getLibraryApi(api).deleteItem({
|
||||
itemId: playlistId,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["music-playlists"],
|
||||
refetchType: "all",
|
||||
});
|
||||
toast.success(t("music.playlists.deleted"));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || t("music.playlists.failed_to_delete"));
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useTwoWaySync } from "./useTwoWaySync";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTwoWaySync } from "./useTwoWaySync";
|
||||
* useRevalidatePlaybackProgressCache invalidates queries related to playback progress.
|
||||
*/
|
||||
export function useInvalidatePlaybackProgressCache() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const { syncPlaybackState } = useTwoWaySync();
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import axios, { type AxiosError, type AxiosInstance } from "axios";
|
||||
import { atom } from "jotai";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { inRange } from "lodash";
|
||||
import type { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
|
||||
import type { User as SeerrUser } from "@/utils/jellyseerr/server/entity/User";
|
||||
import type {
|
||||
MovieResult,
|
||||
Results,
|
||||
@@ -10,10 +10,10 @@ import type {
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import "@/augmentations";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { t } from "i18next";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
|
||||
import {
|
||||
@@ -62,12 +62,12 @@ interface SearchResults {
|
||||
results: Results[];
|
||||
}
|
||||
|
||||
const JELLYSEERR_USER = "JELLYSEERR_USER";
|
||||
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
|
||||
const SEERR_USER = "SEERR_USER";
|
||||
const SEERR_COOKIES = "SEERR_COOKIES";
|
||||
|
||||
export const clearJellyseerrStorageData = () => {
|
||||
storage.remove(JELLYSEERR_USER);
|
||||
storage.remove(JELLYSEERR_COOKIES);
|
||||
export const clearSeerrStorageData = () => {
|
||||
storage.remove(SEERR_USER);
|
||||
storage.remove(SEERR_COOKIES);
|
||||
};
|
||||
|
||||
export enum Endpoints {
|
||||
@@ -111,7 +111,7 @@ export type TestResult =
|
||||
isValid: false;
|
||||
};
|
||||
|
||||
export class JellyseerrApi {
|
||||
export class SeerrApi {
|
||||
axios: AxiosInstance;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
@@ -126,8 +126,8 @@ export class JellyseerrApi {
|
||||
}
|
||||
|
||||
async test(): Promise<TestResult> {
|
||||
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
const user = storage.get<SeerrUser>(SEERR_USER);
|
||||
const cookies = storage.get<string[]>(SEERR_COOKIES);
|
||||
|
||||
if (user && cookies) {
|
||||
return Promise.resolve({
|
||||
@@ -142,15 +142,13 @@ export class JellyseerrApi {
|
||||
const { status, headers, data } = response;
|
||||
if (inRange(status, 200, 299)) {
|
||||
if (data.version < "2.0.0") {
|
||||
const error = t(
|
||||
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
||||
);
|
||||
const error = t("seerr.toasts.seer_does_not_meet_requirements");
|
||||
toast.error(error);
|
||||
throw Error(error);
|
||||
}
|
||||
|
||||
storage.setAny(
|
||||
JELLYSEERR_COOKIES,
|
||||
SEERR_COOKIES,
|
||||
headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? [],
|
||||
);
|
||||
return {
|
||||
@@ -158,9 +156,9 @@ export class JellyseerrApi {
|
||||
requiresPass: true,
|
||||
};
|
||||
}
|
||||
toast.error(t("jellyseerr.toasts.jellyseerr_test_failed"));
|
||||
toast.error(t("seerr.toasts.seerr_test_failed"));
|
||||
writeErrorLog(
|
||||
`Jellyseerr returned a ${status} for url:\n${response.config.url}`,
|
||||
`Seerr returned a ${status} for url:\n${response.config.url}`,
|
||||
response.data,
|
||||
);
|
||||
return {
|
||||
@@ -169,7 +167,7 @@ export class JellyseerrApi {
|
||||
};
|
||||
})
|
||||
.catch((e) => {
|
||||
const msg = t("jellyseerr.toasts.failed_to_test_jellyseerr_server_url");
|
||||
const msg = t("seerr.toasts.failed_to_test_seerr_server_url");
|
||||
toast.error(msg);
|
||||
console.error(msg, e);
|
||||
return {
|
||||
@@ -179,9 +177,9 @@ export class JellyseerrApi {
|
||||
});
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<JellyseerrUser> {
|
||||
async login(username: string, password: string): Promise<SeerrUser> {
|
||||
return this.axios
|
||||
?.post<JellyseerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
|
||||
?.post<SeerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
|
||||
username,
|
||||
password,
|
||||
email: username,
|
||||
@@ -189,7 +187,7 @@ export class JellyseerrApi {
|
||||
.then((response) => {
|
||||
const user = response?.data;
|
||||
if (!user) throw Error("Login failed");
|
||||
storage.setAny(JELLYSEERR_USER, user);
|
||||
storage.setAny(SEERR_USER, user);
|
||||
return user;
|
||||
});
|
||||
}
|
||||
@@ -364,7 +362,7 @@ export class JellyseerrApi {
|
||||
const issue = response.data;
|
||||
|
||||
if (issue.status === IssueStatus.OPEN) {
|
||||
toast.success(t("jellyseerr.toasts.issue_submitted"));
|
||||
toast.success(t("seerr.toasts.issue_submitted"));
|
||||
}
|
||||
return issue;
|
||||
});
|
||||
@@ -392,7 +390,7 @@ export class JellyseerrApi {
|
||||
const cookies = response.headers["set-cookie"];
|
||||
if (cookies) {
|
||||
storage.setAny(
|
||||
JELLYSEERR_COOKIES,
|
||||
SEERR_COOKIES,
|
||||
response.headers["set-cookie"]?.flatMap((c) => c.split("; ")),
|
||||
);
|
||||
}
|
||||
@@ -400,11 +398,11 @@ export class JellyseerrApi {
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
writeErrorLog(
|
||||
`Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
|
||||
`Seerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
|
||||
error.response?.data,
|
||||
);
|
||||
if (error.response?.status === 403) {
|
||||
clearJellyseerrStorageData();
|
||||
clearSeerrStorageData();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
@@ -412,7 +410,7 @@ export class JellyseerrApi {
|
||||
|
||||
this.axios.interceptors.request.use(
|
||||
async (config) => {
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
const cookies = storage.get<string[]>(SEERR_COOKIES);
|
||||
if (cookies) {
|
||||
const headerName = this.axios.defaults.xsrfHeaderName!;
|
||||
const xsrfToken = cookies
|
||||
@@ -425,65 +423,61 @@ export class JellyseerrApi {
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error("Jellyseerr request error", error);
|
||||
console.error("Seerr request error", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
|
||||
const seerrUserAtom = atom(storage.get<SeerrUser>(SEERR_USER));
|
||||
|
||||
export const useJellyseerr = () => {
|
||||
export const useSeerr = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const [seerrUser, setSeerrUser] = useAtom(seerrUserAtom);
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
const jellyseerrApi = useMemo(() => {
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
if (settings?.jellyseerrServerUrl && cookies && jellyseerrUser) {
|
||||
return new JellyseerrApi(settings?.jellyseerrServerUrl);
|
||||
const seerrApi = useMemo(() => {
|
||||
const cookies = storage.get<string[]>(SEERR_COOKIES);
|
||||
if (settings?.seerrServerUrl && cookies && seerrUser) {
|
||||
return new SeerrApi(settings?.seerrServerUrl);
|
||||
}
|
||||
return undefined;
|
||||
}, [settings?.jellyseerrServerUrl, jellyseerrUser]);
|
||||
}, [settings?.seerrServerUrl, seerrUser]);
|
||||
|
||||
const clearAllJellyseerData = useCallback(async () => {
|
||||
clearJellyseerrStorageData();
|
||||
setJellyseerrUser(undefined);
|
||||
updateSettings({ jellyseerrServerUrl: undefined });
|
||||
const clearAllSeerrData = useCallback(async () => {
|
||||
clearSeerrStorageData();
|
||||
setSeerrUser(undefined);
|
||||
updateSettings({ seerrServerUrl: undefined });
|
||||
}, []);
|
||||
|
||||
const requestMedia = useCallback(
|
||||
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||
jellyseerrApi?.request?.(request)?.then(async (mediaRequest) => {
|
||||
seerrApi?.request?.(request)?.then(async (mediaRequest) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["search", "jellyseerr"],
|
||||
queryKey: ["search", "seerr"],
|
||||
});
|
||||
|
||||
switch (mediaRequest.status) {
|
||||
case MediaRequestStatus.PENDING:
|
||||
case MediaRequestStatus.APPROVED:
|
||||
toast.success(
|
||||
t("jellyseerr.toasts.requested_item", { item: title }),
|
||||
);
|
||||
toast.success(t("seerr.toasts.requested_item", { item: title }));
|
||||
onSuccess?.();
|
||||
break;
|
||||
case MediaRequestStatus.DECLINED:
|
||||
toast.error(
|
||||
t("jellyseerr.toasts.you_dont_have_permission_to_request"),
|
||||
);
|
||||
toast.error(t("seerr.toasts.you_dont_have_permission_to_request"));
|
||||
break;
|
||||
case MediaRequestStatus.FAILED:
|
||||
toast.error(
|
||||
t("jellyseerr.toasts.something_went_wrong_requesting_media"),
|
||||
t("seerr.toasts.something_went_wrong_requesting_media"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
[jellyseerrApi],
|
||||
[seerrApi],
|
||||
);
|
||||
|
||||
const isJellyseerrMovieOrTvResult = (
|
||||
const isSeerrMovieOrTvResult = (
|
||||
items: any | null | undefined,
|
||||
): items is MovieResult | TvResult => {
|
||||
return (
|
||||
@@ -496,7 +490,7 @@ export const useJellyseerr = () => {
|
||||
const getTitle = (
|
||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
||||
) => {
|
||||
return isJellyseerrMovieOrTvResult(item)
|
||||
return isSeerrMovieOrTvResult(item)
|
||||
? item.mediaType === MediaType.MOVIE
|
||||
? item?.title
|
||||
: item?.name
|
||||
@@ -509,7 +503,7 @@ export const useJellyseerr = () => {
|
||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
||||
) => {
|
||||
return new Date(
|
||||
(isJellyseerrMovieOrTvResult(item)
|
||||
(isSeerrMovieOrTvResult(item)
|
||||
? item.mediaType === MediaType.MOVIE
|
||||
? item?.releaseDate
|
||||
: item?.firstAirDate
|
||||
@@ -522,36 +516,35 @@ export const useJellyseerr = () => {
|
||||
const getMediaType = (
|
||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
||||
): MediaType => {
|
||||
return isJellyseerrMovieOrTvResult(item)
|
||||
return isSeerrMovieOrTvResult(item)
|
||||
? (item.mediaType as MediaType)
|
||||
: item?.mediaInfo?.mediaType;
|
||||
};
|
||||
|
||||
const jellyseerrRegion = useMemo(
|
||||
() => jellyseerrUser?.settings?.region || "US",
|
||||
[jellyseerrUser],
|
||||
const seerrRegion = useMemo(
|
||||
// streamingRegion and discoverRegion exists. region doesn't
|
||||
() => seerrUser?.settings?.discoverRegion || "US",
|
||||
[seerrUser],
|
||||
);
|
||||
|
||||
const jellyseerrLocale = useMemo(() => {
|
||||
const locale = jellyseerrUser?.settings?.locale || "en";
|
||||
const seerrLocale = useMemo(() => {
|
||||
const locale = seerrUser?.settings?.locale || "en";
|
||||
// Use regex to check if locale already contains region code (e.g., zh-CN, pt-BR)
|
||||
// If not, append the region to create a valid BCP 47 locale string
|
||||
return /^[a-z]{2,3}-/i.test(locale)
|
||||
? locale
|
||||
: `${locale}-${jellyseerrRegion}`;
|
||||
}, [jellyseerrUser, jellyseerrRegion]);
|
||||
return /^[a-z]{2,3}-/i.test(locale) ? locale : `${locale}-${seerrRegion}`;
|
||||
}, [seerrUser, seerrRegion]);
|
||||
|
||||
return {
|
||||
jellyseerrApi,
|
||||
jellyseerrUser,
|
||||
setJellyseerrUser,
|
||||
clearAllJellyseerData,
|
||||
isJellyseerrMovieOrTvResult,
|
||||
seerrApi,
|
||||
seerrUser,
|
||||
setSeerrUser,
|
||||
clearAllSeerrData,
|
||||
isSeerrMovieOrTvResult,
|
||||
getTitle,
|
||||
getYear,
|
||||
getMediaType,
|
||||
jellyseerrRegion,
|
||||
jellyseerrLocale,
|
||||
seerrRegion,
|
||||
seerrLocale,
|
||||
requestMedia,
|
||||
};
|
||||
};
|
||||
296
hooks/useWatchlistMutations.ts
Normal file
296
hooks/useWatchlistMutations.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||
import type {
|
||||
CreateWatchlistRequest,
|
||||
StreamystatsWatchlist,
|
||||
UpdateWatchlistRequest,
|
||||
} from "@/utils/streamystats/types";
|
||||
|
||||
/**
|
||||
* Hook to create a new watchlist
|
||||
*/
|
||||
export const useCreateWatchlist = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (
|
||||
data: CreateWatchlistRequest,
|
||||
): Promise<StreamystatsWatchlist> => {
|
||||
if (!settings?.streamyStatsServerUrl || !api?.accessToken) {
|
||||
throw new Error("Streamystats not configured");
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const response = await streamystatsApi.createWatchlist(data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlists"],
|
||||
});
|
||||
toast.success("Watchlist created");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create watchlist");
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a watchlist
|
||||
*/
|
||||
export const useUpdateWatchlist = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
watchlistId,
|
||||
data,
|
||||
}: {
|
||||
watchlistId: number;
|
||||
data: UpdateWatchlistRequest;
|
||||
}): Promise<StreamystatsWatchlist> => {
|
||||
if (!settings?.streamyStatsServerUrl || !api?.accessToken) {
|
||||
throw new Error("Streamystats not configured");
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const response = await streamystatsApi.updateWatchlist(watchlistId, data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlists"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
||||
});
|
||||
toast.success("Watchlist updated");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update watchlist");
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a watchlist
|
||||
*/
|
||||
export const useDeleteWatchlist = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (watchlistId: number): Promise<void> => {
|
||||
if (!settings?.streamyStatsServerUrl || !api?.accessToken) {
|
||||
throw new Error("Streamystats not configured");
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const response = await streamystatsApi.deleteWatchlist(watchlistId);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, watchlistId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlists"],
|
||||
});
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["streamystats", "watchlist", watchlistId],
|
||||
});
|
||||
toast.success("Watchlist deleted");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to delete watchlist");
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to add an item to a watchlist with optimistic update
|
||||
*/
|
||||
export const useAddToWatchlist = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
watchlistId,
|
||||
itemId,
|
||||
}: {
|
||||
watchlistId: number;
|
||||
itemId: string;
|
||||
watchlistName?: string;
|
||||
}): Promise<void> => {
|
||||
if (!settings?.streamyStatsServerUrl || !api?.accessToken) {
|
||||
throw new Error("Streamystats not configured");
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const response = await streamystatsApi.addWatchlistItem(
|
||||
watchlistId,
|
||||
itemId,
|
||||
);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlistItems", variables.watchlistId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "itemInWatchlists", variables.itemId],
|
||||
});
|
||||
if (variables.watchlistName) {
|
||||
toast.success(`Added to ${variables.watchlistName}`);
|
||||
} else {
|
||||
toast.success("Added to watchlist");
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to add to watchlist");
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to remove an item from a watchlist with optimistic update
|
||||
*/
|
||||
export const useRemoveFromWatchlist = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
watchlistId,
|
||||
itemId,
|
||||
}: {
|
||||
watchlistId: number;
|
||||
itemId: string;
|
||||
watchlistName?: string;
|
||||
}): Promise<void> => {
|
||||
if (!settings?.streamyStatsServerUrl || !api?.accessToken) {
|
||||
throw new Error("Streamystats not configured");
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const response = await streamystatsApi.removeWatchlistItem(
|
||||
watchlistId,
|
||||
itemId,
|
||||
);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlistItems", variables.watchlistId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "itemInWatchlists", variables.itemId],
|
||||
});
|
||||
if (variables.watchlistName) {
|
||||
toast.success(`Removed from ${variables.watchlistName}`);
|
||||
} else {
|
||||
toast.success("Removed from watchlist");
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to remove from watchlist");
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to toggle an item in a watchlist
|
||||
*/
|
||||
export const useToggleWatchlistItem = () => {
|
||||
const addMutation = useAddToWatchlist();
|
||||
const removeMutation = useRemoveFromWatchlist();
|
||||
|
||||
const toggle = useCallback(
|
||||
async (params: {
|
||||
watchlistId: number;
|
||||
itemId: string;
|
||||
isInWatchlist: boolean;
|
||||
watchlistName?: string;
|
||||
}) => {
|
||||
if (params.isInWatchlist) {
|
||||
await removeMutation.mutateAsync({
|
||||
watchlistId: params.watchlistId,
|
||||
itemId: params.itemId,
|
||||
watchlistName: params.watchlistName,
|
||||
});
|
||||
} else {
|
||||
await addMutation.mutateAsync({
|
||||
watchlistId: params.watchlistId,
|
||||
itemId: params.itemId,
|
||||
watchlistName: params.watchlistName,
|
||||
});
|
||||
}
|
||||
},
|
||||
[addMutation, removeMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
toggle,
|
||||
isLoading: addMutation.isPending || removeMutation.isPending,
|
||||
};
|
||||
};
|
||||
290
hooks/useWatchlists.ts
Normal file
290
hooks/useWatchlists.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
PublicSystemInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||
import type {
|
||||
GetWatchlistItemsParams,
|
||||
StreamystatsWatchlist,
|
||||
} from "@/utils/streamystats/types";
|
||||
|
||||
/**
|
||||
* Hook to check if Streamystats is configured
|
||||
*/
|
||||
export const useStreamystatsEnabled = () => {
|
||||
const { settings } = useSettings();
|
||||
return useMemo(
|
||||
() => Boolean(settings?.streamyStatsServerUrl),
|
||||
[settings?.streamyStatsServerUrl],
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get the Jellyfin server ID needed for Streamystats API calls
|
||||
*/
|
||||
export const useJellyfinServerId = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
|
||||
const { data: serverInfo, isLoading } = useQuery({
|
||||
queryKey: ["jellyfin", "serverInfo"],
|
||||
queryFn: async (): Promise<PublicSystemInfo | null> => {
|
||||
if (!api) return null;
|
||||
const response = await getSystemApi(api).getPublicSystemInfo();
|
||||
return response.data;
|
||||
},
|
||||
enabled: Boolean(api) && streamystatsEnabled,
|
||||
staleTime: 60 * 60 * 1000, // 1 hour
|
||||
});
|
||||
|
||||
return {
|
||||
jellyfinServerId: serverInfo?.Id,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get all watchlists (own + public)
|
||||
*/
|
||||
export const useWatchlistsQuery = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"watchlists",
|
||||
settings?.streamyStatsServerUrl,
|
||||
user?.Id,
|
||||
],
|
||||
queryFn: async (): Promise<StreamystatsWatchlist[]> => {
|
||||
if (!settings?.streamyStatsServerUrl || !api?.accessToken) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const response = await streamystatsApi.getWatchlists();
|
||||
return response.data || [];
|
||||
},
|
||||
enabled: streamystatsEnabled && Boolean(api?.accessToken),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single watchlist with its items
|
||||
*/
|
||||
export const useWatchlistDetailQuery = (
|
||||
watchlistId: number | undefined,
|
||||
params?: GetWatchlistItemsParams,
|
||||
) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"watchlist",
|
||||
watchlistId,
|
||||
params?.type,
|
||||
params?.sort,
|
||||
settings?.streamyStatsServerUrl,
|
||||
],
|
||||
queryFn: async (): Promise<StreamystatsWatchlist | null> => {
|
||||
if (
|
||||
!settings?.streamyStatsServerUrl ||
|
||||
!api?.accessToken ||
|
||||
!watchlistId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const response = await streamystatsApi.getWatchlistDetail(
|
||||
watchlistId,
|
||||
params,
|
||||
);
|
||||
return response.data || null;
|
||||
},
|
||||
enabled:
|
||||
streamystatsEnabled &&
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(watchlistId) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get watchlist items enriched with Jellyfin item data
|
||||
*/
|
||||
export const useWatchlistItemsQuery = (
|
||||
watchlistId: number | undefined,
|
||||
params?: GetWatchlistItemsParams,
|
||||
) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
const { jellyfinServerId } = useJellyfinServerId();
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"watchlistItems",
|
||||
watchlistId,
|
||||
jellyfinServerId,
|
||||
params?.type,
|
||||
params?.sort,
|
||||
settings?.streamyStatsServerUrl,
|
||||
],
|
||||
queryFn: async (): Promise<BaseItemDto[]> => {
|
||||
if (
|
||||
!settings?.streamyStatsServerUrl ||
|
||||
!api?.accessToken ||
|
||||
!watchlistId ||
|
||||
!jellyfinServerId ||
|
||||
!user?.Id
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
// Get watchlist item IDs from Streamystats
|
||||
const watchlistDetail = await streamystatsApi.getWatchlistItemIds({
|
||||
watchlistId,
|
||||
jellyfinServerId,
|
||||
});
|
||||
|
||||
const itemIds = watchlistDetail.data?.items;
|
||||
if (!itemIds?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch full item details from Jellyfin
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
ids: itemIds,
|
||||
fields: [
|
||||
"PrimaryImageAspectRatio",
|
||||
"Genres",
|
||||
"Overview",
|
||||
"DateCreated",
|
||||
],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled:
|
||||
streamystatsEnabled &&
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(watchlistId) &&
|
||||
Boolean(jellyfinServerId) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get the user's own watchlists only (for add-to-watchlist picker)
|
||||
*/
|
||||
export const useMyWatchlistsQuery = () => {
|
||||
const user = useAtomValue(userAtom);
|
||||
const { data: allWatchlists, ...rest } = useWatchlistsQuery();
|
||||
|
||||
const myWatchlists = useMemo(() => {
|
||||
if (!allWatchlists || !user?.Id) return [];
|
||||
return allWatchlists.filter((w) => w.userId === user.Id);
|
||||
}, [allWatchlists, user?.Id]);
|
||||
|
||||
return {
|
||||
data: myWatchlists,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check which of the user's watchlists contain a specific item
|
||||
*/
|
||||
export const useItemInWatchlists = (itemId: string | undefined) => {
|
||||
const { data: myWatchlists } = useMyWatchlistsQuery();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const { jellyfinServerId } = useJellyfinServerId();
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"itemInWatchlists",
|
||||
itemId,
|
||||
jellyfinServerId,
|
||||
settings?.streamyStatsServerUrl,
|
||||
],
|
||||
queryFn: async (): Promise<number[]> => {
|
||||
if (
|
||||
!settings?.streamyStatsServerUrl ||
|
||||
!api?.accessToken ||
|
||||
!itemId ||
|
||||
!jellyfinServerId ||
|
||||
!myWatchlists?.length
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
// Check each watchlist to see if it contains the item
|
||||
const watchlistsContainingItem: number[] = [];
|
||||
|
||||
for (const watchlist of myWatchlists) {
|
||||
try {
|
||||
const detail = await streamystatsApi.getWatchlistItemIds({
|
||||
watchlistId: watchlist.id,
|
||||
jellyfinServerId,
|
||||
});
|
||||
if (detail.data?.items?.includes(itemId)) {
|
||||
watchlistsContainingItem.push(watchlist.id);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors for individual watchlists
|
||||
}
|
||||
}
|
||||
|
||||
return watchlistsContainingItem;
|
||||
},
|
||||
enabled:
|
||||
streamystatsEnabled &&
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(itemId) &&
|
||||
Boolean(jellyfinServerId) &&
|
||||
Boolean(myWatchlists?.length),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||
|
||||
interface UseWebSocketProps {
|
||||
@@ -96,8 +96,6 @@ export const useWebSocket = ({
|
||||
| Record<string, string>
|
||||
| undefined; // Arguments are Dictionary<string, string>
|
||||
|
||||
console.log("[WS] ~ ", lastMessage);
|
||||
|
||||
if (command === "PlayPause") {
|
||||
console.log("Command ~ PlayPause");
|
||||
togglePlay();
|
||||
|
||||
97
hooks/useWifiSSID.ts
Normal file
97
hooks/useWifiSSID.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import * as Location from "expo-location";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getSSID } from "@/modules/wifi-ssid";
|
||||
|
||||
export type PermissionStatus =
|
||||
| "granted"
|
||||
| "denied"
|
||||
| "undetermined"
|
||||
| "unavailable";
|
||||
|
||||
export interface UseWifiSSIDReturn {
|
||||
ssid: string | null;
|
||||
permissionStatus: PermissionStatus;
|
||||
requestPermission: () => Promise<boolean>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function mapLocationStatus(
|
||||
status: Location.PermissionStatus,
|
||||
): PermissionStatus {
|
||||
switch (status) {
|
||||
case Location.PermissionStatus.GRANTED:
|
||||
return "granted";
|
||||
case Location.PermissionStatus.DENIED:
|
||||
return "denied";
|
||||
default:
|
||||
return "undetermined";
|
||||
}
|
||||
}
|
||||
|
||||
export function useWifiSSID(): UseWifiSSIDReturn {
|
||||
const [ssid, setSSID] = useState<string | null>(null);
|
||||
const [permissionStatus, setPermissionStatus] =
|
||||
useState<PermissionStatus>("undetermined");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const fetchSSID = useCallback(async () => {
|
||||
const result = await getSSID();
|
||||
console.log("[WiFi Debug] Native module SSID:", result);
|
||||
setSSID(result);
|
||||
}, []);
|
||||
|
||||
const requestPermission = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
const newStatus = mapLocationStatus(status);
|
||||
setPermissionStatus(newStatus);
|
||||
|
||||
if (newStatus === "granted") {
|
||||
await fetchSSID();
|
||||
}
|
||||
|
||||
return newStatus === "granted";
|
||||
} catch {
|
||||
setPermissionStatus("unavailable");
|
||||
return false;
|
||||
}
|
||||
}, [fetchSSID]);
|
||||
|
||||
useEffect(() => {
|
||||
async function initialize() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { status } = await Location.getForegroundPermissionsAsync();
|
||||
const mappedStatus = mapLocationStatus(status);
|
||||
setPermissionStatus(mappedStatus);
|
||||
|
||||
if (mappedStatus === "granted") {
|
||||
await fetchSSID();
|
||||
}
|
||||
} catch {
|
||||
setPermissionStatus("unavailable");
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
initialize();
|
||||
}, [fetchSSID]);
|
||||
|
||||
// Refresh SSID when permission status changes to granted
|
||||
useEffect(() => {
|
||||
if (permissionStatus === "granted") {
|
||||
fetchSSID();
|
||||
|
||||
// Also set up an interval to periodically check SSID
|
||||
const interval = setInterval(fetchSSID, 10000); // Check every 10 seconds
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [permissionStatus, fetchSSID]);
|
||||
|
||||
return {
|
||||
ssid,
|
||||
permissionStatus,
|
||||
requestPermission,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user