New design for syncing playback

This commit is contained in:
Alex Kim
2025-07-18 03:04:21 +10:00
parent 2342c776f2
commit 32a1bbe7de
7 changed files with 308 additions and 340 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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