diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 937c32092..594e2fae9 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -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(() => { diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx index bb87ef12e..e3bba0a98 100644 --- a/components/series/SeasonEpisodesCarousel.tsx +++ b/components/series/SeasonEpisodesCarousel.tsx @@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC = ({ }) => { 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(null); @@ -100,7 +104,7 @@ export const SeasonEpisodesCarousel: React.FC = ({ onPress={() => { router.setParams({ id: _item.Id }); }} - className={`flex flex-col w-44 + className={`flex flex-col w-44 ${item?.Id === _item.Id ? "" : "opacity-50"} `} > diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 7a6086bad..3651876a4 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -365,7 +365,9 @@ export const Controls: FC = ({ { 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 = ({ 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(() => { diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx index b87215b3a..13c02f008 100644 --- a/components/video-player/controls/EpisodeList.tsx +++ b/components/video-player/controls/EpisodeList.tsx @@ -57,6 +57,11 @@ export const EpisodeList: React.FC = ({ 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 ?? ""]; diff --git a/hooks/useImageStorage.ts b/hooks/useImageStorage.ts index ec66c5053..b5b6896ca 100644 --- a/hooks/useImageStorage.ts +++ b/hooks/useImageStorage.ts @@ -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((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( diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts index b4316241a..94abb98b1 100644 --- a/hooks/usePlaybackManager.ts +++ b/hooks/usePlaybackManager.ts @@ -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. diff --git a/providers/Downloads/database.ts b/providers/Downloads/database.ts index 667a5b4dc..9d77ad2a0 100644 --- a/providers/Downloads/database.ts +++ b/providers/Downloads/database.ts @@ -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 | 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(); + 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 }