From ad54823f96592b45dc75de15567b588108884094 Mon Sep 17 00:00:00 2001 From: Alex <111128610+Alexk2309@users.noreply.github.com> Date: Mon, 12 Jan 2026 03:38:41 +1100 Subject: [PATCH] refactor: downloads to minimize prop drilling and improve layout and design (#1337) Co-authored-by: Alex Kim Co-authored-by: Fredrik Burmester Co-authored-by: Simon-Eklundh --- .claude/learned-facts.md | 2 +- CLAUDE.md | 15 ++ app/(auth)/(tabs)/(home)/_layout.tsx | 22 +- .../(tabs)/(home)/downloads/[seriesId].tsx | 181 --------------- app/(auth)/(tabs)/(home)/downloads/index.tsx | 218 +++++++----------- app/(auth)/(tabs)/(home)/settings.tsx | 3 +- .../items/page.tsx | 63 +++-- .../jellyseerr/page.tsx | 3 +- .../series/[id].tsx | 199 ++++++++++------ app/(auth)/(tabs)/(search)/index.tsx | 4 +- .../(tabs)/(watchlists)/[watchlistId].tsx | 3 +- app/(auth)/(tabs)/(watchlists)/_layout.tsx | 3 +- app/(auth)/(tabs)/(watchlists)/create.tsx | 2 +- .../(watchlists)/edit/[watchlistId].tsx | 3 +- app/(auth)/(tabs)/(watchlists)/index.tsx | 2 +- app/(auth)/now-playing.tsx | 2 +- app/(auth)/player/direct-player.tsx | 191 +++++++-------- app/_layout.tsx | 23 +- bun.lock | 1 - components/DownloadItem.tsx | 10 +- components/IntroSheet.tsx | 3 +- components/ItemContent.tsx | 14 +- components/PlayButton.tsx | 19 +- components/PlayButton.tv.tsx | 2 +- components/PlayedStatus.tsx | 1 - .../apple-tv-carousel/AppleTVCarousel.tsx | 2 +- components/common/HeaderBackButton.tsx | 2 +- components/common/JellyseerrItemRouter.tsx | 3 +- components/common/TouchableItemRouter.tsx | 30 ++- components/downloads/DownloadCard.tsx | 2 +- components/downloads/EpisodeCard.tsx | 1 - components/downloads/MovieCard.tsx | 2 +- components/downloads/SeriesCard.tsx | 10 +- components/home/Favorites.tsx | 2 +- components/home/Home.tsx | 3 +- components/home/HomeWithCarousel.tsx | 3 +- components/home/LargeMovieCarousel.tsx | 3 +- components/home/ScrollingCollectionList.tsx | 3 - components/item/ItemPeopleSections.tsx | 9 +- components/jellyseerr/PersonPoster.tsx | 3 +- .../jellyseerr/discover/CompanySlide.tsx | 4 +- components/jellyseerr/discover/GenreSlide.tsx | 4 +- components/music/MiniPlayerBar.tsx | 2 +- components/music/MusicAlbumCard.tsx | 2 +- components/music/MusicAlbumRowCard.tsx | 2 +- components/music/MusicArtistCard.tsx | 2 +- components/music/MusicPlaylistCard.tsx | 2 +- components/music/PlaylistOptionsSheet.tsx | 2 +- components/music/TrackOptionsSheet.tsx | 2 +- components/series/CastAndCrew.tsx | 4 +- components/series/CurrentSeries.tsx | 3 +- components/series/EpisodeTitleHeader.tsx | 2 +- components/series/NextItemButton.tsx | 2 +- components/series/SeasonDropdown.tsx | 13 +- components/series/SeasonEpisodesCarousel.tsx | 20 +- components/series/SeasonPicker.tsx | 60 ++++- components/settings/AppearanceSettings.tsx | 2 +- components/settings/Dashboard.tsx | 2 +- components/settings/OtherSettings.tsx | 2 +- components/settings/PluginSettings.tsx | 2 +- .../controls/ContinueWatchingOverlay.tsx | 2 +- components/video-player/controls/Controls.tsx | 13 +- .../video-player/controls/EpisodeList.tsx | 72 +++--- .../video-player/controls/HeaderControls.tsx | 7 +- components/video-player/controls/constants.ts | 4 + .../controls/contexts/PlayerContext.tsx | 14 +- .../controls/contexts/VideoContext.tsx | 9 +- .../controls/dropdown/DropdownView.tsx | 10 +- components/watchlists/WatchlistSheet.tsx | 2 +- hooks/useAppRouter.ts | 86 +++++++ hooks/useDownloadedFileOpener.ts | 2 +- hooks/useNetworkAwareQueryClient.ts | 28 ++- hooks/usePlaybackManager.ts | 12 +- hooks/useWebsockets.ts | 2 +- .../modules/mpvplayer/MPVLayerRenderer.kt | 3 +- modules/mpv-player/ios/MPVLayerRenderer.swift | 48 +++- modules/mpv-player/ios/MpvPlayerView.swift | 13 +- .../ios/SampleBufferDisplayView.swift | 72 ------ providers/JellyfinProvider.tsx | 4 +- providers/OfflineModeProvider.tsx | 37 +++ providers/WebSocketProvider.tsx | 2 +- utils/downloads/offline-series.ts | 114 +++++++++ 82 files changed, 948 insertions(+), 809 deletions(-) delete mode 100644 app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx create mode 100644 hooks/useAppRouter.ts delete mode 100644 modules/mpv-player/ios/SampleBufferDisplayView.swift create mode 100644 providers/OfflineModeProvider.tsx create mode 100644 utils/downloads/offline-series.ts diff --git a/.claude/learned-facts.md b/.claude/learned-facts.md index e2192950..0dba9d7e 100644 --- a/.claude/learned-facts.md +++ b/.claude/learned-facts.md @@ -24,4 +24,4 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio - **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_ -- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_ +- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 60c7c306..cc3b0a53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,21 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error - File-based routing in `app/` directory - Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)` - Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)` +- **IMPORTANT**: Always use `useAppRouter` from `@/hooks/useAppRouter` instead of `useRouter` from `expo-router`. This custom hook automatically handles offline mode state preservation across navigation: + ```typescript + // ✅ Correct + import useRouter from "@/hooks/useAppRouter"; + const router = useRouter(); + + // ❌ Never use this + import { useRouter } from "expo-router"; + import { router } from "expo-router"; + ``` + +**Offline Mode**: +- Use `OfflineModeProvider` from `@/providers/OfflineModeProvider` to wrap pages that support offline content +- Use `useOfflineMode()` hook to check if current context is offline +- The `useAppRouter` hook automatically injects `offline=true` param when navigating within an offline context **Providers** (wrapping order in `app/_layout.tsx`): 1. JotaiProvider diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 7cef4d13..a7e059ed 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -1,9 +1,10 @@ import { Feather, Ionicons } from "@expo/vector-icons"; -import { Stack, useRouter } from "expo-router"; +import { Stack } from "expo-router"; import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; import { Pressable } from "react-native-gesture-handler"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; +import useRouter from "@/hooks/useAppRouter"; const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); @@ -57,25 +58,6 @@ export default function IndexLayout() { ), }} /> - ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), - }} - /> ( - {}, - ); - const { downloadedItems, deleteItems } = useDownload(); - const insets = useSafeAreaInsets(); - - const series = useMemo(() => { - try { - return ( - downloadedItems - ?.filter((f) => f.item.SeriesId === seriesId) - ?.sort( - (a, b) => - (a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0), - ) || [] - ); - } catch { - return []; - } - }, [downloadedItems, seriesId]); - - // Group episodes by season in a single pass - const seasonGroups = useMemo(() => { - const groups: Record = {}; - - series.forEach((episode) => { - const seasonNumber = episode.item.ParentIndexNumber; - if (seasonNumber !== undefined && seasonNumber !== null) { - if (!groups[seasonNumber]) { - groups[seasonNumber] = []; - } - groups[seasonNumber].push(episode.item); - } - }); - - // Sort episodes within each season - Object.values(groups).forEach((episodes) => { - episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0)); - }); - - return groups; - }, [series]); - - // Get unique seasons (just the season numbers, sorted) - const uniqueSeasons = useMemo(() => { - const seasonNumbers = Object.keys(seasonGroups) - .map(Number) - .sort((a, b) => a - b); - return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season - }, [seasonGroups]); - - const seasonIndex = - seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ?? - episodeSeasonIndex ?? - series?.[0]?.item?.ParentIndexNumber ?? - ""; - - const groupBySeason = useMemo(() => { - return seasonGroups[Number(seasonIndex)] ?? []; - }, [seasonGroups, seasonIndex]); - - const initialSeasonIndex = useMemo( - () => - groupBySeason?.[0]?.ParentIndexNumber ?? - series?.[0]?.item?.ParentIndexNumber, - [groupBySeason, series], - ); - - useEffect(() => { - if (series.length > 0) { - navigation.setOptions({ - title: series[0].item.SeriesName, - }); - } else { - storage.remove(seriesId); - router.back(); - } - }, [series]); - - const deleteSeries = useCallback(() => { - Alert.alert( - "Delete season", - "Are you sure you want to delete the entire season?", - [ - { - text: "Cancel", - style: "cancel", - }, - { - text: "Delete", - onPress: () => - deleteItems( - groupBySeason - .map((item) => item.Id) - .filter((id) => id !== undefined), - ), - style: "destructive", - }, - ], - ); - }, [groupBySeason, deleteItems]); - - const ListHeaderComponent = useCallback(() => { - if (series.length === 0) return null; - - return ( - - { - setSeasonIndexState((prev) => ({ - ...prev, - [series[0].item.ParentId ?? ""]: season.ParentIndexNumber, - })); - }} - /> - - {groupBySeason.length} - - - - - - - - ); - }, [ - series, - uniqueSeasons, - seasonIndexState, - initialSeasonIndex, - groupBySeason, - deleteSeries, - ]); - - return ( - - } - keyExtractor={(item, index) => item.Id ?? `episode-${index}`} - ListHeaderComponent={ListHeaderComponent} - contentInsetAdjustmentBehavior='automatic' - contentContainerStyle={{ - paddingHorizontal: 16, - paddingLeft: insets.left + 16, - paddingRight: insets.right + 16, - paddingTop: Platform.OS === "android" ? 10 : 8, - }} - /> - - ); -} diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 239d0a8c..fb8ef0b9 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -1,5 +1,5 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; -import { useNavigation, useRouter } from "expo-router"; +import { useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -13,8 +13,10 @@ import ActiveDownloads from "@/components/downloads/ActiveDownloads"; import { DownloadSize } from "@/components/downloads/DownloadSize"; import { MovieCard } from "@/components/downloads/MovieCard"; import { SeriesCard } from "@/components/downloads/SeriesCard"; +import useRouter from "@/hooks/useAppRouter"; import { useDownload } from "@/providers/DownloadProvider"; import { type DownloadedItem } from "@/providers/Downloads/types"; +import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { queueAtom } from "@/utils/atoms/queue"; import { writeToLog } from "@/utils/log"; @@ -161,145 +163,99 @@ export default function page() { ); return ( - - - - {/* Queue card - hidden */} - {/* + + + + + + + + {movies.length > 0 && ( + + - {t("home.downloads.queue")} + {t("home.downloads.movies")} - - {t("home.downloads.queue_hint")} - - - {queue.map((q, index) => ( - - router.push(`/(auth)/items/page?id=${q.item.Id}`) - } - className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between' - key={index} - > - - {q.item.Name} - - {q.item.Type} - - - { - removeProcess(q.id); - setQueue((prev) => { - if (!prev) return []; - return [...prev.filter((i) => i.id !== q.id)]; - }); - }} - > - - - + + {movies?.length} + + + + + {movies?.map((item) => ( + + + ))} - - {queue.length === 0 && ( - - {t("home.downloads.no_items_in_queue")} - - )} - */} - - - - - {movies.length > 0 && ( - - - - {t("home.downloads.movies")} - - - {movies?.length} - + - - - {movies?.map((item) => ( - - - - ))} - - - - )} - {groupedBySeries.length > 0 && ( - - - - {t("home.downloads.tvseries")} - - - - {groupedBySeries?.length} + )} + {groupedBySeries.length > 0 && ( + + + + {t("home.downloads.tvseries")} + + + {groupedBySeries?.length} + + - - - - {groupedBySeries?.map((items) => ( - - i.item)} + + + {groupedBySeries?.map((items) => ( + - - ))} - - - - )} - - {otherMedia.length > 0 && ( - - - - {t("home.downloads.other_media")} - - - {otherMedia?.length} - + > + i.item)} + key={items[0].item.SeriesId} + /> + + ))} + + - - - {otherMedia?.map((item) => ( - - - - ))} + )} + + {otherMedia.length > 0 && ( + + + + {t("home.downloads.other_media")} + + + + {otherMedia?.length} + + - - - )} - {downloadedFiles?.length === 0 && ( - - - {t("home.downloads.no_downloaded_items")} - - - )} - - + + + {otherMedia?.map((item) => ( + + + + ))} + + + + )} + {downloadedFiles?.length === 0 && ( + + + {t("home.downloads.no_downloaded_items")} + + + )} + + + ); } diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 415c93bf..76675ae8 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -1,4 +1,4 @@ -import { useNavigation, useRouter } from "expo-router"; +import { useNavigation } from "expo-router"; import { t } from "i18next"; import { useAtom } from "jotai"; import { useEffect } from "react"; @@ -11,6 +11,7 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector"; import { QuickConnect } from "@/components/settings/QuickConnect"; import { StorageSettings } from "@/components/settings/StorageSettings"; import { UserInfo } from "@/components/settings/UserInfo"; +import useRouter from "@/hooks/useAppRouter"; import { useJellyfin, userAtom } from "@/providers/JellyfinProvider"; export default function settings() { diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx index 7f26a289..d6107217 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx @@ -13,6 +13,7 @@ import Animated, { import { Text } from "@/components/common/Text"; import { ItemContent } from "@/components/ItemContent"; import { useItemQuery } from "@/hooks/useItemQuery"; +import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; const Page: React.FC = () => { const { id } = useLocalSearchParams() as { id: string }; @@ -75,39 +76,35 @@ const Page: React.FC = () => { ); return ( - - - - - - - - - - - - - - - - - {item && ( - - )} - + + + + + + + + + + + + + + + + + + {item && } + + ); }; diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx index c8ab71ab..2f0af594 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx @@ -8,7 +8,7 @@ import { } from "@gorhom/bottom-sheet"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { useLocalSearchParams, useNavigation, useRouter } from "expo-router"; +import { useLocalSearchParams, useNavigation } from "expo-router"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -27,6 +27,7 @@ import { PlatformDropdown } from "@/components/PlatformDropdown"; import { JellyserrRatings } from "@/components/Ratings"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; import { ItemActions } from "@/components/series/SeriesActions"; +import useRouter from "@/hooks/useAppRouter"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx index 2636d5c0..9c2d3f48 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx @@ -14,86 +14,124 @@ import { ParallaxScrollView } from "@/components/ParallaxPage"; import { NextUp } from "@/components/series/NextUp"; import { SeasonPicker } from "@/components/series/SeasonPicker"; import { SeriesHeader } from "@/components/series/SeriesHeader"; +import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; +import { + buildOfflineSeriesFromEpisodes, + getDownloadedEpisodesForSeries, +} from "@/utils/downloads/offline-series"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; +import { storage } from "@/utils/mmkv"; const page: React.FC = () => { const navigation = useNavigation(); const { t } = useTranslation(); const params = useLocalSearchParams(); - const { id: seriesId, seasonIndex } = params as { + const { + id: seriesId, + seasonIndex, + offline: offlineParam, + } = params as { id: string; seasonIndex: string; + offline?: string; }; + const isOffline = offlineParam === "true"; + const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); + const { getDownloadedItems, downloadedItems } = useDownload(); + // For offline mode, construct series data from downloaded episodes + // Include downloadedItems.length so query refetches when items are deleted const { data: item } = useQuery({ - queryKey: ["series", seriesId], - queryFn: async () => - await getUserItemData({ + queryKey: ["series", seriesId, isOffline, downloadedItems.length], + queryFn: async () => { + if (isOffline) { + return buildOfflineSeriesFromEpisodes(getDownloadedItems(), seriesId); + } + return await getUserItemData({ api, userId: user?.Id, itemId: seriesId, - }), - staleTime: 60 * 1000, + }); + }, + staleTime: isOffline ? Infinity : 60 * 1000, + enabled: isOffline || (!!api && !!user?.Id), }); - const backdropUrl = useMemo( - () => - getBackdropUrl({ - api, - item, - quality: 90, - width: 1000, - }), - [item], - ); + // For offline mode, use stored base64 image + const base64Image = useMemo(() => { + if (isOffline) { + return storage.getString(seriesId); + } + return null; + }, [isOffline, seriesId]); - const logoUrl = useMemo( - () => - getLogoImageUrlById({ - api, - item, - }), - [item], - ); + const backdropUrl = useMemo(() => { + if (isOffline && base64Image) { + return `data:image/jpeg;base64,${base64Image}`; + } + return getBackdropUrl({ + api, + item, + quality: 90, + width: 1000, + }); + }, [isOffline, base64Image, api, item]); + + const logoUrl = useMemo(() => { + if (isOffline) { + return null; // No logo in offline mode + } + return getLogoImageUrlById({ + api, + item, + }); + }, [isOffline, api, item]); const { data: allEpisodes, isLoading } = useQuery({ - queryKey: ["AllEpisodes", item?.Id], + queryKey: ["AllEpisodes", seriesId, isOffline, downloadedItems.length], queryFn: async () => { - if (!api || !user?.Id || !item?.Id) return []; + if (isOffline) { + return getDownloadedEpisodesForSeries(getDownloadedItems(), seriesId); + } + if (!api || !user?.Id) return []; const res = await getTvShowsApi(api).getEpisodes({ - seriesId: item.Id, + seriesId: seriesId, userId: user.Id, enableUserData: true, - // Note: Including trick play is necessary to enable trick play downloads fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], }); return res?.data.Items || []; }, select: (data) => - // This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order. [...(data || [])].sort( (a, b) => (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0), ), - staleTime: 60, - enabled: !!api && !!user?.Id && !!item?.Id, + staleTime: isOffline ? Infinity : 60, + enabled: isOffline || (!!api && !!user?.Id), }); useEffect(() => { + // Don't show header buttons in offline mode + if (isOffline) { + navigation.setOptions({ + headerRight: () => null, + }); + return; + } + navigation.setOptions({ headerRight: () => - !isLoading && - item && - allEpisodes && - allEpisodes.length > 0 && ( + !isLoading && item && allEpisodes && allEpisodes.length > 0 ? ( {!Platform.isTV && ( @@ -114,49 +152,64 @@ const page: React.FC = () => { /> )} - ), + ) : null, }); - }, [allEpisodes, isLoading, item]); + }, [allEpisodes, isLoading, item, isOffline]); - if (!item || !backdropUrl) return null; + // For offline mode, we can show the page even without backdropUrl + if (!item || (!isOffline && !backdropUrl)) return null; return ( - - } - logo={ - logoUrl ? ( - - ) : undefined - } - > - - - - + + + ) : ( + + ) + } + logo={ + logoUrl ? ( + + ) : undefined + } + > + + + {!isOffline && ( + + + + )} + - - - + + ); }; diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index ef76d8c3..751b1df1 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -7,7 +7,7 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer"; import { useQuery } from "@tanstack/react-query"; import axios from "axios"; import { Image } from "expo-image"; -import { router, useLocalSearchParams, useNavigation } from "expo-router"; +import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { useCallback, @@ -36,6 +36,7 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { SearchItemWrapper } from "@/components/search/SearchItemWrapper"; import { SearchTabButtons } from "@/components/search/SearchTabButtons"; +import useRouter from "@/hooks/useAppRouter"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; @@ -57,6 +58,7 @@ const exampleSearches = [ export default function search() { const params = useLocalSearchParams(); const insets = useSafeAreaInsets(); + const router = useRouter(); const [user] = useAtom(userAtom); diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx index 69e4618e..831feac7 100644 --- a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx +++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx @@ -1,7 +1,7 @@ import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { FlashList } from "@shopify/flash-list"; -import { useLocalSearchParams, useNavigation, useRouter } from "expo-router"; +import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -19,6 +19,7 @@ import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { ItemCardText } from "@/components/ItemCardText"; import { ItemPoster } from "@/components/posters/ItemPoster"; +import useRouter from "@/hooks/useAppRouter"; import { useOrientation } from "@/hooks/useOrientation"; import { useDeleteWatchlist, diff --git a/app/(auth)/(tabs)/(watchlists)/_layout.tsx b/app/(auth)/(tabs)/(watchlists)/_layout.tsx index 51758135..807530ac 100644 --- a/app/(auth)/(tabs)/(watchlists)/_layout.tsx +++ b/app/(auth)/(tabs)/(watchlists)/_layout.tsx @@ -1,9 +1,10 @@ import { Ionicons } from "@expo/vector-icons"; -import { Stack, useRouter } from "expo-router"; +import { Stack } from "expo-router"; import { useTranslation } from "react-i18next"; import { Platform } from "react-native"; import { Pressable } from "react-native-gesture-handler"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; +import useRouter from "@/hooks/useAppRouter"; import { useStreamystatsEnabled } from "@/hooks/useWatchlists"; export default function WatchlistsLayout() { diff --git a/app/(auth)/(tabs)/(watchlists)/create.tsx b/app/(auth)/(tabs)/(watchlists)/create.tsx index aab8104d..af77d5db 100644 --- a/app/(auth)/(tabs)/(watchlists)/create.tsx +++ b/app/(auth)/(tabs)/(watchlists)/create.tsx @@ -1,5 +1,4 @@ import { Ionicons } from "@expo/vector-icons"; -import { useRouter } from "expo-router"; import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -15,6 +14,7 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { useCreateWatchlist } from "@/hooks/useWatchlistMutations"; import type { StreamystatsWatchlistAllowedItemType, diff --git a/app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx index e9108f53..5329d6a6 100644 --- a/app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx +++ b/app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx @@ -1,5 +1,5 @@ import { Ionicons } from "@expo/vector-icons"; -import { useLocalSearchParams, useRouter } from "expo-router"; +import { useLocalSearchParams } from "expo-router"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -15,6 +15,7 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations"; import { useWatchlistDetailQuery } from "@/hooks/useWatchlists"; import type { diff --git a/app/(auth)/(tabs)/(watchlists)/index.tsx b/app/(auth)/(tabs)/(watchlists)/index.tsx index de2e2db8..76d446d4 100644 --- a/app/(auth)/(tabs)/(watchlists)/index.tsx +++ b/app/(auth)/(tabs)/(watchlists)/index.tsx @@ -1,6 +1,5 @@ import { Ionicons } from "@expo/vector-icons"; import { FlashList } from "@shopify/flash-list"; -import { useRouter } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -8,6 +7,7 @@ import { Platform, RefreshControl, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { useStreamystatsEnabled, useWatchlistsQuery, diff --git a/app/(auth)/now-playing.tsx b/app/(auth)/now-playing.tsx index 42e51dc2..934175fb 100644 --- a/app/(auth)/now-playing.tsx +++ b/app/(auth)/now-playing.tsx @@ -6,7 +6,6 @@ import type { MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; -import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, @@ -38,6 +37,7 @@ import { Text } from "@/components/common/Text"; import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; +import useRouter from "@/hooks/useAppRouter"; import { useFavorite } from "@/hooks/useFavorite"; import { useMusicCast } from "@/hooks/useMusicCast"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 0571e9a4..d51b96a4 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -10,13 +10,12 @@ import { getUserLibraryApi, } from "@jellyfin/sdk/lib/utils/api"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; -import { router, useGlobalSearchParams, useNavigation } from "expo-router"; +import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, Platform, useWindowDimensions, View } from "react-native"; import { useAnimatedReaction, useSharedValue } from "react-native-reanimated"; - import { BITRATES } from "@/components/BitrateSelector"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; @@ -27,6 +26,7 @@ import { PlaybackSpeedScope, updatePlaybackSpeedSettings, } from "@/components/video-player/controls/utils/playback-speed-settings"; +import useRouter from "@/hooks/useAppRouter"; import { useHaptic } from "@/hooks/useHaptic"; import { useOrientation } from "@/hooks/useOrientation"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; @@ -44,6 +44,9 @@ import { import { useDownload } from "@/providers/DownloadProvider"; import { DownloadedItem } from "@/providers/Downloads/types"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; + +import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; + import { useSettings } from "@/utils/atoms/settings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { @@ -60,6 +63,7 @@ export default function page() { const api = useAtomValue(apiAtom); const { t } = useTranslation(); const navigation = useNavigation(); + const router = useRouter(); const { settings, updateSettings } = useSettings(); const { width: screenWidth, height: screenHeight } = useWindowDimensions(); @@ -87,10 +91,9 @@ export default function page() { : require("react-native-volume-manager"); const downloadUtils = useDownload(); - const downloadedFiles = useMemo( - () => downloadUtils.getDownloadedItems(), - [downloadUtils.getDownloadedItems], - ); + // Call directly instead of useMemo - the function reference doesn't change + // when data updates, only when the provider initializes + const downloadedFiles = downloadUtils.getDownloadedItems(); const revalidateProgressCache = useInvalidatePlaybackProgressCache(); @@ -109,7 +112,7 @@ export default function page() { bitrateValue: bitrateValueStr, offline: offlineStr, playbackPosition: playbackPositionFromUrl, - } = useGlobalSearchParams<{ + } = useLocalSearchParams<{ itemId: string; audioIndex: string; subtitleIndex: string; @@ -677,8 +680,8 @@ export default function page() { return; } - if (isLoading) { - setIsBuffering(true); + if (isLoading !== undefined) { + setIsBuffering(isLoading); } }, [playbackManager, item?.Id, progress], @@ -833,99 +836,99 @@ export default function page() { ); return ( - - - + + + - setIsVideoLoaded(true)} - onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => { - console.error("Video Error:", e.nativeEvent); - Alert.alert( - t("player.error"), - t("player.an_error_occured_while_playing_the_video"), - ); - writeToLog("ERROR", "Video Error", e.nativeEvent); + { - setTracksReady(true); - }} - /> - {!hasPlaybackStarted && ( - + setIsVideoLoaded(true)} + onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => { + console.error("Video Error:", e.nativeEvent); + Alert.alert( + t("player.error"), + t("player.an_error_occured_while_playing_the_video"), + ); + writeToLog("ERROR", "Video Error", e.nativeEvent); }} - > - - + onTracksReady={() => { + setTracksReady(true); + }} + /> + {!hasPlaybackStarted && ( + + + + )} + + {isMounted === true && item && !isPipMode && ( + )} - {isMounted === true && item && !isPipMode && ( - - )} - - - + + + ); } diff --git a/app/_layout.tsx b/app/_layout.tsx index 3de9dd1b..79278b70 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -48,7 +48,7 @@ import type { NotificationResponse, } from "expo-notifications/build/Notifications.types"; import type { ExpoPushToken } from "expo-notifications/build/Tokens.types"; -import { router, Stack, useSegments } from "expo-router"; +import { Stack, useSegments } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; import * as TaskManager from "expo-task-manager"; import { Provider as JotaiProvider, useAtom } from "jotai"; @@ -57,6 +57,7 @@ import { I18nextProvider } from "react-i18next"; import { Appearance } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; import { GestureHandlerRootView } from "react-native-gesture-handler"; +import useRouter from "@/hooks/useAppRouter"; import { userAtom } from "@/providers/JellyfinProvider"; import { store } from "@/utils/store"; import "react-native-reanimated"; @@ -81,14 +82,9 @@ SplashScreen.setOptions({ fade: true, }); -function redirect(notification: typeof Notifications.Notification) { - const url = notification.request.content.data?.url; - if (url) { - router.push(url); - } -} - function useNotificationObserver() { + const router = useRouter(); + useEffect(() => { if (Platform.isTV) return; @@ -99,14 +95,17 @@ function useNotificationObserver() { if (!isMounted || !response?.notification) { return; } - redirect(response?.notification); + const url = response?.notification.request.content.data?.url; + if (url) { + router.push(url); + } }, ); return () => { isMounted = false; }; - }, []); + }, [router]); } if (!Platform.isTV) { @@ -231,6 +230,7 @@ function Layout() { const [user] = useAtom(userAtom); const [api] = useAtom(apiAtom); const _segments = useSegments(); + const router = useRouter(); useEffect(() => { i18n.changeLanguage( @@ -323,9 +323,6 @@ function Layout() { responseListener.current = Notifications?.addNotificationResponseReceivedListener( (response: NotificationResponse) => { - // redirect if internal notification - redirect(response?.notification); - // Currently the notifications supported by the plugin will send data for deep links. const { title, data } = response.notification.request.content; writeInfoLog(`Notification ${title} opened`, data); diff --git a/bun.lock b/bun.lock index df82f2c8..e4a04eb2 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "streamyfin", diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 91a1c5d5..e50b4efc 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -9,13 +9,14 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { type Href, router } from "expo-router"; +import { type Href } from "expo-router"; import { t } from "i18next"; import { useAtom } from "jotai"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Platform, Switch, View, type ViewProps } from "react-native"; import { toast } from "sonner-native"; +import useRouter from "@/hooks/useAppRouter"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -62,6 +63,7 @@ export const DownloadItems: React.FC = ({ const [user] = useAtom(userAtom); const [queue, _setQueue] = useAtom(queueAtom); const { settings } = useSettings(); + const router = useRouter(); const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false); const { processes, startBackgroundDownload, downloadedItems } = useDownload(); @@ -170,9 +172,11 @@ export const DownloadItems: React.FC = ({ firstItem.Type !== "Episode" ? "/downloads" : ({ - pathname: `/downloads/${firstItem.SeriesId}`, + pathname: "/series/[id]", params: { - episodeSeasonIndex: firstItem.ParentIndexNumber, + id: firstItem.SeriesId!, + seasonIndex: firstItem.ParentIndexNumber?.toString(), + offline: "true", }, } as Href), ); diff --git a/components/IntroSheet.tsx b/components/IntroSheet.tsx index 4e9b7dc0..0a744e32 100644 --- a/components/IntroSheet.tsx +++ b/components/IntroSheet.tsx @@ -6,13 +6,13 @@ import { BottomSheetScrollView, } from "@gorhom/bottom-sheet"; import { Image } from "expo-image"; -import { router } from "expo-router"; import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Linking, Platform, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { storage } from "@/utils/mmkv"; export interface IntroSheetRef { @@ -24,6 +24,7 @@ export const IntroSheet = forwardRef((_, ref) => { const bottomSheetRef = useRef(null); const { t } = useTranslation(); const insets = useSafeAreaInsets(); + const router = useRouter(); useImperativeHandle(ref, () => ({ present: () => { diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 9e9592f5..1b2cbbac 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -26,6 +26,7 @@ import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useOrientation } from "@/hooks/useOrientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { AddToFavorites } from "./AddToFavorites"; @@ -45,13 +46,13 @@ export type SelectedOptions = { interface ItemContentProps { item: BaseItemDto; - isOffline: boolean; itemWithSources?: BaseItemDto | null; } export const ItemContent: React.FC = React.memo( - ({ item, isOffline, itemWithSources }) => { + ({ item, itemWithSources }) => { const [api] = useAtom(apiAtom); + const isOffline = useOfflineMode(); const { settings } = useSettings(); const { orientation } = useOrientation(); const navigation = useNavigation(); @@ -228,7 +229,6 @@ export const ItemContent: React.FC = React.memo( @@ -243,11 +243,7 @@ export const ItemContent: React.FC = React.memo( {item.Type === "Episode" && ( - + )} {!isOffline && @@ -264,7 +260,7 @@ export const ItemContent: React.FC = React.memo( )} - + {!isOffline && } diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 9d296216..1c3fd46f 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -2,7 +2,6 @@ import { useActionSheet } from "@expo/react-native-action-sheet"; import { Feather, Ionicons } from "@expo/vector-icons"; import { BottomSheetView } from "@gorhom/bottom-sheet"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { useRouter } from "expo-router"; import { useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; @@ -24,11 +23,13 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; +import useRouter from "@/hooks/useAppRouter"; import { useHaptic } from "@/hooks/useHaptic"; import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import { getDownloadedItemById } from "@/providers/Downloads/database"; import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { useSettings } from "@/utils/atoms/settings"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; @@ -44,7 +45,6 @@ import type { SelectedOptions } from "./ItemContent"; interface Props extends React.ComponentProps { item: BaseItemDto; selectedOptions: SelectedOptions; - isOffline?: boolean; colors?: ThemeColors; } @@ -54,9 +54,9 @@ const MIN_PLAYBACK_WIDTH = 15; export const PlayButton: React.FC = ({ item, selectedOptions, - isOffline, colors, }: Props) => { + const isOffline = useOfflineMode(); const { showActionSheetWithOptions } = useActionSheet(); const client = useRemoteMediaClient(); const mediaStatus = useMediaStatus(); @@ -300,6 +300,19 @@ export const PlayButton: React.FC = ({ // Check if item is downloaded const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined; + // If already in offline mode, play downloaded file directly + if (isOffline && downloadedItem) { + const queryParams = new URLSearchParams({ + itemId: item.Id!, + offline: "true", + playbackPosition: + item.UserData?.PlaybackPositionTicks?.toString() ?? "0", + }); + goToPlayer(queryParams.toString()); + return; + } + + // If online but file is downloaded, ask user which version to play if (downloadedItem) { if (Platform.OS === "android") { // Show bottom sheet for Android diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx index b66cce35..f79a3174 100644 --- a/components/PlayButton.tv.tsx +++ b/components/PlayButton.tv.tsx @@ -1,6 +1,5 @@ import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import { useCallback, useEffect } from "react"; import { TouchableOpacity, View } from "react-native"; @@ -14,6 +13,7 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; +import useRouter from "@/hooks/useAppRouter"; import { useHaptic } from "@/hooks/useHaptic"; import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx index f4cf1ed6..dd2198cd 100644 --- a/components/PlayedStatus.tsx +++ b/components/PlayedStatus.tsx @@ -7,7 +7,6 @@ import { RoundButton } from "./RoundButton"; interface Props extends ViewProps { items: BaseItemDto[]; - isOffline?: boolean; size?: "default" | "large"; } diff --git a/components/apple-tv-carousel/AppleTVCarousel.tsx b/components/apple-tv-carousel/AppleTVCarousel.tsx index ef8d8bd6..82c0f7b4 100644 --- a/components/apple-tv-carousel/AppleTVCarousel.tsx +++ b/components/apple-tv-carousel/AppleTVCarousel.tsx @@ -7,7 +7,6 @@ import { import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { LinearGradient } from "expo-linear-gradient"; -import { useRouter } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import { @@ -26,6 +25,7 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; +import useRouter from "@/hooks/useAppRouter"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; diff --git a/components/common/HeaderBackButton.tsx b/components/common/HeaderBackButton.tsx index 477bb5f7..8818a9a0 100644 --- a/components/common/HeaderBackButton.tsx +++ b/components/common/HeaderBackButton.tsx @@ -1,8 +1,8 @@ import { Ionicons } from "@expo/vector-icons"; import { BlurView, type BlurViewProps } from "expo-blur"; -import { useRouter } from "expo-router"; import { Platform } from "react-native"; import { Pressable, type PressableProps } from "react-native-gesture-handler"; +import useRouter from "@/hooks/useAppRouter"; interface Props extends BlurViewProps { background?: "blur" | "transparent"; diff --git a/components/common/JellyseerrItemRouter.tsx b/components/common/JellyseerrItemRouter.tsx index 2fdaa801..f6878bc5 100644 --- a/components/common/JellyseerrItemRouter.tsx +++ b/components/common/JellyseerrItemRouter.tsx @@ -1,7 +1,8 @@ -import { useRouter, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import type React from "react"; import { type PropsWithChildren } from "react"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; +import useRouter from "@/hooks/useAppRouter"; import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index 070837c3..2c85e094 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -1,14 +1,16 @@ import { useActionSheet } from "@expo/react-native-action-sheet"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useRouter, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import { type PropsWithChildren, useCallback } from "react"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; +import useRouter from "@/hooks/useAppRouter"; import { useFavorite } from "@/hooks/useFavorite"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; +import { useDownload } from "@/providers/DownloadProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; interface Props extends TouchableOpacityProps { item: BaseItemDto; - isOffline?: boolean; } export const itemRouter = (item: BaseItemDto, from: string) => { @@ -134,26 +136,20 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => { export const TouchableItemRouter: React.FC> = ({ item, - isOffline = false, children, ...props }) => { - const router = useRouter(); const segments = useSegments(); const { showActionSheetWithOptions } = useActionSheet(); const markAsPlayedStatus = useMarkAsPlayed([item]); const { isFavorite, toggleFavorite } = useFavorite(item); + const router = useRouter(); + const isOffline = useOfflineMode(); + const { deleteFile } = useDownload(); const from = (segments as string[])[2] || "(home)"; const handlePress = useCallback(() => { - // For offline mode, we still need to use query params - if (isOffline) { - const url = `${itemRouter(item, from)}&offline=true`; - router.push(url as any); - return; - } - // Force music libraries to navigate via the explicit string route. // This avoids losing the dynamic [libraryId] param when going through a nested navigator. if ("CollectionType" in item && item.CollectionType === "music") { @@ -163,7 +159,7 @@ export const TouchableItemRouter: React.FC> = ({ const navigation = getItemNavigation(item, from); router.push(navigation as any); - }, [from, isOffline, item, router]); + }, [from, item, router]); const showActionSheet = useCallback(() => { if ( @@ -179,14 +175,19 @@ export const TouchableItemRouter: React.FC> = ({ "Mark as Played", "Mark as Not Played", isFavorite ? "Unmark as Favorite" : "Mark as Favorite", + ...(isOffline ? ["Delete Download"] : []), "Cancel", ]; const cancelButtonIndex = options.length - 1; + const destructiveButtonIndex = isOffline + ? cancelButtonIndex - 1 + : undefined; showActionSheetWithOptions( { options, cancelButtonIndex, + destructiveButtonIndex, }, async (selectedIndex) => { if (selectedIndex === 0) { @@ -195,6 +196,8 @@ export const TouchableItemRouter: React.FC> = ({ await markAsPlayedStatus(false); } else if (selectedIndex === 2) { toggleFavorite(); + } else if (isOffline && selectedIndex === 3 && item.Id) { + deleteFile(item.Id); } }, ); @@ -203,6 +206,9 @@ export const TouchableItemRouter: React.FC> = ({ isFavorite, markAsPlayedStatus, toggleFavorite, + isOffline, + deleteFile, + item.Id, ]); if ( diff --git a/components/downloads/DownloadCard.tsx b/components/downloads/DownloadCard.tsx index 47dedf12..66f2a81b 100644 --- a/components/downloads/DownloadCard.tsx +++ b/components/downloads/DownloadCard.tsx @@ -1,6 +1,5 @@ import { Ionicons } from "@expo/vector-icons"; import { Image } from "expo-image"; -import { useRouter } from "expo-router"; import { t } from "i18next"; import { useMemo } from "react"; import { @@ -11,6 +10,7 @@ import { } from "react-native"; import { toast } from "sonner-native"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useDownload } from "@/providers/DownloadProvider"; import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator"; diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx index 3c57a17a..f907bacf 100644 --- a/components/downloads/EpisodeCard.tsx +++ b/components/downloads/EpisodeCard.tsx @@ -61,7 +61,6 @@ export const EpisodeCard: React.FC = ({ item }) => { return ( diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx index f02fe796..9d805ddb 100644 --- a/components/downloads/MovieCard.tsx +++ b/components/downloads/MovieCard.tsx @@ -67,7 +67,7 @@ export const MovieCard: React.FC = ({ item }) => { }, [showActionSheetWithOptions, handleDeleteFile]); return ( - + {base64Image ? ( = ({ items }) => { const { deleteItems } = useDownload(); const { showActionSheetWithOptions } = useActionSheet(); + const router = useRouter(); const base64Image = useMemo(() => { return storage.getString(items[0].SeriesId!); @@ -46,7 +47,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => { return ( router.push(`/downloads/${items[0].SeriesId}`)} + onPress={() => + router.push({ + pathname: "/series/[id]", + params: { id: items[0].SeriesId!, offline: "true" }, + }) + } onLongPress={showActionSheet} > {base64Image ? ( diff --git a/components/home/Favorites.tsx b/components/home/Favorites.tsx index 9a9234ab..84fa36b9 100644 --- a/components/home/Favorites.tsx +++ b/components/home/Favorites.tsx @@ -2,7 +2,6 @@ import type { Api } from "@jellyfin/sdk"; import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { Image } from "expo-image"; -import { useRouter } from "expo-router"; import { t } from "i18next"; import { useAtom } from "jotai"; import { useCallback, useEffect, useState } from "react"; @@ -10,6 +9,7 @@ import { Text, View } from "react-native"; // PNG ASSET import heart from "@/assets/icons/heart.fill.png"; import { Colors } from "@/constants/Colors"; +import useRouter from "@/hooks/useAppRouter"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList"; diff --git a/components/home/Home.tsx b/components/home/Home.tsx index 30c3bebc..1da3b358 100644 --- a/components/home/Home.tsx +++ b/components/home/Home.tsx @@ -12,7 +12,7 @@ import { getUserViewsApi, } from "@jellyfin/sdk/lib/utils/api"; import { type QueryFunction, useQuery } from "@tanstack/react-query"; -import { useNavigation, useRouter, useSegments } from "expo-router"; +import { useNavigation, useSegments } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -33,6 +33,7 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom import { Loader } from "@/components/Loader"; import { MediaListSection } from "@/components/medialists/MediaListSection"; import { Colors } from "@/constants/Colors"; +import useRouter from "@/hooks/useAppRouter"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useDownload } from "@/providers/DownloadProvider"; diff --git a/components/home/HomeWithCarousel.tsx b/components/home/HomeWithCarousel.tsx index ba74c15c..c513294f 100644 --- a/components/home/HomeWithCarousel.tsx +++ b/components/home/HomeWithCarousel.tsx @@ -12,7 +12,7 @@ import { getUserViewsApi, } from "@jellyfin/sdk/lib/utils/api"; import { type QueryFunction, useQuery } from "@tanstack/react-query"; -import { useNavigation, useRouter, useSegments } from "expo-router"; +import { useNavigation, useSegments } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -35,6 +35,7 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom import { Loader } from "@/components/Loader"; import { MediaListSection } from "@/components/medialists/MediaListSection"; import { Colors } from "@/constants/Colors"; +import useRouter from "@/hooks/useAppRouter"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useDownload } from "@/providers/DownloadProvider"; diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx index 913e024b..cf79af73 100644 --- a/components/home/LargeMovieCarousel.tsx +++ b/components/home/LargeMovieCarousel.tsx @@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { useRouter, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useMemo } from "react"; import { Dimensions, View, type ViewProps } from "react-native"; @@ -16,6 +16,7 @@ import Carousel, { type ICarouselInstance, Pagination, } from "react-native-reanimated-carousel"; +import useRouter from "@/hooks/useAppRouter"; import { useHaptic } from "@/hooks/useHaptic"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 54542c44..3d64466f 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -21,7 +21,6 @@ interface Props extends ViewProps { queryKey: QueryKey; queryFn: QueryFunction; hideIfEmpty?: boolean; - isOffline?: boolean; scrollY?: number; // For lazy loading enableLazyLoading?: boolean; // Enable/disable lazy loading } @@ -33,7 +32,6 @@ export const ScrollingCollectionList: React.FC = ({ queryFn, queryKey, hideIfEmpty = false, - isOffline = false, scrollY = 0, enableLazyLoading = false, ...props @@ -106,7 +104,6 @@ export const ScrollingCollectionList: React.FC = ({ = ({ - item, - isOffline, - ...props -}) => { +export const ItemPeopleSections: React.FC = ({ item, ...props }) => { + const isOffline = useOfflineMode(); const [enabled, setEnabled] = useState(false); useEffect(() => { diff --git a/components/jellyseerr/PersonPoster.tsx b/components/jellyseerr/PersonPoster.tsx index 70e44d35..e926b093 100644 --- a/components/jellyseerr/PersonPoster.tsx +++ b/components/jellyseerr/PersonPoster.tsx @@ -1,8 +1,9 @@ -import { useRouter, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import type React from "react"; import { TouchableOpacity, View, type ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; import Poster from "@/components/posters/Poster"; +import useRouter from "@/hooks/useAppRouter"; import { useJellyseerr } from "@/hooks/useJellyseerr"; interface Props { diff --git a/components/jellyseerr/discover/CompanySlide.tsx b/components/jellyseerr/discover/CompanySlide.tsx index 9643f48e..abba1d31 100644 --- a/components/jellyseerr/discover/CompanySlide.tsx +++ b/components/jellyseerr/discover/CompanySlide.tsx @@ -1,9 +1,10 @@ -import { router, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import type React from "react"; import { useCallback } from "react"; import { TouchableOpacity, type ViewProps } from "react-native"; import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide"; +import useRouter from "@/hooks/useAppRouter"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import { COMPANY_LOGO_IMAGE_FILTER, @@ -16,6 +17,7 @@ const CompanySlide: React.FC< > = ({ slide, data, ...props }) => { const segments = useSegments(); const { jellyseerrApi } = useJellyseerr(); + const router = useRouter(); const from = (segments as string[])[2] || "(home)"; const navigate = useCallback( diff --git a/components/jellyseerr/discover/GenreSlide.tsx b/components/jellyseerr/discover/GenreSlide.tsx index 8edaf4c3..31248540 100644 --- a/components/jellyseerr/discover/GenreSlide.tsx +++ b/components/jellyseerr/discover/GenreSlide.tsx @@ -1,10 +1,11 @@ import { useQuery } from "@tanstack/react-query"; -import { router, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import type React from "react"; import { useCallback } from "react"; import { TouchableOpacity, type ViewProps } from "react-native"; import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide"; +import useRouter from "@/hooks/useAppRouter"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces"; @@ -13,6 +14,7 @@ import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/consta const GenreSlide: React.FC = ({ slide, ...props }) => { const segments = useSegments(); const { jellyseerrApi } = useJellyseerr(); + const router = useRouter(); const from = (segments as string[])[2] || "(home)"; const navigate = useCallback( diff --git a/components/music/MiniPlayerBar.tsx b/components/music/MiniPlayerBar.tsx index a53fa49c..41f5a553 100644 --- a/components/music/MiniPlayerBar.tsx +++ b/components/music/MiniPlayerBar.tsx @@ -1,6 +1,5 @@ import { Ionicons } from "@expo/vector-icons"; import { Image } from "expo-image"; -import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useMemo } from "react"; import { @@ -23,6 +22,7 @@ import Animated, { } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { apiAtom } from "@/providers/JellyfinProvider"; import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; diff --git a/components/music/MusicAlbumCard.tsx b/components/music/MusicAlbumCard.tsx index a4c9f780..1a747c8e 100644 --- a/components/music/MusicAlbumCard.tsx +++ b/components/music/MusicAlbumCard.tsx @@ -1,10 +1,10 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; -import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useMemo } from "react"; import { TouchableOpacity, View } from "react-native"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; diff --git a/components/music/MusicAlbumRowCard.tsx b/components/music/MusicAlbumRowCard.tsx index 4b08700a..6612a392 100644 --- a/components/music/MusicAlbumRowCard.tsx +++ b/components/music/MusicAlbumRowCard.tsx @@ -1,10 +1,10 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; -import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useMemo } from "react"; import { TouchableOpacity, View } from "react-native"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; diff --git a/components/music/MusicArtistCard.tsx b/components/music/MusicArtistCard.tsx index e62edf80..c98e3fce 100644 --- a/components/music/MusicArtistCard.tsx +++ b/components/music/MusicArtistCard.tsx @@ -1,10 +1,10 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; -import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useMemo } from "react"; import { TouchableOpacity, View } from "react-native"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; diff --git a/components/music/MusicPlaylistCard.tsx b/components/music/MusicPlaylistCard.tsx index 6aa3387c..93caf7a4 100644 --- a/components/music/MusicPlaylistCard.tsx +++ b/components/music/MusicPlaylistCard.tsx @@ -3,11 +3,11 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useMemo } from "react"; import { TouchableOpacity, View } from "react-native"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { getLocalPath } from "@/providers/AudioStorage"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; diff --git a/components/music/PlaylistOptionsSheet.tsx b/components/music/PlaylistOptionsSheet.tsx index 02d16cd2..ab2b3a24 100644 --- a/components/music/PlaylistOptionsSheet.tsx +++ b/components/music/PlaylistOptionsSheet.tsx @@ -6,12 +6,12 @@ import { BottomSheetView, } from "@gorhom/bottom-sheet"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useRouter } from "expo-router"; import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Alert, StyleSheet, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { useDeletePlaylist } from "@/hooks/usePlaylistMutations"; interface Props { diff --git a/components/music/TrackOptionsSheet.tsx b/components/music/TrackOptionsSheet.tsx index b59d075c..a0b78472 100644 --- a/components/music/TrackOptionsSheet.tsx +++ b/components/music/TrackOptionsSheet.tsx @@ -7,7 +7,6 @@ import { } from "@gorhom/bottom-sheet"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; -import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, @@ -25,6 +24,7 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { useFavorite } from "@/hooks/useFavorite"; import { audioStorageEvents, diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx index 7dabcc76..06bf47be 100644 --- a/components/series/CastAndCrew.tsx +++ b/components/series/CastAndCrew.tsx @@ -2,13 +2,14 @@ import type { BaseItemDto, BaseItemPerson, } from "@jellyfin/sdk/lib/generated-client/models"; -import { router, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import { useAtom } from "jotai"; import type React from "react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { TouchableOpacity, View, type ViewProps } from "react-native"; import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values"; +import useRouter from "@/hooks/useAppRouter"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { HorizontalScroll } from "../common/HorizontalScroll"; @@ -24,6 +25,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => { const [api] = useAtom(apiAtom); const segments = useSegments(); const { t } = useTranslation(); + const router = useRouter(); const from = (segments as string[])[2]; const destinctPeople = useMemo(() => { diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx index e4d38c82..d8c49e25 100644 --- a/components/series/CurrentSeries.tsx +++ b/components/series/CurrentSeries.tsx @@ -1,10 +1,10 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { router } from "expo-router"; import { useAtom } from "jotai"; import type React from "react"; import { useTranslation } from "react-i18next"; import { TouchableOpacity, View, type ViewProps } from "react-native"; import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values"; +import useRouter from "@/hooks/useAppRouter"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; import { HorizontalScroll } from "../common/HorizontalScroll"; @@ -18,6 +18,7 @@ interface Props extends ViewProps { export const CurrentSeries: React.FC = ({ item, ...props }) => { const [api] = useAtom(apiAtom); const { t } = useTranslation(); + const router = useRouter(); return ( diff --git a/components/series/EpisodeTitleHeader.tsx b/components/series/EpisodeTitleHeader.tsx index e9f2b1aa..fe473a92 100644 --- a/components/series/EpisodeTitleHeader.tsx +++ b/components/series/EpisodeTitleHeader.tsx @@ -1,7 +1,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useRouter } from "expo-router"; import { TouchableOpacity, View, type ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; interface Props extends ViewProps { item: BaseItemDto; diff --git a/components/series/NextItemButton.tsx b/components/series/NextItemButton.tsx index a2aae63f..5d6497c3 100644 --- a/components/series/NextItemButton.tsx +++ b/components/series/NextItemButton.tsx @@ -2,9 +2,9 @@ import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; -import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import { useMemo } from "react"; +import useRouter from "@/hooks/useAppRouter"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { Button } from "../Button"; diff --git a/components/series/SeasonDropdown.tsx b/components/series/SeasonDropdown.tsx index 5f6b64cd..b59b74a3 100644 --- a/components/series/SeasonDropdown.tsx +++ b/components/series/SeasonDropdown.tsx @@ -54,29 +54,28 @@ export const SeasonDropdown: React.FC = ({ [state, item, keys], ); + // Always use IndexNumber for Season objects (not keys.index which is for the item) const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => - Number(a[keys.index]) - Number(b[keys.index]); + Number(a.IndexNumber) - Number(b.IndexNumber); const optionGroups = useMemo( () => [ { options: seasons?.sort(sortByIndex).map((season: any) => { - const title = - season[keys.title] || - season.Name || - `Season ${season.IndexNumber}`; + const title = season.Name || `Season ${season.IndexNumber}`; return { type: "radio" as const, label: title, value: season.Id || season.IndexNumber, - selected: Number(season[keys.index]) === Number(seasonIndex), + // Compare season's IndexNumber with the selected seasonIndex + selected: Number(season.IndexNumber) === Number(seasonIndex), onPress: () => onSelect(season), }; }) || [], }, ], - [seasons, keys, seasonIndex, onSelect], + [seasons, seasonIndex, onSelect], ); useEffect(() => { diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx index add0e83e..bb87ef12 100644 --- a/components/series/SeasonEpisodesCarousel.tsx +++ b/components/series/SeasonEpisodesCarousel.tsx @@ -1,12 +1,14 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; -import { router } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useMemo, useRef } from "react"; import { TouchableOpacity, type ViewStyle } from "react-native"; +import useRouter from "@/hooks/useAppRouter"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { getDownloadedEpisodesBySeasonId } from "@/utils/downloads/offline-series"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { HorizontalScroll, @@ -17,7 +19,6 @@ import { ItemCardText } from "../ItemCardText"; interface Props { item?: BaseItemDto | null; loading?: boolean; - isOffline?: boolean; style?: ViewStyle; containerStyle?: ViewStyle; } @@ -25,17 +26,14 @@ interface Props { export const SeasonEpisodesCarousel: React.FC = ({ item, loading, - isOffline, style, containerStyle, }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); + const isOffline = useOfflineMode(); + const router = useRouter(); const { getDownloadedItems } = useDownload(); - const downloadedFiles = useMemo( - () => getDownloadedItems(), - [getDownloadedItems], - ); const scrollRef = useRef(null); @@ -51,11 +49,7 @@ export const SeasonEpisodesCarousel: React.FC = ({ queryKey: ["episodes", seasonId, isOffline], queryFn: async () => { if (isOffline) { - return downloadedFiles - ?.filter( - (f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId, - ) - .map((f) => f.item); + return getDownloadedEpisodesBySeasonId(getDownloadedItems(), seasonId!); } if (!api || !user?.Id || !item?.SeriesId) return []; const response = await getTvShowsApi(api).getEpisodes({ @@ -73,7 +67,7 @@ export const SeasonEpisodesCarousel: React.FC = ({ }); return response.data.Items as BaseItemDto[]; }, - enabled: !!api && !!user?.Id && !!seasonId, + enabled: !!seasonId && (isOffline || (!!api && !!user?.Id)), }); useEffect(() => { diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index f97f6c0e..01a81545 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -10,7 +10,13 @@ import { SeasonDropdown, type SeasonIndexState, } from "@/components/series/SeasonDropdown"; +import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { + buildOfflineSeasons, + getDownloadedEpisodesForSeason, +} from "@/utils/downloads/offline-series"; import { runtimeTicksToSeconds } from "@/utils/time"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { Text } from "../common/Text"; @@ -31,6 +37,8 @@ export const SeasonPicker: React.FC = ({ item }) => { const [user] = useAtom(userAtom); const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); const { t } = useTranslation(); + const isOffline = useOfflineMode(); + const { getDownloadedItems, downloadedItems } = useDownload(); const seasonIndex = useMemo( () => seasonIndexState[item.Id ?? ""], @@ -38,8 +46,12 @@ export const SeasonPicker: React.FC = ({ item }) => { ); const { data: seasons } = useQuery({ - queryKey: ["seasons", item.Id], + queryKey: ["seasons", item.Id, isOffline, downloadedItems.length], queryFn: async () => { + if (isOffline) { + return buildOfflineSeasons(getDownloadedItems(), item.Id!); + } + if (!api || !user?.Id || !item.Id) return []; const response = await api.axiosInstance.get( `${api.basePath}/Shows/${item.Id}/Seasons`, @@ -58,8 +70,8 @@ export const SeasonPicker: React.FC = ({ item }) => { return response.data.Items; }, - staleTime: 60, - enabled: !!api && !!user?.Id && !!item.Id, + staleTime: isOffline ? Infinity : 60, + enabled: isOffline || (!!api && !!user?.Id && !!item.Id), }); const selectedSeasonId: string | null = useMemo(() => { @@ -73,9 +85,33 @@ export const SeasonPicker: React.FC = ({ item }) => { return season.Id!; }, [seasons, seasonIndex]); + // For offline mode, we use season index number instead of ID + const selectedSeasonNumber = useMemo(() => { + if (!isOffline) return null; + const season = seasons?.find( + (s: BaseItemDto) => + s.IndexNumber === seasonIndex || s.Name === seasonIndex, + ); + return season?.IndexNumber ?? null; + }, [isOffline, seasons, seasonIndex]); + const { data: episodes, isPending } = useQuery({ - queryKey: ["episodes", item.Id, selectedSeasonId], + queryKey: [ + "episodes", + item.Id, + isOffline ? selectedSeasonNumber : selectedSeasonId, + isOffline, + downloadedItems.length, + ], queryFn: async () => { + if (isOffline) { + return getDownloadedEpisodesForSeason( + getDownloadedItems(), + item.Id!, + selectedSeasonNumber!, + ); + } + if (!api || !user?.Id || !item.Id || !selectedSeasonId) { return []; } @@ -85,7 +121,6 @@ export const SeasonPicker: React.FC = ({ item }) => { userId: user.Id, seasonId: selectedSeasonId, enableUserData: true, - // Note: Including trick play is necessary to enable trick play downloads fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], }); @@ -97,7 +132,10 @@ export const SeasonPicker: React.FC = ({ item }) => { return res.data.Items; }, - enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, + staleTime: isOffline ? Infinity : 0, + enabled: isOffline + ? !!item.Id && selectedSeasonNumber !== null + : !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, }); // Used for height calculation @@ -127,7 +165,7 @@ export const SeasonPicker: React.FC = ({ item }) => { })); }} /> - {episodes?.length ? ( + {episodes?.length && !isOffline ? ( = ({ item }) => { {runtimeTicksToSeconds(e.RunTimeTicks)} - - - + {!isOffline && ( + + + + )} void; setShowControls: (shown: boolean) => void; - offline?: boolean; mediaSource?: MediaSourceInfo | null; seek: (ticks: number) => void; startPictureInPicture?: () => Promise; @@ -83,12 +84,12 @@ export const Controls: FC = ({ aspectRatio = "default", isZoomedToFill = false, onZoomToggle, - offline = false, api = null, downloadedFiles = undefined, playbackSpeed = 1.0, setPlaybackSpeed, }) => { + const offline = useOfflineMode(); const { settings, updateSettings } = useSettings(); const router = useRouter(); const lightHapticFeedback = useHaptic("light"); @@ -110,7 +111,9 @@ export const Controls: FC = ({ } = useTrickplay(item); const min = useSharedValue(0); - const max = useSharedValue(ticksToMs(item.RunTimeTicks || 0)); + // Regular value for use during render (avoids Reanimated warning) + const maxMs = ticksToMs(item.RunTimeTicks || 0); + const max = useSharedValue(maxMs); // Animation values for controls const controlsOpacity = useSharedValue(showControls ? 1 : 0); @@ -303,7 +306,7 @@ export const Controls: FC = ({ offline, api, downloadedFiles, - max.value, + maxMs, ); const goToItemCommon = useCallback( diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx index a7d6c5bc..35a23535 100644 --- a/components/video-player/controls/EpisodeList.tsx +++ b/components/video-player/controls/EpisodeList.tsx @@ -2,11 +2,10 @@ import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useGlobalSearchParams } from "expo-router"; import { atom, useAtom } from "jotai"; import { useEffect, useMemo, useRef } from "react"; import { TouchableOpacity, View } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import { HorizontalScroll, @@ -19,10 +18,16 @@ import { type SeasonIndexState, } from "@/components/series/SeasonDropdown"; import { useDownload } from "@/providers/DownloadProvider"; -import type { DownloadedItem } from "@/providers/Downloads/types"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { + getDownloadedEpisodesForSeason, + getDownloadedSeasonNumbers, +} from "@/utils/downloads/offline-series"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { runtimeTicksToSeconds } from "@/utils/time"; +import { HEADER_LAYOUT, ICON_SIZES } from "./constants"; type Props = { item: BaseItemDto; @@ -40,10 +45,9 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { const scrollToIndex = (index: number) => { scrollViewRef.current?.scrollToIndex(index, 100); }; - const { offline } = useGlobalSearchParams<{ - offline: string; - }>(); - const isOffline = offline === "true"; + const isOffline = useOfflineMode(); + const { settings } = useSettings(); + const insets = useSafeAreaInsets(); // Set the initial season index useEffect(() => { @@ -56,10 +60,6 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { }, []); const { getDownloadedItems } = useDownload(); - const downloadedFiles = useMemo( - () => getDownloadedItems(), - [getDownloadedItems], - ); const seasonIndex = seasonIndexState[item.ParentId ?? ""]; @@ -68,15 +68,9 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { queryFn: async () => { if (isOffline) { if (!item.SeriesId) return []; - const seriesEpisodes = downloadedFiles?.filter( - (f: DownloadedItem) => f.item.SeriesId === item.SeriesId, - ); - const seasonNumbers = Array.from( - new Set( - seriesEpisodes - ?.map((f: DownloadedItem) => f.item.ParentIndexNumber) - .filter(Boolean), - ), + const seasonNumbers = getDownloadedSeasonNumbers( + getDownloadedItems(), + item.SeriesId, ); // Create fake season objects return seasonNumbers.map((seasonNumber) => ({ @@ -117,14 +111,12 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { queryKey: ["episodes", item.SeriesId, selectedSeasonId], queryFn: async () => { if (isOffline) { - if (!item.SeriesId) return []; - return downloadedFiles - ?.filter( - (f: DownloadedItem) => - f.item.SeriesId === item.SeriesId && - f.item.ParentIndexNumber === seasonIndex, - ) - .map((f: DownloadedItem) => f.item); + if (!item.SeriesId || typeof seasonIndex !== "number") return []; + return getDownloadedEpisodesForSeason( + getDownloadedItems(), + item.SeriesId, + seasonIndex, + ); } if (!api || !user?.Id || !item.Id || !selectedSeasonId) return []; const res = await getTvShowsApi(api).getEpisodes({ @@ -153,6 +145,9 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { const queryClient = useQueryClient(); useEffect(() => { + // Don't prefetch when offline - data is already local + if (isOffline) return; + for (const e of episodes || []) { queryClient.prefetchQuery({ queryKey: ["item", e.Id], @@ -168,7 +163,7 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { staleTime: 60 * 5 * 1000, }); } - }, [episodes]); + }, [episodes, isOffline]); // Scroll to the current item when episodes are fetched useEffect(() => { @@ -181,15 +176,24 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { }, [episodes, item.Id]); return ( - - + {seasons && seasons.length > 0 && !episodesLoading && episodes && ( = ({ item, close, goToItem }) => { onPress={async () => { close(); }} - className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2 ml-auto' + className='aspect-square flex flex-col rounded-xl items-center justify-center p-2 ml-auto' > - + @@ -274,6 +278,6 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { showsHorizontalScrollIndicator={false} /> )} - + ); }; diff --git a/components/video-player/controls/HeaderControls.tsx b/components/video-player/controls/HeaderControls.tsx index ba93fb3c..09a92892 100644 --- a/components/video-player/controls/HeaderControls.tsx +++ b/components/video-player/controls/HeaderControls.tsx @@ -3,15 +3,15 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; -import { useRouter } from "expo-router"; import { type FC, useCallback, useState } from "react"; import { Platform, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import useRouter from "@/hooks/useAppRouter"; import { useHaptic } from "@/hooks/useHaptic"; import { useOrientation } from "@/hooks/useOrientation"; import { OrientationLock } from "@/packages/expo-screen-orientation"; import { useSettings } from "@/utils/atoms/settings"; -import { ICON_SIZES } from "./constants"; +import { HEADER_LAYOUT, ICON_SIZES } from "./constants"; import DropdownView from "./dropdown/DropdownView"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { type AspectRatio } from "./VideoScalingModeSelector"; @@ -98,12 +98,13 @@ export const HeaderControls: FC = ({ left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, + padding: HEADER_LAYOUT.CONTAINER_PADDING, }, ]} pointerEvents={showControls ? "auto" : "none"} className='flex flex-row justify-between' > - + {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( = ({ mediaSource, isVideoLoaded, tracksReady, - offline = false, downloadedItem = null, }) => { const value = useMemo( @@ -52,18 +49,9 @@ export const PlayerProvider: React.FC = ({ mediaSource, isVideoLoaded, tracksReady, - offline, downloadedItem, }), - [ - playerRef, - item, - mediaSource, - isVideoLoaded, - tracksReady, - offline, - downloadedItem, - ], + [playerRef, item, mediaSource, isVideoLoaded, tracksReady, downloadedItem], ); return ( diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx index 1443af5b..ec9ca995 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -47,7 +47,7 @@ */ import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client"; -import { router, useLocalSearchParams } from "expo-router"; +import { useLocalSearchParams } from "expo-router"; import type React from "react"; import { createContext, @@ -57,7 +57,9 @@ import { useMemo, useState, } from "react"; +import useRouter from "@/hooks/useAppRouter"; import type { MpvAudioTrack } from "@/modules"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils"; import type { Track } from "../types"; import { usePlayerContext, usePlayerControls } from "./PlayerContext"; @@ -75,9 +77,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({ const [subtitleTracks, setSubtitleTracks] = useState(null); const [audioTracks, setAudioTracks] = useState(null); - const { tracksReady, mediaSource, offline, downloadedItem } = - usePlayerContext(); + const { tracksReady, mediaSource, downloadedItem } = usePlayerContext(); const playerControls = usePlayerControls(); + const offline = useOfflineMode(); + const router = useRouter(); const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } = useLocalSearchParams<{ diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index 18e861a8..83487d29 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -1,5 +1,5 @@ import { Ionicons } from "@expo/vector-icons"; -import { useLocalSearchParams, useRouter } from "expo-router"; +import { useLocalSearchParams } from "expo-router"; import { useCallback, useMemo, useRef } from "react"; import { Platform, View } from "react-native"; import { BITRATES } from "@/components/BitrateSelector"; @@ -8,6 +8,8 @@ import { PlatformDropdown, } from "@/components/PlatformDropdown"; import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector"; +import useRouter from "@/hooks/useAppRouter"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; import { usePlayerContext } from "../contexts/PlayerContext"; import { useVideoContext } from "../contexts/VideoContext"; @@ -38,8 +40,9 @@ const DropdownView = ({ const { item, mediaSource } = usePlayerContext(); const { settings, updateSettings } = useSettings(); const router = useRouter(); + const isOffline = useOfflineMode(); - const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } = + const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } = useLocalSearchParams<{ itemId: string; audioIndex: string; @@ -47,15 +50,12 @@ const DropdownView = ({ mediaSourceId: string; bitrateValue: string; playbackPosition: string; - offline: string; }>(); // Use ref to track playbackPosition without causing re-renders const playbackPositionRef = useRef(playbackPosition); playbackPositionRef.current = playbackPosition; - const isOffline = offline === "true"; - // Stabilize IDs to prevent unnecessary recalculations const itemIdRef = useRef(item.Id); const mediaSourceIdRef = useRef(mediaSource?.Id); diff --git a/components/watchlists/WatchlistSheet.tsx b/components/watchlists/WatchlistSheet.tsx index 74764902..9d585909 100644 --- a/components/watchlists/WatchlistSheet.tsx +++ b/components/watchlists/WatchlistSheet.tsx @@ -6,7 +6,6 @@ import { BottomSheetView, } from "@gorhom/bottom-sheet"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useRouter } from "expo-router"; import React, { forwardRef, useCallback, @@ -23,6 +22,7 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; +import useRouter from "@/hooks/useAppRouter"; import { useAddToWatchlist, useRemoveFromWatchlist, diff --git a/hooks/useAppRouter.ts b/hooks/useAppRouter.ts new file mode 100644 index 00000000..956ea928 --- /dev/null +++ b/hooks/useAppRouter.ts @@ -0,0 +1,86 @@ +import { useRouter } from "expo-router"; +import { useCallback, useMemo } from "react"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; + +/** + * Drop-in replacement for expo-router's useRouter that automatically + * preserves offline state across navigation. + * + * - For object-form navigation, automatically adds offline=true when in offline context + * - For string URLs, passes through unchanged (caller handles offline param) + * + * @example + * import useRouter from "@/hooks/useAppRouter"; + * + * const router = useRouter(); + * router.push({ pathname: "/items/page", params: { id: item.Id } }); // offline added automatically + */ +export function useAppRouter() { + const router = useRouter(); + const isOffline = useOfflineMode(); + + const push = useCallback( + (href: Parameters[0]) => { + if (typeof href === "string") { + router.push(href as any); + } else { + const callerParams = (href.params ?? {}) as Record; + const hasExplicitOffline = "offline" in callerParams; + router.push({ + ...href, + params: { + // Only add offline if caller hasn't explicitly set it + ...(isOffline && !hasExplicitOffline && { offline: "true" }), + ...callerParams, + }, + } as any); + } + }, + [router, isOffline], + ); + + const replace = useCallback( + (href: Parameters[0]) => { + if (typeof href === "string") { + router.replace(href as any); + } else { + const callerParams = (href.params ?? {}) as Record; + const hasExplicitOffline = "offline" in callerParams; + router.replace({ + ...href, + params: { + // Only add offline if caller hasn't explicitly set it + ...(isOffline && !hasExplicitOffline && { offline: "true" }), + ...callerParams, + }, + } as any); + } + }, + [router, isOffline], + ); + + const setParams = useCallback( + (params: Parameters[0]) => { + const callerParams = (params ?? {}) as Record; + const hasExplicitOffline = "offline" in callerParams; + router.setParams({ + // Only add offline if caller hasn't explicitly set it + ...(isOffline && !hasExplicitOffline && { offline: "true" }), + ...callerParams, + }); + }, + [router, isOffline], + ); + + return useMemo( + () => ({ + ...router, + push, + replace, + setParams, + }), + [router, push, replace, setParams], + ); +} + +export default useAppRouter; diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts index 732ae36c..845161a1 100644 --- a/hooks/useDownloadedFileOpener.ts +++ b/hooks/useDownloadedFileOpener.ts @@ -1,6 +1,6 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { useRouter } from "expo-router"; import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { writeToLog } from "@/utils/log"; diff --git a/hooks/useNetworkAwareQueryClient.ts b/hooks/useNetworkAwareQueryClient.ts index b0b2314b..66c92874 100644 --- a/hooks/useNetworkAwareQueryClient.ts +++ b/hooks/useNetworkAwareQueryClient.ts @@ -36,12 +36,26 @@ export function useNetworkAwareQueryClient(): NetworkAwareQueryClient { ); return useMemo(() => { - // Create a proxy-like object that inherits from queryClient - // but overrides invalidateQueries - const wrapped = Object.create(queryClient) as NetworkAwareQueryClient; - wrapped.invalidateQueries = networkAwareInvalidate; - wrapped.forceInvalidateQueries = - queryClient.invalidateQueries.bind(queryClient); - return wrapped; + // Use a Proxy to wrap the queryClient and override invalidateQueries. + // Object.create doesn't work because QueryClient uses private fields (#) + // which can only be accessed on the exact instance they were defined on. + const forceInvalidate = queryClient.invalidateQueries.bind(queryClient); + + return new Proxy(queryClient, { + get(target, prop) { + if (prop === "invalidateQueries") { + return networkAwareInvalidate; + } + if (prop === "forceInvalidateQueries") { + return forceInvalidate; + } + const value = Reflect.get(target, prop, target); + // Bind methods to the original target to preserve private field access + if (typeof value === "function") { + return value.bind(target); + } + return value; + }, + }) as NetworkAwareQueryClient; }, [queryClient, networkAwareInvalidate]); } diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts index a38f718a..8387511f 100644 --- a/hooks/usePlaybackManager.ts +++ b/hooks/usePlaybackManager.ts @@ -3,7 +3,7 @@ import type { PlaybackProgressInfo, } from "@jellyfin/sdk/lib/generated-client"; import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { useMemo } from "react"; import { useDownload } from "@/providers/DownloadProvider"; @@ -69,6 +69,7 @@ export const usePlaybackManager = ({ const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { isConnected } = useNetworkStatus(); + const queryClient = useQueryClient(); const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } = useDownload(); @@ -186,6 +187,9 @@ export const usePlaybackManager = ({ }, }, }); + // Force invalidate queries so they refetch from updated local database + queryClient.invalidateQueries({ queryKey: ["item", itemId] }); + queryClient.invalidateQueries({ queryKey: ["episodes"] }); } // Handle remote state update if online @@ -226,6 +230,9 @@ export const usePlaybackManager = ({ }, }, }); + // Force invalidate queries so they refetch from updated local database + queryClient.invalidateQueries({ queryKey: ["item", itemId] }); + queryClient.invalidateQueries({ queryKey: ["episodes"] }); } // Handle remote state update if online @@ -268,6 +275,9 @@ export const usePlaybackManager = ({ }, }, }); + // Force invalidate queries so they refetch from updated local database + queryClient.invalidateQueries({ queryKey: ["item", itemId] }); + queryClient.invalidateQueries({ queryKey: ["episodes"] }); } // Handle remote state update if online diff --git a/hooks/useWebsockets.ts b/hooks/useWebsockets.ts index 87dd9162..6881f3d6 100644 --- a/hooks/useWebsockets.ts +++ b/hooks/useWebsockets.ts @@ -1,7 +1,7 @@ -import { useRouter } from "expo-router"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Alert } from "react-native"; +import useRouter from "@/hooks/useAppRouter"; import { useWebSocketContext } from "@/providers/WebSocketProvider"; interface UseWebSocketProps { diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt index 0cb80a1a..039ff94a 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -507,7 +507,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { MPVLib.MPV_EVENT_FILE_LOADED -> { // Add external subtitles now that file is loaded if (pendingExternalSubtitles.isNotEmpty()) { - for (subUrl in pendingExternalSubtitles) { + pendingExternalSubtitles.forEachIndexed { index, subUrl -> + android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl") MPVLib.command(arrayOf("sub-add", subUrl)) } pendingExternalSubtitles = emptyList() diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index f3d05ec1..728e9fc3 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -36,6 +36,9 @@ final class MPVLayerRenderer { private var isRunning = false private var isStopping = false + // KVO observation for display layer status + private var statusObservation: NSKeyValueObservation? + weak var delegate: MPVLayerRendererDelegate? // Thread-safe state for playback @@ -78,6 +81,37 @@ final class MPVLayerRenderer { init(displayLayer: AVSampleBufferDisplayLayer) { self.displayLayer = displayLayer + observeDisplayLayerStatus() + } + + + /// Watches for display layer failures and auto-recovers. + /// + /// iOS aggressively kills VideoToolbox decoder sessions when the app is + /// backgrounded, the screen is locked, or system resources are low. + /// This causes the video to go black - especially problematic for PiP. + /// + /// This KVO observer detects when the display layer status becomes `.failed` + /// and automatically reinitializes the hardware decoder to restore video. + private func observeDisplayLayerStatus() { + statusObservation = displayLayer.observe(\.status, options: [.new]) { [weak self] layer, _ in + guard let self else { return } + + if layer.status == .failed { + print("🔧 Display layer failed - auto-resetting decoder") + self.queue.async { + self.performDecoderReset() + } + } + } + } + + /// Actually performs the decoder reset (called by observer or manually) + private func performDecoderReset() { + guard let handle = mpv else { return } + print("🔧 Resetting decoder: status=\(displayLayer.status.rawValue), requiresFlush=\(displayLayer.requiresFlushToResumeDecoding)") + commandSync(handle, ["set", "hwdec", "no"]) + commandSync(handle, ["set", "hwdec", "auto"]) } deinit { @@ -150,6 +184,10 @@ final class MPVLayerRenderer { isRunning = false isStopping = true + // Stop observing display layer status + statusObservation?.invalidate() + statusObservation = nil + queue.sync { [weak self] in guard let self, let handle = self.mpv else { return } @@ -339,8 +377,10 @@ final class MPVLayerRenderer { // Add external subtitles now that the file is loaded let hadExternalSubs = !pendingExternalSubtitles.isEmpty if hadExternalSubs, let handle = mpv { - for subUrl in pendingExternalSubtitles { - command(handle, ["sub-add", subUrl]) + for (index, subUrl) in pendingExternalSubtitles.enumerated() { + print("🔧 Adding external subtitle [\(index)]: \(subUrl)") + // Use commandSync to ensure subs are added in exact order (not async) + commandSync(handle, ["sub-add", subUrl]) } pendingExternalSubtitles = [] // Set subtitle after external subs are added @@ -531,7 +571,9 @@ final class MPVLayerRenderer { cachedPosition = clamped commandSync(handle, ["seek", String(clamped), "absolute"]) } - + + + func seek(by seconds: Double) { guard let handle = mpv else { return } let newPosition = max(0, cachedPosition + seconds) diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index 1981db5d..b4cc40bf 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -51,12 +51,13 @@ class MpvPlayerView: ExpoView { private var currentURL: URL? private var cachedPosition: Double = 0 private var cachedDuration: Double = 0 - private var intendedPlayState: Bool = false // For PiP - ignores transient states during seek + private var intendedPlayState: Bool = false private var _isZoomedToFill: Bool = false required init(appContext: AppContext? = nil) { super.init(appContext: appContext) setupView() + // Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer } private func setupView() { @@ -361,6 +362,11 @@ extension MpvPlayerView: PiPControllerDelegate { renderer?.syncTimebase() // Set current time for PiP progress bar pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration) + + // Reset to fit for PiP (zoomed video doesn't display correctly in PiP) + if _isZoomedToFill { + displayLayer.videoGravity = .resizeAspect + } } func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) { @@ -380,6 +386,11 @@ extension MpvPlayerView: PiPControllerDelegate { // Ensure timebase is synced after PiP ends renderer?.syncTimebase() pipController?.updatePlaybackState() + + // Restore the user's zoom preference + if _isZoomedToFill { + displayLayer.videoGravity = .resizeAspectFill + } } func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) { diff --git a/modules/mpv-player/ios/SampleBufferDisplayView.swift b/modules/mpv-player/ios/SampleBufferDisplayView.swift deleted file mode 100644 index 8e432c33..00000000 --- a/modules/mpv-player/ios/SampleBufferDisplayView.swift +++ /dev/null @@ -1,72 +0,0 @@ -import UIKit -import AVFoundation - -final class SampleBufferDisplayView: UIView { - override class var layerClass: AnyClass { AVSampleBufferDisplayLayer.self } - - var displayLayer: AVSampleBufferDisplayLayer { - return layer as! AVSampleBufferDisplayLayer - } - - private(set) var pipController: PiPController? - - weak var pipDelegate: PiPControllerDelegate? { - didSet { - pipController?.delegate = pipDelegate - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - commonInit() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - commonInit() - } - - private func commonInit() { - backgroundColor = .black - displayLayer.videoGravity = .resizeAspect - #if !os(tvOS) - #if compiler(>=6.0) - if #available(iOS 26.0, *) { - displayLayer.preferredDynamicRange = .automatic - } else if #available(iOS 17.0, *) { - displayLayer.wantsExtendedDynamicRangeContent = true - } - #endif - if #available(iOS 17.0, *) { - displayLayer.wantsExtendedDynamicRangeContent = true - } - #endif - setupPictureInPicture() - } - - private func setupPictureInPicture() { - pipController = PiPController(sampleBufferDisplayLayer: displayLayer) - } - - // MARK: - PiP Control Methods - - func startPictureInPicture() { - pipController?.startPictureInPicture() - } - - func stopPictureInPicture() { - pipController?.stopPictureInPicture() - } - - var isPictureInPictureSupported: Bool { - return pipController?.isPictureInPictureSupported ?? false - } - - var isPictureInPictureActive: Bool { - return pipController?.isPictureInPictureActive ?? false - } - - var isPictureInPicturePossible: Bool { - return pipController?.isPictureInPicturePossible ?? false - } -} diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index fa1615ab..da75dcd1 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -4,7 +4,7 @@ import type { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getUserApi } from "@jellyfin/sdk/lib/utils/api"; import { useMutation } from "@tanstack/react-query"; import axios, { AxiosError } from "axios"; -import { router, useSegments } from "expo-router"; +import { useSegments } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; import { atom, useAtom } from "jotai"; import type React from "react"; @@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next"; import { AppState, Platform } from "react-native"; import { getDeviceName } from "react-native-device-info"; import uuid from "react-native-uuid"; +import useRouter from "@/hooks/useAppRouter"; import { useInterval } from "@/hooks/useInterval"; import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; import { useSettings } from "@/utils/atoms/settings"; @@ -581,6 +582,7 @@ export const useJellyfin = (): JellyfinContextValue => { function useProtectedRoute(user: UserDto | null, loaded = false) { const segments = useSegments(); + const router = useRouter(); useEffect(() => { if (loaded === false) return; diff --git a/providers/OfflineModeProvider.tsx b/providers/OfflineModeProvider.tsx new file mode 100644 index 00000000..a904919f --- /dev/null +++ b/providers/OfflineModeProvider.tsx @@ -0,0 +1,37 @@ +import { createContext, type ReactNode, useContext } from "react"; + +const UNSET = Symbol("OfflineModeNotProvided"); + +const OfflineModeContext = createContext(UNSET); + +interface OfflineModeProviderProps { + isOffline: boolean; + children: ReactNode; +} + +/** + * Provides offline mode state to all child components. + * Wrap pages that support offline mode with this provider. + */ +export function OfflineModeProvider({ + isOffline, + children, +}: OfflineModeProviderProps) { + return ( + + {children} + + ); +} + +/** + * Returns whether the current view is in offline mode. + * Must be used within an OfflineModeProvider (set at page level). + */ +export function useOfflineMode(): boolean { + const context = useContext(OfflineModeContext); + if (context === UNSET) { + return false; + } + return context; +} diff --git a/providers/WebSocketProvider.tsx b/providers/WebSocketProvider.tsx index 73dcec81..78d3c3c8 100644 --- a/providers/WebSocketProvider.tsx +++ b/providers/WebSocketProvider.tsx @@ -1,5 +1,4 @@ import { getSessionApi } from "@jellyfin/sdk/lib/utils/api"; -import { useRouter } from "expo-router"; import { useAtomValue } from "jotai"; import { createContext, @@ -12,6 +11,7 @@ import { useState, } from "react"; import { AppState, type AppStateStatus } from "react-native"; +import useRouter from "@/hooks/useAppRouter"; import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider"; import { useNetworkStatus } from "@/providers/NetworkStatusProvider"; diff --git a/utils/downloads/offline-series.ts b/utils/downloads/offline-series.ts new file mode 100644 index 00000000..29bb5e1a --- /dev/null +++ b/utils/downloads/offline-series.ts @@ -0,0 +1,114 @@ +/** + * Utility functions for querying downloaded series/episode data. + * Centralizes common filtering patterns to reduce duplication. + */ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import type { DownloadedItem } from "@/providers/Downloads/types"; + +/** Sort episodes by season then episode number */ +const sortByEpisodeOrder = (a: BaseItemDto, b: BaseItemDto) => + (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || + (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0); + +/** + * Get all downloaded episodes for a series, sorted by season and episode number. + */ +export function getDownloadedEpisodesForSeries( + downloadedItems: DownloadedItem[] | undefined, + seriesId: string, +): BaseItemDto[] { + if (!downloadedItems) return []; + return downloadedItems + .filter((f) => f.item.SeriesId === seriesId) + .map((f) => f.item) + .sort(sortByEpisodeOrder); +} + +/** + * Get downloaded episodes for a specific season of a series. + */ +export function getDownloadedEpisodesForSeason( + downloadedItems: DownloadedItem[] | undefined, + seriesId: string, + seasonNumber: number, +): BaseItemDto[] { + return getDownloadedEpisodesForSeries(downloadedItems, seriesId).filter( + (ep) => ep.ParentIndexNumber === seasonNumber, + ); +} + +/** + * Get downloaded episodes by seasonId (for carousel views). + */ +export function getDownloadedEpisodesBySeasonId( + downloadedItems: DownloadedItem[] | undefined, + seasonId: string, +): BaseItemDto[] { + if (!downloadedItems) return []; + return downloadedItems + .filter((f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId) + .map((f) => f.item) + .sort(sortByEpisodeOrder); +} + +/** + * Get unique season numbers from downloaded episodes for a series. + */ +export function getDownloadedSeasonNumbers( + downloadedItems: DownloadedItem[] | undefined, + seriesId: string, +): number[] { + const episodes = getDownloadedEpisodesForSeries(downloadedItems, seriesId); + return [ + ...new Set( + episodes + .map((ep) => ep.ParentIndexNumber) + .filter((n): n is number => n != null), + ), + ].sort((a, b) => a - b); +} + +/** + * Build fake season objects from downloaded episodes. + * Useful for offline mode where we don't have actual season data. + */ +export function buildOfflineSeasons( + downloadedItems: DownloadedItem[] | undefined, + seriesId: string, +): BaseItemDto[] { + const episodes = getDownloadedEpisodesForSeries(downloadedItems, seriesId); + const seasonNumbers = getDownloadedSeasonNumbers(downloadedItems, seriesId); + + return seasonNumbers.map((seasonNum) => { + const firstEpisode = episodes.find( + (ep) => ep.ParentIndexNumber === seasonNum, + ); + return { + Id: `offline-season-${seasonNum}`, + IndexNumber: seasonNum, + Name: firstEpisode?.SeasonName || `Season ${seasonNum}`, + SeriesId: seriesId, + } as BaseItemDto; + }); +} + +/** + * Build a series-like object from downloaded episodes. + * Useful for offline mode where we don't have the actual series data. + */ +export function buildOfflineSeriesFromEpisodes( + downloadedItems: DownloadedItem[] | undefined, + seriesId: string, +): BaseItemDto | null { + const episodes = getDownloadedEpisodesForSeries(downloadedItems, seriesId); + if (episodes.length === 0) return null; + + const firstEpisode = episodes[0]; + return { + Id: seriesId, + Name: firstEpisode.SeriesName, + Type: "Series", + ProductionYear: firstEpisode.ProductionYear, + Overview: firstEpisode.SeriesName, + } as BaseItemDto; +}