feat: KSPlayer as an option for iOS + other improvements (#1266)

This commit is contained in:
Fredrik Burmester
2026-01-03 13:05:50 +01:00
committed by GitHub
parent d1795c9df8
commit 74d86b5d12
191 changed files with 88479 additions and 2316 deletions

View File

@@ -7,23 +7,13 @@ import { useHaptic } from "./useHaptic";
/**
* Custom hook to handle skipping credits in a media player.
*
* @param {string} itemId - The ID of the media item.
* @param {number} currentTime - The current playback time (ms for VLC, seconds otherwise).
* @param {function} seek - Function to seek to a position.
* @param {function} play - Function to resume playback.
* @param {boolean} isVlc - Whether using VLC player (uses milliseconds).
* @param {boolean} isOffline - Whether in offline mode.
* @param {Api|null} api - The Jellyfin API client.
* @param {DownloadedItem[]|undefined} downloadedFiles - Downloaded files for offline mode.
* @param {number|undefined} totalDuration - Total duration of the video (ms for VLC, seconds otherwise).
* 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,
@@ -32,21 +22,15 @@ export const useCreditSkipper = (
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
const lightHapticFeedback = useHaptic("light");
// Convert currentTime to seconds for consistent comparison (matching useIntroSkipper pattern)
if (isVlc) {
currentTime = msToSeconds(currentTime);
}
// Convert ms to seconds for comparison with timestamps
const currentTimeSeconds = msToSeconds(currentTime);
const totalDurationInSeconds =
isVlc && totalDuration ? msToSeconds(totalDuration) : totalDuration;
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(
@@ -60,7 +44,13 @@ export const useCreditSkipper = (
// 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) return false;
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
@@ -70,8 +60,8 @@ export const useCreditSkipper = (
useEffect(() => {
if (creditTimestamps) {
const shouldShow =
currentTime > creditTimestamps.startTime &&
currentTime < creditTimestamps.endTime;
currentTimeSeconds > creditTimestamps.startTime &&
currentTimeSeconds < creditTimestamps.endTime;
setShowSkipCreditButton(shouldShow);
} else {
@@ -80,7 +70,7 @@ export const useCreditSkipper = (
setShowSkipCreditButton(false);
}
}
}, [creditTimestamps, currentTime, showSkipCreditButton]);
}, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]);
const skipCredit = useCallback(() => {
if (!creditTimestamps) return;

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

@@ -2,108 +2,92 @@ 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 { useCallback, useEffect, useMemo, useState } from "react";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
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 [isFavorite, setIsFavorite] = useState<boolean | undefined>(
item.UserData?.IsFavorite,
);
useEffect(() => {
setIsFavorite(item.UserData?.IsFavorite);
}, [item.UserData?.IsFavorite]);
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 },
};
},
);
};
const itemQueryKeyPrefix = useMemo(
() => ["item", item.Id] as const,
[item.Id],
);
const markFavoriteMutation = useMutation({
mutationFn: async () => {
if (api && user) {
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 },
};
},
);
},
[itemQueryKeyPrefix, queryClient],
);
const favoriteMutation = useMutation({
mutationFn: async (nextIsFavorite: boolean) => {
if (!api || !user || !item.Id) return;
if (nextIsFavorite) {
await getUserLibraryApi(api).markFavoriteItem({
userId: user.Id,
itemId: item.Id!,
itemId: item.Id,
});
return;
}
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: true } });
onMutate: async (nextIsFavorite: boolean) => {
await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix });
return { previousItem };
const previousIsFavorite = isFavorite;
const previousQueries = queryClient.getQueriesData<BaseItemDto | null>({
queryKey: itemQueryKeyPrefix,
});
setIsFavorite(nextIsFavorite);
updateItemInQueries({ UserData: { IsFavorite: nextIsFavorite } });
return { previousIsFavorite, previousQueries };
},
onError: (_err, _variables, context) => {
if (context?.previousItem) {
queryClient.setQueryData([type, item.Id], context.previousItem);
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

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

@@ -528,7 +528,8 @@ export const useJellyseerr = () => {
};
const jellyseerrRegion = useMemo(
() => jellyseerrUser?.settings?.region || "US",
// streamingRegion and discoverRegion exists. region doesn't
() => jellyseerrUser?.settings?.discoverRegion || "US",
[jellyseerrUser],
);

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

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,
@@ -53,27 +53,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

@@ -237,6 +237,7 @@ export const usePlaybackManager = ({
});
} catch (error) {
console.error("Failed to mark item as played on server", error);
throw error;
}
}
};
@@ -278,6 +279,7 @@ export const usePlaybackManager = ({
});
} catch (error) {
console.error("Failed to mark item as unplayed on server", error);
throw error;
}
}
};

View File

@@ -0,0 +1,295 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useCallback } from "react";
import { toast } from "sonner-native";
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 = useQueryClient();
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 = useQueryClient();
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 = useQueryClient();
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 = useQueryClient();
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 = useQueryClient();
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

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