forked from Ninjalama/streamyfin_mirror
refactor: Feature/offline mode rework (#859)
Co-authored-by: lostb1t <coding-mosses0z@icloud.com> Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com> Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com> Co-authored-by: Gauvino <uruknarb20@gmail.com> Co-authored-by: storm1er <le.storm1er@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Chris <182387676+whoopsi-daisy@users.noreply.github.com> Co-authored-by: arch-fan <55891793+arch-fan@users.noreply.github.com> Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
This commit is contained in:
284
hooks/usePlaybackManager.ts
Normal file
284
hooks/usePlaybackManager.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useNetInfo } from "@react-native-community/netinfo";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
interface PlaybackManagerProps {
|
||||
item?: BaseItemDto | null;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets adjacent items (previous/current/next) for offline mode from downloaded files
|
||||
*/
|
||||
const getOfflineAdjacentItems = (
|
||||
item: BaseItemDto,
|
||||
downloadedFiles: DownloadedItem[],
|
||||
): BaseItemDto[] | null => {
|
||||
if (!item.SeriesId || !downloadedFiles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seriesEpisodes = downloadedFiles
|
||||
.filter((f) => f.item.SeriesId === item.SeriesId)
|
||||
.map((f) => f.item);
|
||||
|
||||
seriesEpisodes.sort((a, b) => {
|
||||
if (a.ParentIndexNumber !== b.ParentIndexNumber) {
|
||||
return (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0);
|
||||
}
|
||||
return (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0);
|
||||
});
|
||||
|
||||
const currentIndex = seriesEpisodes.findIndex((ep) => ep.Id === item.Id);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: BaseItemDto[] = [];
|
||||
if (currentIndex > 0) {
|
||||
result.push(seriesEpisodes[currentIndex - 1]);
|
||||
}
|
||||
result.push(seriesEpisodes[currentIndex]);
|
||||
if (currentIndex < seriesEpisodes.length - 1) {
|
||||
result.push(seriesEpisodes[currentIndex + 1]);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook to manage playback state, abstracting away the complexities of
|
||||
* online/offline and local/remote state management.
|
||||
*
|
||||
* This provides a simple facade for player components to report playback
|
||||
* without needing to know the underlying details of data syncing.
|
||||
*/
|
||||
export const usePlaybackManager = ({
|
||||
item,
|
||||
isOffline = false,
|
||||
}: PlaybackManagerProps = {}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const netInfo = useNetInfo();
|
||||
const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } =
|
||||
useDownload();
|
||||
|
||||
/** Whether the device is online. actually it's connected to the internet. */
|
||||
const isOnline = netInfo.isConnected;
|
||||
|
||||
// Adjacent episodes logic
|
||||
const { data: adjacentItems } = useQuery({
|
||||
queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
|
||||
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||
if (!item || !item.SeriesId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isOffline) {
|
||||
return getOfflineAdjacentItems(item, getDownloadedItems() || []);
|
||||
}
|
||||
|
||||
if (!api) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: item.SeriesId,
|
||||
adjacentTo: item.Id,
|
||||
limit: 3,
|
||||
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
||||
});
|
||||
|
||||
return res.data.Items || null;
|
||||
},
|
||||
enabled:
|
||||
(isOffline || !!api) &&
|
||||
!!item?.Id &&
|
||||
!!item?.SeriesId &&
|
||||
(item?.Type === "Episode" || item?.Type === "Audio"),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const previousItem = useMemo(() => {
|
||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adjacentItems.length === 2) {
|
||||
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||
}
|
||||
|
||||
return adjacentItems[0];
|
||||
}, [adjacentItems, item]);
|
||||
|
||||
/** The next item in the series */
|
||||
const nextItem = useMemo(() => {
|
||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adjacentItems.length === 2) {
|
||||
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||
}
|
||||
|
||||
return adjacentItems[2];
|
||||
}, [adjacentItems, item]);
|
||||
|
||||
/**
|
||||
* Reports playback progress.
|
||||
*
|
||||
* - If offline and the item is downloaded, updates are saved locally.
|
||||
* - If online and the item is downloaded, it updates locally and syncs with the server.
|
||||
* - If online and streaming, it reports directly to the server.
|
||||
*
|
||||
* @param itemId The ID of the item.
|
||||
* @param positionTicks The current playback position in ticks.
|
||||
*/
|
||||
const reportPlaybackProgress = async (
|
||||
itemId: string,
|
||||
positionTicks: number,
|
||||
metadata?: {
|
||||
AudioStreamIndex: number;
|
||||
SubtitleStreamIndex: number;
|
||||
},
|
||||
) => {
|
||||
const localItem = getDownloadedItemById(itemId);
|
||||
|
||||
// Handle local state update for downloaded items
|
||||
if (localItem) {
|
||||
const isItemConsideredPlayed =
|
||||
(localItem.item.UserData?.PlayedPercentage ?? 0) > 90;
|
||||
updateDownloadedItem(itemId, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...localItem.item.UserData,
|
||||
PlaybackPositionTicks: isItemConsideredPlayed
|
||||
? 0
|
||||
: Math.floor(positionTicks),
|
||||
Played: isItemConsideredPlayed,
|
||||
LastPlayedDate: new Date().toISOString(),
|
||||
PlayedPercentage: isItemConsideredPlayed
|
||||
? 0
|
||||
: (positionTicks / localItem.item.RunTimeTicks!) * 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
if (isOnline && api) {
|
||||
try {
|
||||
await getPlaystateApi(api).reportPlaybackProgress({
|
||||
playbackProgressInfo: {
|
||||
ItemId: itemId,
|
||||
PositionTicks: Math.floor(positionTicks),
|
||||
...(metadata && { AudioStreamIndex: metadata.AudioStreamIndex }),
|
||||
...(metadata && {
|
||||
SubtitleStreamIndex: metadata.SubtitleStreamIndex,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to report playback progress", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks an item as played.
|
||||
*
|
||||
* - If offline and downloaded, it marks as played locally.
|
||||
* - If online, it marks as played on the server and syncs the state back to the local item if it exists.
|
||||
*
|
||||
* @param itemId The ID of the item.
|
||||
*/
|
||||
const markItemPlayed = async (itemId: string) => {
|
||||
const localItem = getDownloadedItemById(itemId);
|
||||
|
||||
// Handle local state update for downloaded items
|
||||
if (localItem) {
|
||||
updateDownloadedItem(itemId, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...localItem.item.UserData,
|
||||
Played: true,
|
||||
PlaybackPositionTicks: 0,
|
||||
PlayedPercentage: 0,
|
||||
LastPlayedDate: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
if (isOnline && api && user) {
|
||||
try {
|
||||
await getPlaystateApi(api).markPlayedItem({
|
||||
itemId,
|
||||
userId: user.Id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to mark item as played on server", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks an item as unplayed.
|
||||
*
|
||||
* - If offline and downloaded, it marks as unplayed locally.
|
||||
* - If online, it marks as unplayed on the server and syncs the state back to the local item if it exists.
|
||||
*
|
||||
* @param itemId The ID of the item.
|
||||
*/
|
||||
const markItemUnplayed = async (itemId: string) => {
|
||||
const localItem = getDownloadedItemById(itemId);
|
||||
|
||||
// Handle local state update for downloaded items
|
||||
if (localItem) {
|
||||
updateDownloadedItem(itemId, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...localItem.item.UserData,
|
||||
Played: false,
|
||||
PlaybackPositionTicks: 0,
|
||||
PlayedPercentage: 0,
|
||||
LastPlayedDate: new Date().toISOString(), // Keep track of when it was marked unplayed
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
if (isOnline && api && user) {
|
||||
try {
|
||||
await getPlaystateApi(api).markUnplayedItem({
|
||||
itemId,
|
||||
userId: user.Id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to mark item as unplayed on server", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
reportPlaybackProgress,
|
||||
markItemPlayed,
|
||||
markItemUnplayed,
|
||||
previousItem,
|
||||
nextItem,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user