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:
Alex
2025-08-16 05:34:22 +10:00
committed by GitHub
parent 4fba558c33
commit ca92f61900
94 changed files with 3325 additions and 3523 deletions

View File

@@ -1,11 +1,80 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useGlobalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef, useState } from "react";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
import { ticksToMs } from "@/utils/time";
interface TrickplayData {
interface TrickplayUrl {
x: number;
y: number;
url: string;
}
/** Hook to handle trickplay logic for a given item. */
export const useTrickplay = (item: BaseItemDto) => {
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
const { getDownloadedItemById } = useDownload();
const lastCalculationTime = useRef(0);
const throttleDelay = 200;
const isOffline = useGlobalSearchParams().offline === "true";
const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]);
/** Generates the trickplay URL for the given item and sheet index.
* We change between offline and online trickplay URLs depending on the state of the app. */
const getTrickplayUrl = useCallback(
(item: BaseItemDto, sheetIndex: number) => {
// If we are offline, we can use the downloaded item's trickplay data path
const downloadedItem = getDownloadedItemById(item.Id!);
if (isOffline && downloadedItem?.trickPlayData?.path) {
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
}
return generateTrickplayUrl(item, sheetIndex);
},
[trickplayInfo],
);
/** Calculates the trickplay URL for the current progress. */
const calculateTrickplayUrl = useCallback(
(progress: number) => {
const now = Date.now();
if (
!trickplayInfo ||
!item.Id ||
now - lastCalculationTime.current < throttleDelay
)
return;
lastCalculationTime.current = now;
const { sheetIndex, x, y } = calculateTrickplayTile(
progress,
trickplayInfo,
);
const url = getTrickplayUrl(item, sheetIndex);
if (url) setTrickPlayUrl({ x, y, url });
},
[trickplayInfo, item, throttleDelay, getTrickplayUrl],
);
/** Prefetches all the trickplay images for the item. */
const prefetchAllTrickplayImages = useCallback(() => {
if (!trickplayInfo || !item.Id) return;
for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
const url = getTrickplayUrl(item, index);
if (url) Image.prefetch(url);
}
}, [trickplayInfo, item, getTrickplayUrl]);
return {
trickPlayUrl,
calculateTrickplayUrl,
prefetchAllTrickplayImages,
trickplayInfo,
};
};
export interface TrickplayData {
Interval?: number;
TileWidth?: number;
TileHeight?: number;
@@ -14,136 +83,93 @@ interface TrickplayData {
ThumbnailCount?: number;
}
interface TrickplayUrl {
x: number;
y: number;
url: string;
export interface TrickplayInfo {
resolution: string;
aspectRatio: number;
data: TrickplayData;
totalImageSheets: number;
}
export const useTrickplay = (item: BaseItemDto, enabled = true) => {
const [api] = useAtom(apiAtom);
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
const lastCalculationTime = useRef(0);
const throttleDelay = 200; // 200ms throttle
/** Generates a trickplay URL based on the item, resolution, and sheet index. */
export const generateTrickplayUrl = (item: BaseItemDto, sheetIndex: number) => {
const api = store.get(apiAtom);
const resolution = getTrickplayInfo(item)?.resolution;
if (!resolution || !api) return null;
return `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${sheetIndex}.jpg?api_key=${api.accessToken}`;
};
const trickplayInfo = useMemo(() => {
if (!enabled || !item.Id || !item.Trickplay) {
return null;
}
/**
* Parses the trickplay metadata from a BaseItemDto.
* @param item The Jellyfin media item.
* @returns Parsed trickplay information or null if not available.
*/
export const getTrickplayInfo = (item: BaseItemDto): TrickplayInfo | null => {
if (!item.Id || !item.Trickplay) return null;
const mediaSourceId = item.Id;
const trickplayData: Record<string, TrickplayData> | undefined =
item.Trickplay[mediaSourceId];
const mediaSourceId = item.Id;
const trickplayDataForSource = item.Trickplay[mediaSourceId];
if (!trickplayData) {
return null;
}
if (!trickplayDataForSource) {
return null;
}
// Get the first available resolution
const firstResolution = Object.keys(trickplayData)[0];
return firstResolution
? {
resolution: firstResolution,
aspectRatio:
trickplayData[firstResolution].Width! /
trickplayData[firstResolution].Height!,
data: trickplayData[firstResolution],
}
: null;
}, [item, enabled]);
const firstResolution = Object.keys(trickplayDataForSource)[0];
if (!firstResolution) {
return null;
}
// Takes in ticks.
const calculateTrickplayUrl = useCallback(
(progress: number) => {
if (!enabled) {
return null;
}
const data = trickplayDataForSource[firstResolution];
const { Interval, TileWidth, TileHeight, Width, Height } = data;
const now = Date.now();
if (now - lastCalculationTime.current < throttleDelay) {
return null;
}
lastCalculationTime.current = now;
if (
!Interval ||
!TileWidth ||
!TileHeight ||
!Width ||
!Height ||
!item.RunTimeTicks
) {
return null;
}
if (!trickplayInfo || !api || !item.Id) {
return null;
}
const { data, resolution } = trickplayInfo;
const { Interval, TileWidth, TileHeight, Width, Height } = data;
if (
!Interval ||
!TileWidth ||
!TileHeight ||
!resolution ||
!Width ||
!Height
) {
throw new Error("Invalid trickplay data");
}
const currentTimeMs = Math.max(0, ticksToMs(progress));
const currentTile = Math.floor(currentTimeMs / Interval);
const tileSize = TileWidth * TileHeight;
const tileOffset = currentTile % tileSize;
const index = Math.floor(currentTile / tileSize);
const tileOffsetX = tileOffset % TileWidth;
const tileOffsetY = Math.floor(tileOffset / TileWidth);
const newTrickPlayUrl = {
x: tileOffsetX,
y: tileOffsetY,
url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`,
};
setTrickPlayUrl(newTrickPlayUrl);
return newTrickPlayUrl;
},
[trickplayInfo, item, api, enabled],
);
const prefetchAllTrickplayImages = useCallback(() => {
if (!api || !enabled || !trickplayInfo || !item.Id || !item.RunTimeTicks) {
return;
}
const { data, resolution } = trickplayInfo;
const { Interval, TileWidth, TileHeight, Width, Height } = data;
if (
!Interval ||
!TileWidth ||
!TileHeight ||
!resolution ||
!Width ||
!Height
) {
throw new Error("Invalid trickplay data");
}
// Calculate tiles per sheet
const tilesPerRow = TileWidth;
const tilesPerColumn = TileHeight;
const tilesPerSheet = tilesPerRow * tilesPerColumn;
const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
const totalIndexes = Math.ceil(totalTiles / tilesPerSheet);
// Prefetch all trickplay images
for (let index = 0; index < totalIndexes; index++) {
const url = `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`;
Image.prefetch(url);
}
}, [trickplayInfo, item, api, enabled]);
const tilesPerSheet = TileWidth * TileHeight;
const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
const totalImageSheets = Math.ceil(totalTiles / tilesPerSheet);
return {
trickPlayUrl: enabled ? trickPlayUrl : null,
calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null,
prefetchAllTrickplayImages: enabled
? prefetchAllTrickplayImages
: () => null,
trickplayInfo: enabled ? trickplayInfo : null,
resolution: firstResolution,
aspectRatio: Width / Height,
data,
totalImageSheets,
};
};
/**
* Calculates the specific image sheet and tile offset for a given time.
* @param progressTicks The current playback time in ticks.
* @param trickplayInfo The parsed trickplay information object.
* @returns An object with the image sheet index, and the X/Y coordinates for the tile.
*/
const calculateTrickplayTile = (
progressTicks: number,
trickplayInfo: TrickplayInfo,
) => {
const { data } = trickplayInfo;
const { Interval, TileWidth, TileHeight } = data;
if (!Interval || !TileWidth || !TileHeight) {
throw new Error("Invalid trickplay data provided to calculateTile");
}
const currentTimeMs = Math.max(0, ticksToMs(progressTicks));
const currentTile = Math.floor(currentTimeMs / Interval);
const tilesPerSheet = TileWidth * TileHeight;
const sheetIndex = Math.floor(currentTile / tilesPerSheet);
const tileIndexInSheet = currentTile % tilesPerSheet;
const x = tileIndexInSheet % TileWidth;
const y = Math.floor(tileIndexInSheet / TileWidth);
return { sheetIndex, x, y };
};