mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-03 04:28:31 +01:00
Merge branch 'develop' into cleanup/dead-code
This commit is contained in:
@@ -274,6 +274,11 @@ export default function DirectPlayerPage() {
|
||||
};
|
||||
|
||||
if (itemId) {
|
||||
setItem(null);
|
||||
setDownloadedItem(null);
|
||||
// Clear the previous episode's stream so the loader gate stays closed
|
||||
// until the new item's stream resolves (avoids a stale MPV source frame).
|
||||
setStream(null);
|
||||
fetchItemData();
|
||||
}
|
||||
}, [itemId, offline, api, user?.Id]);
|
||||
@@ -316,6 +321,12 @@ export default function DirectPlayerPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure item matches the current itemId to avoid race conditions
|
||||
if (item.Id !== itemId) {
|
||||
setStreamStatus({ isLoading: false, isError: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
let result: Stream | null = null;
|
||||
if (offline && downloadedItem?.mediaSource) {
|
||||
const url = downloadedItem.videoFilePath;
|
||||
@@ -388,6 +399,7 @@ export default function DirectPlayerPage() {
|
||||
item,
|
||||
user?.Id,
|
||||
downloadedItem,
|
||||
offline,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot, so refetches after
|
||||
// updateDownloadedItem() reflect the latest state instead of a stale
|
||||
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
@@ -100,7 +104,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
onPress={() => {
|
||||
router.setParams({ id: _item.Id });
|
||||
}}
|
||||
className={`flex flex-col w-44
|
||||
className={`flex flex-col w-44
|
||||
${item?.Id === _item.Id ? "" : "opacity-50"}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -365,7 +365,9 @@ export const Controls: FC<Props> = ({
|
||||
{ applyLanguagePreferences: true },
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
// Use setParams instead of replace to avoid unmounting/remounting the player,
|
||||
// which would create a new MPV native view and crash with "mp_initialize already initialized".
|
||||
router.setParams({
|
||||
...(offline && { offline: "true" }),
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
@@ -374,11 +376,17 @@ export const Controls: FC<Props> = ({
|
||||
bitrateValue: bitrateValue?.toString(),
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
});
|
||||
},
|
||||
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
||||
[
|
||||
settings,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
mediaSource,
|
||||
bitrateValue,
|
||||
router,
|
||||
offline,
|
||||
],
|
||||
);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
|
||||
@@ -57,6 +57,11 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot. The snapshot only refreshes on the
|
||||
// provider refreshKey, so after updateDownloadedItem() invalidates
|
||||
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
|
||||
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import { useCallback } from "react";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
@@ -12,36 +13,28 @@ const useImageStorage = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
|
||||
* resolves to an empty payload under RN's New Architecture.
|
||||
*/
|
||||
const image2Base64 = useCallback(async (url?: string | null) => {
|
||||
if (!url) return null;
|
||||
|
||||
let blob: Blob;
|
||||
const tmpFile = new File(
|
||||
Paths.cache,
|
||||
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
|
||||
);
|
||||
try {
|
||||
// Fetch the data from the URL
|
||||
const response = await fetch(url);
|
||||
blob = await response.blob();
|
||||
const downloaded = await File.downloadFileAsync(url, tmpFile, {
|
||||
idempotent: true,
|
||||
});
|
||||
return await downloaded.base64();
|
||||
} catch (error) {
|
||||
console.warn("Error fetching image:", error);
|
||||
return null;
|
||||
} finally {
|
||||
if (tmpFile.exists) tmpFile.delete();
|
||||
}
|
||||
|
||||
// Create a FileReader instance
|
||||
const reader = new FileReader();
|
||||
|
||||
// Convert blob to base64
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
// Extract the base64 string (remove the data URL prefix)
|
||||
const base64 = reader.result.split(",")[1];
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to convert image to base64"));
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveImage = useCallback(
|
||||
|
||||
@@ -109,30 +109,35 @@ export const usePlaybackManager = ({
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Derive prev/next from the current item's real position in the adjacent
|
||||
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
|
||||
* not guarantee a fixed [prev, current, next] shape — at the first/last
|
||||
* episode it can still return the current item as the first/last entry — so
|
||||
* length-based indexing wrongly surfaces the current episode as "previous".
|
||||
*/
|
||||
const currentIndex = useMemo(
|
||||
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
|
||||
[adjacentItems, item],
|
||||
);
|
||||
|
||||
/** A neighbour is only navigable if it has an actual media file (not a
|
||||
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
|
||||
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
|
||||
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
|
||||
|
||||
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]);
|
||||
if (!adjacentItems || currentIndex <= 0) return null;
|
||||
const candidate = adjacentItems[currentIndex - 1];
|
||||
return isNavigable(candidate) ? candidate : null;
|
||||
}, [adjacentItems, currentIndex, 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]);
|
||||
if (!adjacentItems || currentIndex < 0) return null;
|
||||
const candidate = adjacentItems[currentIndex + 1];
|
||||
return isNavigable(candidate) ? candidate : null;
|
||||
}, [adjacentItems, currentIndex, item]);
|
||||
|
||||
/**
|
||||
* Reports playback progress.
|
||||
|
||||
@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
|
||||
|
||||
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
||||
|
||||
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
|
||||
let cachedDb: DownloadsDatabase | null = null;
|
||||
let cacheVersion = 0;
|
||||
|
||||
// Performance optimization: Cache the flattened items array
|
||||
let cachedItems: DownloadedItem[] | null = null;
|
||||
let itemsCacheVersion = -1;
|
||||
|
||||
// Performance optimization: Index for O(1) item lookups by ID
|
||||
let itemIndex: Map<string, DownloadedItem> | null = null;
|
||||
let indexCacheVersion = -1;
|
||||
|
||||
/**
|
||||
* Get the downloads database from storage
|
||||
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
|
||||
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
|
||||
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
|
||||
* saveDownloadsDatabase() runs and the derived caches stay consistent.
|
||||
*/
|
||||
export function getDownloadsDatabase(): DownloadsDatabase {
|
||||
// Return cached database if available
|
||||
if (cachedDb !== null) {
|
||||
return cachedDb;
|
||||
}
|
||||
|
||||
// Parse from storage and cache the result
|
||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||
if (file) {
|
||||
return JSON.parse(file) as DownloadsDatabase;
|
||||
cachedDb = JSON.parse(file) as DownloadsDatabase;
|
||||
return cachedDb;
|
||||
}
|
||||
return { movies: {}, series: {}, other: {} };
|
||||
|
||||
const emptyDb = { movies: {}, series: {}, other: {} };
|
||||
cachedDb = emptyDb;
|
||||
return emptyDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the downloads database to storage
|
||||
* PERFORMANCE: Updates cache and invalidates derived caches
|
||||
*/
|
||||
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
||||
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
||||
// Update the cache with the new database
|
||||
cachedDb = db;
|
||||
// Invalidate derived caches (items array and index)
|
||||
cachedItems = null;
|
||||
itemIndex = null;
|
||||
cacheVersion++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all downloaded items as a flat array
|
||||
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
|
||||
*/
|
||||
export function getAllDownloadedItems(): DownloadedItem[] {
|
||||
// Return cached items if available and up-to-date
|
||||
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
|
||||
return cachedItems;
|
||||
}
|
||||
|
||||
// Build the items array from the database
|
||||
const db = getDownloadsDatabase();
|
||||
const items: DownloadedItem[] = [];
|
||||
|
||||
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
cachedItems = items;
|
||||
itemsCacheVersion = cacheVersion;
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a downloaded item by its ID
|
||||
* Build or refresh the item index for O(1) lookups
|
||||
*/
|
||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||
const db = getDownloadsDatabase();
|
||||
|
||||
if (db.movies[id]) {
|
||||
return db.movies[id];
|
||||
function ensureItemIndex(): void {
|
||||
if (itemIndex !== null && indexCacheVersion === cacheVersion) {
|
||||
return; // Index is up-to-date
|
||||
}
|
||||
|
||||
for (const series of Object.values(db.series)) {
|
||||
for (const season of Object.values(series.seasons)) {
|
||||
for (const episode of Object.values(season.episodes)) {
|
||||
if (episode.item.Id === id) {
|
||||
return episode;
|
||||
}
|
||||
}
|
||||
// Build new index from all items
|
||||
itemIndex = new Map<string, DownloadedItem>();
|
||||
const items = getAllDownloadedItems();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.item.Id) {
|
||||
itemIndex.set(item.item.Id, item);
|
||||
}
|
||||
}
|
||||
|
||||
if (db.other?.[id]) {
|
||||
return db.other[id];
|
||||
}
|
||||
indexCacheVersion = cacheVersion;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
/**
|
||||
* Get a downloaded item by its ID
|
||||
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
|
||||
*/
|
||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||
ensureItemIndex();
|
||||
return itemIndex!.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,4 +268,5 @@ export function updateDownloadedItem(
|
||||
*/
|
||||
export function clearAllDownloadedItems(): void {
|
||||
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
||||
// saveDownloadsDatabase already invalidates caches
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user