forked from Ninjalama/streamyfin_mirror
New design for syncing playback
This commit is contained in:
@@ -2,7 +2,6 @@ import {
|
||||
type BaseItemDto,
|
||||
type MediaSourceInfo,
|
||||
PlaybackOrder,
|
||||
type PlaybackProgressInfo,
|
||||
PlaybackStartInfo,
|
||||
RepeatMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
@@ -23,6 +22,7 @@ import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { VlcPlayerView } from "@/modules";
|
||||
@@ -108,6 +108,7 @@ export default function page() {
|
||||
const [settings] = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const offline = offlineStr === "true";
|
||||
const playbackManager = usePlaybackManager();
|
||||
|
||||
const audioIndex = audioIndexStr
|
||||
? Number.parseInt(audioIndexStr, 10)
|
||||
@@ -253,7 +254,10 @@ export default function page() {
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
reportPlaybackProgress();
|
||||
playbackManager.reportPlaybackProgress(
|
||||
item?.Id!,
|
||||
msToTicks(progress.get()),
|
||||
);
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
await getPlaystateApi(api!).reportPlaybackStart({
|
||||
@@ -331,7 +335,10 @@ export default function page() {
|
||||
|
||||
if (!item?.Id) return;
|
||||
|
||||
reportPlaybackProgress();
|
||||
playbackManager.reportPlaybackProgress(
|
||||
item.Id,
|
||||
msToTicks(progress.get()),
|
||||
);
|
||||
},
|
||||
[
|
||||
item?.Id,
|
||||
@@ -351,42 +358,6 @@ export default function page() {
|
||||
setIsPipStarted(pipStarted);
|
||||
}, []);
|
||||
|
||||
const reportPlaybackProgress = useCallback(async () => {
|
||||
// If offline we constant want to be writing to our local cache t
|
||||
if (offline) {
|
||||
const downloadedItem = downloadUtils.getDownloadedItemById(itemId);
|
||||
if (downloadedItem) {
|
||||
downloadedItem.item.UserData = {
|
||||
...downloadedItem.item.UserData,
|
||||
PlaybackPositionTicks: msToTicks(progress.get()),
|
||||
LastPlayedDate: new Date().toISOString(),
|
||||
PlayedPercentage: downloadedItem.item.RunTimeTicks
|
||||
? (msToTicks(progress.get()) / downloadedItem.item.RunTimeTicks) *
|
||||
100
|
||||
: 0,
|
||||
Played: false,
|
||||
};
|
||||
downloadUtils.updateDownloadedItem(itemId, downloadedItem);
|
||||
}
|
||||
console.log("reported playback progress", itemId, progress.get());
|
||||
return;
|
||||
}
|
||||
if (!api || !stream) return;
|
||||
await getPlaystateApi(api).reportPlaybackProgress({
|
||||
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
isPlaying,
|
||||
offline,
|
||||
stream,
|
||||
item?.Id,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
progress,
|
||||
]);
|
||||
|
||||
/** Gets the initial playback position in seconds. */
|
||||
const startPosition = useMemo(() => {
|
||||
return ticksToSeconds(getInitialPlaybackTicks());
|
||||
@@ -475,14 +446,24 @@ export default function page() {
|
||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||
if (state === "Playing") {
|
||||
setIsPlaying(true);
|
||||
reportPlaybackProgress();
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
item.Id,
|
||||
msToTicks(progress.get()),
|
||||
);
|
||||
}
|
||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "Paused") {
|
||||
setIsPlaying(false);
|
||||
reportPlaybackProgress();
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
item.Id,
|
||||
msToTicks(progress.get()),
|
||||
);
|
||||
}
|
||||
if (!Platform.isTV) await deactivateKeepAwake();
|
||||
return;
|
||||
}
|
||||
@@ -494,7 +475,7 @@ export default function page() {
|
||||
setIsBuffering(true);
|
||||
}
|
||||
},
|
||||
[reportPlaybackProgress],
|
||||
[playbackManager, item?.Id, progress],
|
||||
);
|
||||
|
||||
const allAudio =
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { QueryKey, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { togglePlayState } from "@/utils/jellyfin/playstate/togglePlayState";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
import { usePlaybackManager } from "./usePlaybackManager";
|
||||
import { useInvalidatePlaybackProgressCache } from "./useRevalidatePlaybackProgressCache";
|
||||
|
||||
export const useMarkAsPlayed = (items: BaseItemDto[], isOffline = false) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
export const useMarkAsPlayed = (items: BaseItemDto[]) => {
|
||||
const queryClient = useQueryClient();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const downloads = useDownload();
|
||||
const { markItemPlayed, markItemUnplayed } = usePlaybackManager();
|
||||
const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache();
|
||||
const invalidateQueries = async () => {
|
||||
const queriesToInvalidate: QueryKey[] = [];
|
||||
@@ -31,16 +26,10 @@ export const useMarkAsPlayed = (items: BaseItemDto[], isOffline = false) => {
|
||||
lightHapticFeedback();
|
||||
// Process all items
|
||||
await Promise.all(
|
||||
items.map((item) =>
|
||||
togglePlayState({
|
||||
api,
|
||||
item,
|
||||
userId: user?.Id,
|
||||
isOffline,
|
||||
downloads,
|
||||
played,
|
||||
}),
|
||||
),
|
||||
items.map((item) => {
|
||||
if (!item.Id) return Promise.resolve();
|
||||
return played ? markItemPlayed(item.Id) : markItemUnplayed(item.Id);
|
||||
}),
|
||||
);
|
||||
invalidatePlaybackProgressCache();
|
||||
invalidateQueries();
|
||||
|
||||
195
hooks/usePlaybackManager.ts
Normal file
195
hooks/usePlaybackManager.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api/user-library-api";
|
||||
import { useNetInfo } from "@react-native-community/netinfo";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
/**
|
||||
* 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 = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const netInfo = useNetInfo();
|
||||
const { getDownloadedItemById, updateDownloadedItem } = useDownload();
|
||||
|
||||
const isOnline = netInfo.isConnected;
|
||||
|
||||
/**
|
||||
* Fetches the latest state of an item from the server and updates the local
|
||||
* downloaded version to match. This ensures the local item has the
|
||||
* canonical state from the server.
|
||||
*/
|
||||
const _syncRemoteToLocal = async (localItem: DownloadedItem) => {
|
||||
if (!isOnline || !api || !user) return;
|
||||
|
||||
try {
|
||||
const remoteItem = (
|
||||
await getUserLibraryApi(api).getItem({
|
||||
itemId: localItem.item.Id!,
|
||||
userId: user.Id,
|
||||
})
|
||||
).data;
|
||||
if (remoteItem) {
|
||||
updateDownloadedItem(localItem.item.Id!, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: { ...remoteItem.UserData },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to sync remote item state to local", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) => {
|
||||
const localItem = getDownloadedItemById(itemId);
|
||||
|
||||
// Handle local state update for downloaded items
|
||||
if (localItem) {
|
||||
updateDownloadedItem(itemId, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...localItem.item.UserData,
|
||||
PlaybackPositionTicks: positionTicks,
|
||||
LastPlayedDate: new Date().toISOString(),
|
||||
PlayedPercentage:
|
||||
(positionTicks / localItem.item.RunTimeTicks!) * 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
if (isOnline && api) {
|
||||
await getPlaystateApi(api).reportPlaybackProgress({
|
||||
playbackProgressInfo: {
|
||||
ItemId: itemId,
|
||||
PositionTicks: positionTicks,
|
||||
} as PlaybackProgressInfo,
|
||||
});
|
||||
|
||||
// If it was a downloaded item, re-sync with the server for the latest state.
|
||||
// This is crucial because the server might have marked the item as "Played"
|
||||
// based on its own rules (e.g., >95% progress).
|
||||
if (localItem) {
|
||||
await _syncRemoteToLocal(localItem);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 && !isOnline) {
|
||||
updateDownloadedItem(itemId, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...localItem.item.UserData,
|
||||
Played: true,
|
||||
LastPlayedDate: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
if (isOnline && api && user) {
|
||||
try {
|
||||
await getPlaystateApi(api).markPlayedItem({
|
||||
itemId,
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
// If it was a downloaded item, re-sync with server for the latest state
|
||||
if (localItem) {
|
||||
await _syncRemoteToLocal(localItem);
|
||||
}
|
||||
} 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 && !isOnline) {
|
||||
updateDownloadedItem(itemId, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...localItem.item.UserData,
|
||||
Played: false,
|
||||
PlaybackPositionTicks: 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,
|
||||
});
|
||||
|
||||
// If it was a downloaded item, re-sync with server for the latest state
|
||||
if (localItem) {
|
||||
await _syncRemoteToLocal(localItem);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to mark item as unplayed on server", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { reportPlaybackProgress, markItemPlayed, markItemUnplayed };
|
||||
};
|
||||
@@ -1,36 +1,40 @@
|
||||
import { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api/playstate-api";
|
||||
import { useNetInfo } from "@react-native-community/netinfo";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { apiAtom, userAtom } from "../providers/JellyfinProvider";
|
||||
import { usePlaybackManager } from "./usePlaybackManager";
|
||||
|
||||
/**
|
||||
* This hook is used to sync the playback state of a downloaded item with the server.
|
||||
* It ensures that the playback state of a downloaded item is always up to date.
|
||||
*
|
||||
* The syncPlaybackState function returns a Promise<boolean> indicating whether a server update was made (true) or not (false).
|
||||
* This hook is used to sync the playback state of a downloaded item with the server
|
||||
* when the application comes back online after being used offline.
|
||||
*/
|
||||
export const useTwoWaySync = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const [_settings] = useSettings();
|
||||
const netInfo = useNetInfo();
|
||||
const { getDownloadedItemById, updateDownloadedItem } = useDownload();
|
||||
const { reportPlaybackProgress } = usePlaybackManager();
|
||||
|
||||
const syncPlaybackState = async (itemId: string) => {
|
||||
if (!api || !user) {
|
||||
return;
|
||||
/**
|
||||
* Syncs the playback state of an offline item with the server.
|
||||
* It determines if the local or remote state is more recent and applies the necessary update.
|
||||
*
|
||||
* @returns A Promise<boolean> indicating whether a server update was made (true) or not (false).
|
||||
*/
|
||||
const syncPlaybackState = async (itemId: string): Promise<boolean> => {
|
||||
if (!api || !user || !netInfo.isConnected) {
|
||||
// Cannot sync if offline or not logged in
|
||||
return false;
|
||||
}
|
||||
|
||||
const localItem = getDownloadedItemById(itemId);
|
||||
if (!localItem) return;
|
||||
if (!localItem) return false;
|
||||
|
||||
const remoteItem = (
|
||||
await getUserLibraryApi(api).getItem({ itemId, userId: user.Id })
|
||||
).data;
|
||||
if (!remoteItem) return;
|
||||
if (!remoteItem) return false;
|
||||
|
||||
const localLastPlayed = localItem.item.UserData?.LastPlayedDate
|
||||
? new Date(localItem.item.UserData.LastPlayedDate)
|
||||
@@ -39,13 +43,8 @@ export const useTwoWaySync = () => {
|
||||
? new Date(remoteItem.UserData.LastPlayedDate)
|
||||
: new Date(0);
|
||||
|
||||
console.log("localItem", localItem.item.Name);
|
||||
console.log("localLastPlayed", localLastPlayed);
|
||||
console.log("remoteLastPlayed", remoteLastPlayed);
|
||||
|
||||
// Update our local item if the remote has been played more recently.
|
||||
// Another edge case is when the remote item not been played. We want to sync that with the se
|
||||
// If they're same we fall back to the server as the single source of truth.
|
||||
// If the remote item has been played more recently or at the same time,
|
||||
// we take the server's version as the source of truth.
|
||||
if (remoteLastPlayed >= localLastPlayed) {
|
||||
updateDownloadedItem(itemId, {
|
||||
...localItem,
|
||||
@@ -60,81 +59,17 @@ export const useTwoWaySync = () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
return false;
|
||||
} else {
|
||||
console.log("syncing local to remote", localItem.item.Name);
|
||||
await syncLocalToRemote(localItem);
|
||||
// The local item was played more recently (i.e., while offline),
|
||||
// so we need to push its state to the server using our manager.
|
||||
await reportPlaybackProgress(
|
||||
localItem.item.Id!,
|
||||
localItem.item.UserData?.PlaybackPositionTicks || 0,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function is used to sync the playback state of a downloaded item with the server.
|
||||
*/
|
||||
const syncLocalToRemote = async (localItem: DownloadedItem) => {
|
||||
if (!api || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the local Item is marked as completed, we mark it as played on the server.
|
||||
// Report the playback progress to the server.
|
||||
|
||||
console.log(
|
||||
"playback position ticks",
|
||||
localItem.item.UserData?.PlaybackPositionTicks,
|
||||
);
|
||||
|
||||
// If the local item is marked as played, we mark it as played on the server.
|
||||
if (localItem.item.UserData?.Played) {
|
||||
await getPlaystateApi(api).markPlayedItem({
|
||||
itemId: localItem.item.Id!,
|
||||
datePlayed: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
// If the local item is marked as unplayed, we mark it as unplayed on the server.
|
||||
await getPlaystateApi(api).markUnplayedItem({
|
||||
itemId: localItem.item.Id!,
|
||||
});
|
||||
}
|
||||
|
||||
await getPlaystateApi(api).reportPlaybackProgress({
|
||||
playbackProgressInfo: {
|
||||
ItemId: localItem.item.Id,
|
||||
PositionTicks: localItem.item.UserData?.PlaybackPositionTicks,
|
||||
} as PlaybackProgressInfo,
|
||||
});
|
||||
// Pull back whatever the server gives us we use that as the single source of truth.
|
||||
// We do this because sometimes, the server marks things as played when the posistion ticks marks a certain point.
|
||||
const remoteItem = (
|
||||
await getUserLibraryApi(api).getItem({
|
||||
itemId: localItem.item.Id!,
|
||||
userId: user.Id,
|
||||
})
|
||||
).data;
|
||||
if (!remoteItem) {
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
"remote item after synced played status",
|
||||
remoteItem.UserData?.Played,
|
||||
);
|
||||
console.log("remote last played date", remoteItem.UserData?.LastPlayedDate);
|
||||
|
||||
updateDownloadedItem(localItem.item.Id!, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...remoteItem.UserData,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const localItem2 = getDownloadedItemById(localItem.item.Id!);
|
||||
|
||||
console.log("localItem2", localItem2?.item.UserData?.LastPlayedDate);
|
||||
};
|
||||
|
||||
return { syncPlaybackState, syncLocalToRemote };
|
||||
return { syncPlaybackState };
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
/**
|
||||
* Represents the data for downloaded trickplay files.
|
||||
@@ -22,27 +22,57 @@ interface UserData {
|
||||
audioStreamIndex: number;
|
||||
}
|
||||
|
||||
export interface SyncPolicy {
|
||||
type: "series" | "movie";
|
||||
id: string; // SeriesId or MovieId
|
||||
rule: "next_unwatched" | "all";
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export enum SyncStatus {
|
||||
SYNCED = "synced",
|
||||
PENDING_DOWNLOAD = "pending_download",
|
||||
PENDING_DELETION = "pending_deletion",
|
||||
ERROR = "error",
|
||||
}
|
||||
|
||||
/** Represents a segment of time in a media item, used for intro/credit skipping. */
|
||||
export interface MediaTimeSegment {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||
export interface DownloadedItem {
|
||||
/** The full Jellyfin item object. */
|
||||
/** The Jellyfin item DTO. */
|
||||
item: BaseItemDto;
|
||||
/** The local file path to the downloaded video file. The path is already prefixed with file:// */
|
||||
videoFilePath: string;
|
||||
/** The size of the downloaded video file in bytes. */
|
||||
videoFileSize: number;
|
||||
/** The data for the associated trickplay files. */
|
||||
trickPlayData?: TrickPlayData;
|
||||
/**
|
||||
* The specific media source that was downloaded.
|
||||
* Contains info on available audio/subtitle tracks within the file.
|
||||
*/
|
||||
/** The media source information. */
|
||||
mediaSource: MediaSourceInfo;
|
||||
/** The local file path of the downloaded video. */
|
||||
videoFilePath: string;
|
||||
/** The size of the video file in bytes. */
|
||||
videoFileSize: number;
|
||||
/** The local file path of the downloaded trickplay images. */
|
||||
trickPlayData?: TrickPlayData;
|
||||
/** The intro segments for the item. */
|
||||
introSegments?: MediaTimeSegments[];
|
||||
introSegments?: MediaTimeSegment[];
|
||||
/** The credit segments for the item. */
|
||||
creditSegments?: MediaTimeSegments[];
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
/** The user data for the item. */
|
||||
userData?: UserData;
|
||||
offlineUserData: {
|
||||
audioStreamIndex: number;
|
||||
subtitleStreamIndex: number;
|
||||
};
|
||||
syncStatus: SyncStatus;
|
||||
lastSyncedAt: string;
|
||||
serverEtag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,10 +87,16 @@ export interface DownloadedSeason {
|
||||
* Represents a downloaded series, containing seasons and their episodes.
|
||||
*/
|
||||
export interface DownloadedSeries {
|
||||
/** The Jellyfin item object for the series itself. */
|
||||
/** The Jellyfin item DTO for the series. */
|
||||
seriesInfo: BaseItemDto;
|
||||
/** A map of season numbers to their downloaded season data. */
|
||||
seasons: Record<number, DownloadedSeason>;
|
||||
seasons: Record<
|
||||
number,
|
||||
{
|
||||
/** A map of episode numbers to their downloaded episode data. */
|
||||
episodes: Record<number, DownloadedItem>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,8 +104,9 @@ export interface DownloadedSeries {
|
||||
* This object is what will be saved to your local storage.
|
||||
*/
|
||||
export interface DownloadsDatabase {
|
||||
/** A map of movie IDs to their downloaded item data. */
|
||||
/** A map of movie IDs to their downloaded movie data. */
|
||||
movies: Record<string, DownloadedItem>;
|
||||
/** A map of series IDs to their downloaded series data. */
|
||||
series: Record<string, DownloadedSeries>;
|
||||
syncPolicies: SyncPolicy[];
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
import old from "@/utils/profiles/old";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getMediaInfoApi,
|
||||
getPlaystateApi,
|
||||
getSessionApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getAuthHeaders } from "../jellyfin";
|
||||
import { postCapabilities } from "../session/capabilities";
|
||||
|
||||
interface ReportPlaybackProgressParams {
|
||||
api?: Api | null;
|
||||
sessionId?: string | null;
|
||||
itemId?: string | null;
|
||||
positionTicks?: number | null;
|
||||
IsPaused?: boolean;
|
||||
deviceProfile?: Settings["deviceProfile"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports playback progress to the Jellyfin server.
|
||||
*
|
||||
* @param params - The parameters for reporting playback progress
|
||||
* @throws {Error} If any required parameter is missing
|
||||
*/
|
||||
export const reportPlaybackProgress = async ({
|
||||
api,
|
||||
sessionId,
|
||||
itemId,
|
||||
positionTicks,
|
||||
IsPaused = false,
|
||||
deviceProfile,
|
||||
}: ReportPlaybackProgressParams): Promise<void> => {
|
||||
if (!api || !sessionId || !itemId || !positionTicks) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info("reportPlaybackProgress ~ IsPaused", IsPaused);
|
||||
|
||||
try {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId,
|
||||
audioStreamIndex: 0,
|
||||
subtitleStreamIndex: 0,
|
||||
mediaSourceId: itemId,
|
||||
positionTicks: Math.round(positionTicks),
|
||||
isPaused: IsPaused,
|
||||
isMuted: false,
|
||||
playMethod: "Transcode",
|
||||
});
|
||||
// await api.axiosInstance.post(
|
||||
// `${api.basePath}/Sessions/Playing/Progress`,
|
||||
// {
|
||||
// ItemId: itemId,
|
||||
// PlaySessionId: sessionId,
|
||||
// IsPaused,
|
||||
// PositionTicks: Math.round(positionTicks),
|
||||
// CanSeek: true,
|
||||
// MediaSourceId: itemId,
|
||||
// EventName: "timeupdate",
|
||||
// },
|
||||
// { headers: getAuthHeaders(api) }
|
||||
// );
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import type { AxiosError } from "axios";
|
||||
import type { useDownload } from "@/providers/DownloadProvider";
|
||||
|
||||
interface TogglePlayStateParams {
|
||||
api: Api | null | undefined;
|
||||
item: BaseItemDto | null | undefined;
|
||||
userId: string | null | undefined;
|
||||
downloads: ReturnType<typeof useDownload>;
|
||||
isOffline?: boolean;
|
||||
played: boolean;
|
||||
}
|
||||
|
||||
export const togglePlayState = async ({
|
||||
api,
|
||||
item,
|
||||
userId,
|
||||
downloads,
|
||||
isOffline = false,
|
||||
played,
|
||||
}: TogglePlayStateParams): Promise<boolean> => {
|
||||
if (!item?.Id) {
|
||||
console.error("Invalid item for togglePlayState");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isOffline) {
|
||||
const downloadedItem = downloads.getDownloadedItemById(item.Id);
|
||||
if (downloadedItem) {
|
||||
const newUserData = played
|
||||
? {
|
||||
Played: true,
|
||||
LastPlayedDate: new Date().toISOString(),
|
||||
PlaybackPositionTicks: 0,
|
||||
}
|
||||
: {
|
||||
Played: false,
|
||||
LastPlayedDate: new Date().toISOString(),
|
||||
PlaybackPositionTicks: 0,
|
||||
PlayedPercentage: 0,
|
||||
};
|
||||
|
||||
downloads.updateDownloadedItem(item.Id, {
|
||||
...downloadedItem,
|
||||
item: {
|
||||
...downloadedItem.item,
|
||||
UserData: {
|
||||
...downloadedItem.item.UserData,
|
||||
...newUserData,
|
||||
},
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!api || !userId) {
|
||||
console.error("Invalid parameters for online togglePlayState");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (played) {
|
||||
if (!item.RunTimeTicks) {
|
||||
console.error(
|
||||
"Invalid parameters for markAsPlayed (RunTimeTicks missing)",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const response = await getPlaystateApi(api).markPlayedItem({
|
||||
itemId: item.Id,
|
||||
datePlayed: new Date().toISOString(),
|
||||
});
|
||||
return response.status === 200;
|
||||
} else {
|
||||
await api.axiosInstance.delete(
|
||||
`${api.basePath}/UserPlayedItems/${item.Id}`,
|
||||
{
|
||||
params: { userId },
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
console.error(
|
||||
`Failed to toggle play state to ${played}:`,
|
||||
axiosError.message,
|
||||
axiosError.response?.status,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user