mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: KSPlayer as an option for iOS + other improvements (#1266)
This commit is contained in:
committed by
GitHub
parent
d1795c9df8
commit
74d86b5d12
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,31 +7,26 @@ import { useHaptic } from "./useHaptic";
|
||||
|
||||
/**
|
||||
* Custom hook to handle skipping intros in a media player.
|
||||
* MPV player uses milliseconds for time.
|
||||
*
|
||||
* @param {number} currentTime - The current playback time in seconds.
|
||||
* @param {number} currentTime - The current playback time in milliseconds.
|
||||
*/
|
||||
export const useIntroSkipper = (
|
||||
itemId: string,
|
||||
currentTime: number,
|
||||
seek: (ticks: number) => void,
|
||||
seek: (ms: number) => void,
|
||||
play: () => void,
|
||||
isVlc = false,
|
||||
isOffline = false,
|
||||
api: Api | null = null,
|
||||
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||
) => {
|
||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||
if (isVlc) {
|
||||
currentTime = msToSeconds(currentTime);
|
||||
}
|
||||
// Convert ms to seconds for comparison with timestamps
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const wrappedSeek = (seconds: number) => {
|
||||
if (isVlc) {
|
||||
seek(secondsToMs(seconds));
|
||||
return;
|
||||
}
|
||||
seek(seconds);
|
||||
seek(secondsToMs(seconds));
|
||||
};
|
||||
|
||||
const { data: segments } = useSegments(
|
||||
@@ -45,8 +40,8 @@ export const useIntroSkipper = (
|
||||
useEffect(() => {
|
||||
if (introTimestamps) {
|
||||
const shouldShow =
|
||||
currentTime > introTimestamps.startTime &&
|
||||
currentTime < introTimestamps.endTime;
|
||||
currentTimeSeconds > introTimestamps.startTime &&
|
||||
currentTimeSeconds < introTimestamps.endTime;
|
||||
|
||||
setShowSkipButton(shouldShow);
|
||||
} else {
|
||||
@@ -54,7 +49,7 @@ export const useIntroSkipper = (
|
||||
setShowSkipButton(false);
|
||||
}
|
||||
}
|
||||
}, [introTimestamps, currentTime, showSkipButton]);
|
||||
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
|
||||
|
||||
const skipIntro = useCallback(() => {
|
||||
if (!introTimestamps) return;
|
||||
|
||||
37
hooks/useItemPeopleQuery.ts
Normal file
37
hooks/useItemPeopleQuery.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {
|
||||
BaseItemPerson,
|
||||
ItemFields,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export const useItemPeopleQuery = (
|
||||
itemId: string | undefined,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
return useQuery<BaseItemPerson[]>({
|
||||
queryKey: ["item", itemId, "people"],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !itemId) return [];
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
ids: [itemId],
|
||||
userId: user.Id,
|
||||
fields: ["People" satisfies ItemFields],
|
||||
});
|
||||
|
||||
const people = response.data.Items?.[0]?.People;
|
||||
return Array.isArray(people) ? people : [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!itemId && enabled,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
295
hooks/useWatchlistMutations.ts
Normal file
295
hooks/useWatchlistMutations.ts
Normal 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
290
hooks/useWatchlists.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
PublicSystemInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||
import type {
|
||||
GetWatchlistItemsParams,
|
||||
StreamystatsWatchlist,
|
||||
} from "@/utils/streamystats/types";
|
||||
|
||||
/**
|
||||
* Hook to check if Streamystats is configured
|
||||
*/
|
||||
export const useStreamystatsEnabled = () => {
|
||||
const { settings } = useSettings();
|
||||
return useMemo(
|
||||
() => Boolean(settings?.streamyStatsServerUrl),
|
||||
[settings?.streamyStatsServerUrl],
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get the Jellyfin server ID needed for Streamystats API calls
|
||||
*/
|
||||
export const useJellyfinServerId = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
|
||||
const { data: serverInfo, isLoading } = useQuery({
|
||||
queryKey: ["jellyfin", "serverInfo"],
|
||||
queryFn: async (): Promise<PublicSystemInfo | null> => {
|
||||
if (!api) return null;
|
||||
const response = await getSystemApi(api).getPublicSystemInfo();
|
||||
return response.data;
|
||||
},
|
||||
enabled: Boolean(api) && streamystatsEnabled,
|
||||
staleTime: 60 * 60 * 1000, // 1 hour
|
||||
});
|
||||
|
||||
return {
|
||||
jellyfinServerId: serverInfo?.Id,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get all watchlists (own + public)
|
||||
*/
|
||||
export const useWatchlistsQuery = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"watchlists",
|
||||
settings?.streamyStatsServerUrl,
|
||||
user?.Id,
|
||||
],
|
||||
queryFn: async (): Promise<StreamystatsWatchlist[]> => {
|
||||
if (!settings?.streamyStatsServerUrl || !api?.accessToken) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const response = await streamystatsApi.getWatchlists();
|
||||
return response.data || [];
|
||||
},
|
||||
enabled: streamystatsEnabled && Boolean(api?.accessToken),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single watchlist with its items
|
||||
*/
|
||||
export const useWatchlistDetailQuery = (
|
||||
watchlistId: number | undefined,
|
||||
params?: GetWatchlistItemsParams,
|
||||
) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"watchlist",
|
||||
watchlistId,
|
||||
params?.type,
|
||||
params?.sort,
|
||||
settings?.streamyStatsServerUrl,
|
||||
],
|
||||
queryFn: async (): Promise<StreamystatsWatchlist | null> => {
|
||||
if (
|
||||
!settings?.streamyStatsServerUrl ||
|
||||
!api?.accessToken ||
|
||||
!watchlistId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const response = await streamystatsApi.getWatchlistDetail(
|
||||
watchlistId,
|
||||
params,
|
||||
);
|
||||
return response.data || null;
|
||||
},
|
||||
enabled:
|
||||
streamystatsEnabled &&
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(watchlistId) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get watchlist items enriched with Jellyfin item data
|
||||
*/
|
||||
export const useWatchlistItemsQuery = (
|
||||
watchlistId: number | undefined,
|
||||
params?: GetWatchlistItemsParams,
|
||||
) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
const { jellyfinServerId } = useJellyfinServerId();
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"watchlistItems",
|
||||
watchlistId,
|
||||
jellyfinServerId,
|
||||
params?.type,
|
||||
params?.sort,
|
||||
settings?.streamyStatsServerUrl,
|
||||
],
|
||||
queryFn: async (): Promise<BaseItemDto[]> => {
|
||||
if (
|
||||
!settings?.streamyStatsServerUrl ||
|
||||
!api?.accessToken ||
|
||||
!watchlistId ||
|
||||
!jellyfinServerId ||
|
||||
!user?.Id
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
// Get watchlist item IDs from Streamystats
|
||||
const watchlistDetail = await streamystatsApi.getWatchlistItemIds({
|
||||
watchlistId,
|
||||
jellyfinServerId,
|
||||
});
|
||||
|
||||
const itemIds = watchlistDetail.data?.items;
|
||||
if (!itemIds?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch full item details from Jellyfin
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
ids: itemIds,
|
||||
fields: [
|
||||
"PrimaryImageAspectRatio",
|
||||
"Genres",
|
||||
"Overview",
|
||||
"DateCreated",
|
||||
],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled:
|
||||
streamystatsEnabled &&
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(watchlistId) &&
|
||||
Boolean(jellyfinServerId) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get the user's own watchlists only (for add-to-watchlist picker)
|
||||
*/
|
||||
export const useMyWatchlistsQuery = () => {
|
||||
const user = useAtomValue(userAtom);
|
||||
const { data: allWatchlists, ...rest } = useWatchlistsQuery();
|
||||
|
||||
const myWatchlists = useMemo(() => {
|
||||
if (!allWatchlists || !user?.Id) return [];
|
||||
return allWatchlists.filter((w) => w.userId === user.Id);
|
||||
}, [allWatchlists, user?.Id]);
|
||||
|
||||
return {
|
||||
data: myWatchlists,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check which of the user's watchlists contain a specific item
|
||||
*/
|
||||
export const useItemInWatchlists = (itemId: string | undefined) => {
|
||||
const { data: myWatchlists } = useMyWatchlistsQuery();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const { jellyfinServerId } = useJellyfinServerId();
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"itemInWatchlists",
|
||||
itemId,
|
||||
jellyfinServerId,
|
||||
settings?.streamyStatsServerUrl,
|
||||
],
|
||||
queryFn: async (): Promise<number[]> => {
|
||||
if (
|
||||
!settings?.streamyStatsServerUrl ||
|
||||
!api?.accessToken ||
|
||||
!itemId ||
|
||||
!jellyfinServerId ||
|
||||
!myWatchlists?.length
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
// Check each watchlist to see if it contains the item
|
||||
const watchlistsContainingItem: number[] = [];
|
||||
|
||||
for (const watchlist of myWatchlists) {
|
||||
try {
|
||||
const detail = await streamystatsApi.getWatchlistItemIds({
|
||||
watchlistId: watchlist.id,
|
||||
jellyfinServerId,
|
||||
});
|
||||
if (detail.data?.items?.includes(itemId)) {
|
||||
watchlistsContainingItem.push(watchlist.id);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors for individual watchlists
|
||||
}
|
||||
}
|
||||
|
||||
return watchlistsContainingItem;
|
||||
},
|
||||
enabled:
|
||||
streamystatsEnabled &&
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(itemId) &&
|
||||
Boolean(jellyfinServerId) &&
|
||||
Boolean(myWatchlists?.length),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user