fix: resolve merge conflict in useSeerr.ts - keep improved BCP 47 locale logic

This commit is contained in:
Uruk
2026-01-12 11:40:13 +01:00
384 changed files with 114648 additions and 9463 deletions

86
hooks/useAppRouter.ts Normal file
View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
});
};

View File

@@ -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
View 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,
};
};

View 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]);
}

View File

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

View File

@@ -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
View 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;

View 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;
};

View File

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

View File

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

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

View File

@@ -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
View 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,
};
}