refactor: downloads to minimize prop drilling and improve layout and design (#1337)

Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
This commit is contained in:
Alex
2026-01-12 03:38:41 +11:00
committed by GitHub
parent cfa638afc6
commit ad54823f96
82 changed files with 948 additions and 809 deletions

View File

@@ -77,6 +77,21 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error
- File-based routing in `app/` directory - File-based routing in `app/` directory
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)` - Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,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`): **Providers** (wrapping order in `app/_layout.tsx`):
1. JotaiProvider 1. JotaiProvider

View File

@@ -1,9 +1,10 @@
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { Pressable } from "react-native-gesture-handler"; import { Pressable } from "react-native-gesture-handler";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
@@ -57,25 +58,6 @@ export default function IndexLayout() {
), ),
}} }}
/> />
<Stack.Screen
name='downloads/[seriesId]'
options={{
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
title: t("home.downloads.tvseries"),
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen <Stack.Screen
name='sessions/index' name='sessions/index'
options={{ options={{

View File

@@ -1,181 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList } from "@shopify/flash-list";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
export default function page() {
const navigation = useNavigation();
const local = useLocalSearchParams();
const { seriesId, episodeSeasonIndex } = local as {
seriesId: string;
episodeSeasonIndex: number | string | undefined;
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{},
);
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<number, BaseItemDto[]> = {};
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<BaseItemDto[]>(() => {
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 (
<View className='flex flex-row items-center justify-start pb-2'>
<SeasonDropdown
item={series[0].item}
seasons={uniqueSeasons}
state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
}));
}}
/>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
</View>
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name='trash' size={20} color='white' />
</TouchableOpacity>
</View>
</View>
);
}, [
series,
uniqueSeasons,
seasonIndexState,
initialSeasonIndex,
groupBySeason,
deleteSeries,
]);
return (
<View className='flex-1'>
<FlashList
key={seasonIndex}
data={groupBySeason}
renderItem={({ item }) => <EpisodeCard item={item} />}
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,
}}
/>
</View>
);
}

View File

@@ -1,5 +1,5 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -13,8 +13,10 @@ import ActiveDownloads from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize"; import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard"; import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard"; import { SeriesCard } from "@/components/downloads/SeriesCard";
import useRouter from "@/hooks/useAppRouter";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem } from "@/providers/Downloads/types"; import { type DownloadedItem } from "@/providers/Downloads/types";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { queueAtom } from "@/utils/atoms/queue"; import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
@@ -161,145 +163,99 @@ export default function page() {
); );
return ( return (
<ScrollView <OfflineModeProvider isOffline={true}>
showsVerticalScrollIndicator={false} <ScrollView
contentInsetAdjustmentBehavior='automatic' showsVerticalScrollIndicator={false}
> contentInsetAdjustmentBehavior='automatic'
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}> >
<View className='mb-4 flex flex-col space-y-4 px-4'> <View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
{/* Queue card - hidden */} <View className='mb-4 flex flex-col space-y-4 px-4'>
{/* <View className='bg-neutral-900 p-4 rounded-2xl'> <ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'> <Text className='text-lg font-bold'>
{t("home.downloads.queue")} {t("home.downloads.movies")}
</Text> </Text>
<Text className='text-xs opacity-70 text-red-600'> <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
{t("home.downloads.queue_hint")} <Text className='text-xs font-bold'>{movies?.length}</Text>
</Text> </View>
<View className='flex flex-col space-y-2 mt-2'> </View>
{queue.map((q, index) => ( <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<TouchableOpacity <View className='px-4 flex flex-row'>
onPress={() => {movies?.map((item) => (
router.push(`/(auth)/items/page?id=${q.item.Id}`) <TouchableItemRouter item={item.item} key={item.item.Id}>
} <MovieCard item={item.item} />
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between' </TouchableItemRouter>
key={index}
>
<View>
<Text className='font-semibold'>{q.item.Name}</Text>
<Text className='text-xs opacity-50'>
{q.item.Type}
</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name='close' size={24} color='red' />
</TouchableOpacity>
</TouchableOpacity>
))} ))}
</View> </View>
</ScrollView>
{queue.length === 0 && (
<Text className='opacity-50'>
{t("home.downloads.no_items_in_queue")}
</Text>
)}
</View> */}
<ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> )}
<View className='px-4 flex flex-row'> {groupedBySeries.length > 0 && (
{movies?.map((item) => ( <View className='mb-4'>
<TouchableItemRouter <View className='flex flex-row items-center justify-between mb-2 px-4'>
item={item.item} <Text className='text-lg font-bold'>
isOffline {t("home.downloads.tvseries")}
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text> </Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text>
</View>
</View> </View>
</View> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <View className='px-4 flex flex-row'>
<View className='px-4 flex flex-row'> {groupedBySeries?.map((items) => (
{groupedBySeries?.map((items) => ( <View
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}> className='mb-2 last:mb-0'
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId} key={items[0].item.SeriesId}
/> >
</View> <SeriesCard
))} items={items.map((i) => i.item)}
</View> key={items[0].item.SeriesId}
</ScrollView> />
</View> </View>
)} ))}
</View>
{otherMedia.length > 0 && ( </ScrollView>
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.other_media")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{otherMedia?.length}</Text>
</View>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> )}
<View className='px-4 flex flex-row'>
{otherMedia?.map((item) => ( {otherMedia.length > 0 && (
<TouchableItemRouter <View className='mb-4'>
item={item.item} <View className='flex flex-row items-center justify-between mb-2 px-4'>
isOffline <Text className='text-lg font-bold'>
key={item.item.Id} {t("home.downloads.other_media")}
> </Text>
<MovieCard item={item.item} /> <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
</TouchableItemRouter> <Text className='text-xs font-bold'>
))} {otherMedia?.length}
</Text>
</View>
</View> </View>
</ScrollView> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
</View> <View className='px-4 flex flex-row'>
)} {otherMedia?.map((item) => (
{downloadedFiles?.length === 0 && ( <TouchableItemRouter item={item.item} key={item.item.Id}>
<View className='flex px-4'> <MovieCard item={item.item} />
<Text className='opacity-50'> </TouchableItemRouter>
{t("home.downloads.no_downloaded_items")} ))}
</Text> </View>
</View> </ScrollView>
)} </View>
</View> )}
</ScrollView> {downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View>
</ScrollView>
</OfflineModeProvider>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useNavigation, useRouter } from "expo-router"; import { useNavigation } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -11,6 +11,7 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { QuickConnect } from "@/components/settings/QuickConnect"; import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings"; import { StorageSettings } from "@/components/settings/StorageSettings";
import { UserInfo } from "@/components/settings/UserInfo"; import { UserInfo } from "@/components/settings/UserInfo";
import useRouter from "@/hooks/useAppRouter";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
export default function settings() { export default function settings() {

View File

@@ -13,6 +13,7 @@ import Animated, {
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent"; import { ItemContent } from "@/components/ItemContent";
import { useItemQuery } from "@/hooks/useItemQuery"; import { useItemQuery } from "@/hooks/useItemQuery";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
const Page: React.FC = () => { const Page: React.FC = () => {
const { id } = useLocalSearchParams() as { id: string }; const { id } = useLocalSearchParams() as { id: string };
@@ -75,39 +76,35 @@ const Page: React.FC = () => {
); );
return ( return (
<View className='flex flex-1 relative'> <OfflineModeProvider isOffline={isOffline}>
<Animated.View <View className='flex flex-1 relative'>
pointerEvents={"none"} <Animated.View
style={[animatedStyle]} pointerEvents={"none"}
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black' style={[animatedStyle]}
> className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
<View >
style={{ <View
height: item?.Type === "Episode" ? 300 : 450, style={{
}} height: item?.Type === "Episode" ? 300 : 450,
className='bg-transparent rounded-lg mb-4 w-full' }}
/> className='bg-transparent rounded-lg mb-4 w-full'
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' /> />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' /> <View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' /> <View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='flex flex-row space-x-1 mb-8'> <View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> <View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> <View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> <View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
</View> <View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' /> </View>
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' /> <View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' /> <View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' /> <View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
</Animated.View> <View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
{item && ( </Animated.View>
<ItemContent {item && <ItemContent item={item} itemWithSources={itemWithSources} />}
item={item} </View>
isOffline={isOffline} </OfflineModeProvider>
itemWithSources={itemWithSources}
/>
)}
</View>
); );
}; };

View File

@@ -8,7 +8,7 @@ import {
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -27,6 +27,7 @@ import { PlatformDropdown } from "@/components/PlatformDropdown";
import { JellyserrRatings } from "@/components/Ratings"; import { JellyserrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions"; import { ItemActions } from "@/components/series/SeriesActions";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";

View File

@@ -14,86 +14,124 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp"; import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker"; import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader"; import { SeriesHeader } from "@/components/series/SeriesHeader";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; 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 { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { storage } from "@/utils/mmkv";
const page: React.FC = () => { const page: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as { const {
id: seriesId,
seasonIndex,
offline: offlineParam,
} = params as {
id: string; id: string;
seasonIndex: string; seasonIndex: string;
offline?: string;
}; };
const isOffline = offlineParam === "true";
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); 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({ const { data: item } = useQuery({
queryKey: ["series", seriesId], queryKey: ["series", seriesId, isOffline, downloadedItems.length],
queryFn: async () => queryFn: async () => {
await getUserItemData({ if (isOffline) {
return buildOfflineSeriesFromEpisodes(getDownloadedItems(), seriesId);
}
return await getUserItemData({
api, api,
userId: user?.Id, userId: user?.Id,
itemId: seriesId, itemId: seriesId,
}), });
staleTime: 60 * 1000, },
staleTime: isOffline ? Infinity : 60 * 1000,
enabled: isOffline || (!!api && !!user?.Id),
}); });
const backdropUrl = useMemo( // For offline mode, use stored base64 image
() => const base64Image = useMemo(() => {
getBackdropUrl({ if (isOffline) {
api, return storage.getString(seriesId);
item, }
quality: 90, return null;
width: 1000, }, [isOffline, seriesId]);
}),
[item],
);
const logoUrl = useMemo( const backdropUrl = useMemo(() => {
() => if (isOffline && base64Image) {
getLogoImageUrlById({ return `data:image/jpeg;base64,${base64Image}`;
api, }
item, return getBackdropUrl({
}), api,
[item], 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({ const { data: allEpisodes, isLoading } = useQuery({
queryKey: ["AllEpisodes", item?.Id], queryKey: ["AllEpisodes", seriesId, isOffline, downloadedItems.length],
queryFn: async () => { 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({ const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.Id, seriesId: seriesId,
userId: user.Id, userId: user.Id,
enableUserData: true, enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
}); });
return res?.data.Items || []; return res?.data.Items || [];
}, },
select: (data) => 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( [...(data || [])].sort(
(a, b) => (a, b) =>
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0), (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
), ),
staleTime: 60, staleTime: isOffline ? Infinity : 60,
enabled: !!api && !!user?.Id && !!item?.Id, enabled: isOffline || (!!api && !!user?.Id),
}); });
useEffect(() => { useEffect(() => {
// Don't show header buttons in offline mode
if (isOffline) {
navigation.setOptions({
headerRight: () => null,
});
return;
}
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRight: () =>
!isLoading && !isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
item &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} /> <AddToFavorites item={item} />
{!Platform.isTV && ( {!Platform.isTV && (
@@ -114,49 +152,64 @@ const page: React.FC = () => {
/> />
)} )}
</View> </View>
), ) : 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 ( return (
<ParallaxScrollView <OfflineModeProvider isOffline={isOffline}>
headerHeight={400} <ParallaxScrollView
headerImage={ headerHeight={400}
<Image headerImage={
source={{ backdropUrl ? (
uri: backdropUrl, <Image
}} source={{
style={{ uri: backdropUrl,
width: "100%", }}
height: "100%", style={{
}} width: "100%",
/> height: "100%",
} }}
logo={ />
logoUrl ? ( ) : (
<Image <View
source={{ style={{
uri: logoUrl, width: "100%",
}} height: "100%",
style={{ backgroundColor: "#1a1a1a",
height: 130, }}
width: "100%", />
}} )
contentFit='contain' }
/> logo={
) : undefined logoUrl ? (
} <Image
> source={{
<View className='flex flex-col pt-4'> uri: logoUrl,
<SeriesHeader item={item} /> }}
<View className='mb-4'> style={{
<NextUp seriesId={seriesId} /> height: 130,
width: "100%",
}}
contentFit='contain'
/>
) : undefined
}
>
<View className='flex flex-col pt-4'>
<SeriesHeader item={item} />
{!isOffline && (
<View className='mb-4'>
<NextUp seriesId={seriesId} />
</View>
)}
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
</View> </View>
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} /> </ParallaxScrollView>
</View> </OfflineModeProvider>
</ParallaxScrollView>
); );
}; };

View File

@@ -7,7 +7,7 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { import {
useCallback, useCallback,
@@ -36,6 +36,7 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper"; import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons"; import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -57,6 +58,7 @@ const exampleSearches = [
export default function search() { export default function search() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);

View File

@@ -1,7 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -19,6 +19,7 @@ import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { import {
useDeleteWatchlist, useDeleteWatchlist,

View File

@@ -1,9 +1,10 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler"; import { Pressable } from "react-native-gesture-handler";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import useRouter from "@/hooks/useAppRouter";
import { useStreamystatsEnabled } from "@/hooks/useWatchlists"; import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
export default function WatchlistsLayout() { export default function WatchlistsLayout() {

View File

@@ -1,5 +1,4 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -15,6 +14,7 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useCreateWatchlist } from "@/hooks/useWatchlistMutations"; import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
import type { import type {
StreamystatsWatchlistAllowedItemType, StreamystatsWatchlistAllowedItemType,

View File

@@ -1,5 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -15,6 +15,7 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations"; import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists"; import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
import type { import type {

View File

@@ -1,6 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { import {
useStreamystatsEnabled, useStreamystatsEnabled,
useWatchlistsQuery, useWatchlistsQuery,

View File

@@ -6,7 +6,6 @@ import type {
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { import React, {
useCallback, useCallback,
@@ -38,6 +37,7 @@ import { Text } from "@/components/common/Text";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
import { useMusicCast } from "@/hooks/useMusicCast"; import { useMusicCast } from "@/hooks/useMusicCast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";

View File

@@ -10,13 +10,12 @@ import {
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; 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 { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, useWindowDimensions, View } from "react-native"; import { Alert, Platform, useWindowDimensions, View } from "react-native";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated"; import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -27,6 +26,7 @@ import {
PlaybackSpeedScope, PlaybackSpeedScope,
updatePlaybackSpeedSettings, updatePlaybackSpeedSettings,
} from "@/components/video-player/controls/utils/playback-speed-settings"; } from "@/components/video-player/controls/utils/playback-speed-settings";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
@@ -44,6 +44,9 @@ import {
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types"; import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { import {
@@ -60,6 +63,7 @@ export default function page() {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = useNavigation(); const navigation = useNavigation();
const router = useRouter();
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const { width: screenWidth, height: screenHeight } = useWindowDimensions();
@@ -87,10 +91,9 @@ export default function page() {
: require("react-native-volume-manager"); : require("react-native-volume-manager");
const downloadUtils = useDownload(); const downloadUtils = useDownload();
const downloadedFiles = useMemo( // Call directly instead of useMemo - the function reference doesn't change
() => downloadUtils.getDownloadedItems(), // when data updates, only when the provider initializes
[downloadUtils.getDownloadedItems], const downloadedFiles = downloadUtils.getDownloadedItems();
);
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -109,7 +112,7 @@ export default function page() {
bitrateValue: bitrateValueStr, bitrateValue: bitrateValueStr,
offline: offlineStr, offline: offlineStr,
playbackPosition: playbackPositionFromUrl, playbackPosition: playbackPositionFromUrl,
} = useGlobalSearchParams<{ } = useLocalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
subtitleIndex: string; subtitleIndex: string;
@@ -677,8 +680,8 @@ export default function page() {
return; return;
} }
if (isLoading) { if (isLoading !== undefined) {
setIsBuffering(true); setIsBuffering(isLoading);
} }
}, },
[playbackManager, item?.Id, progress], [playbackManager, item?.Id, progress],
@@ -833,99 +836,99 @@ export default function page() {
); );
return ( return (
<PlayerProvider <OfflineModeProvider isOffline={offline}>
playerRef={videoRef} <PlayerProvider
item={item} playerRef={videoRef}
mediaSource={stream?.mediaSource} item={item}
isVideoLoaded={isVideoLoaded} mediaSource={stream?.mediaSource}
tracksReady={tracksReady} isVideoLoaded={isVideoLoaded}
offline={offline} tracksReady={tracksReady}
downloadedItem={downloadedItem} downloadedItem={downloadedItem}
> >
<VideoProvider> <VideoProvider>
<View
style={{
flex: 1,
backgroundColor: "black",
height: "100%",
width: "100%",
}}
>
<View <View
style={{ style={{
display: "flex", flex: 1,
width: "100%", backgroundColor: "black",
height: "100%", height: "100%",
position: "relative", width: "100%",
flexDirection: "column",
justifyContent: "center",
}} }}
> >
<MpvPlayerView <View
ref={videoRef} style={{
source={videoSource} display: "flex",
style={{ width: "100%", height: "100%" }} width: "100%",
onProgress={onProgress} height: "100%",
onPlaybackStateChange={onPlaybackStateChanged} position: "relative",
onLoad={() => setIsVideoLoaded(true)} flexDirection: "column",
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => { justifyContent: "center",
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); <MpvPlayerView
}} ref={videoRef}
/> source={videoSource}
{!hasPlaybackStarted && ( style={{ width: "100%", height: "100%" }}
<View onProgress={onProgress}
style={{ onPlaybackStateChange={onPlaybackStateChanged}
position: "absolute", onLoad={() => setIsVideoLoaded(true)}
top: 0, onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
left: 0, console.error("Video Error:", e.nativeEvent);
right: 0, Alert.alert(
bottom: 0, t("player.error"),
backgroundColor: "black", t("player.an_error_occured_while_playing_the_video"),
justifyContent: "center", );
alignItems: "center", writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
> onTracksReady={() => {
<Loader /> setTracksReady(true);
</View> }}
/>
{!hasPlaybackStarted && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "black",
justifyContent: "center",
alignItems: "center",
}}
>
<Loader />
</View>
)}
</View>
{isMounted === true && item && !isPipMode && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle}
api={api}
downloadedFiles={downloadedFiles}
playbackSpeed={currentPlaybackSpeed}
setPlaybackSpeed={handleSetPlaybackSpeed}
/>
)} )}
</View> </View>
{isMounted === true && item && !isPipMode && ( </VideoProvider>
<Controls </PlayerProvider>
mediaSource={stream?.mediaSource} </OfflineModeProvider>
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
offline={offline}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle}
api={api}
downloadedFiles={downloadedFiles}
playbackSpeed={currentPlaybackSpeed}
setPlaybackSpeed={handleSetPlaybackSpeed}
/>
)}
</View>
</VideoProvider>
</PlayerProvider>
); );
} }

View File

@@ -48,7 +48,7 @@ import type {
NotificationResponse, NotificationResponse,
} from "expo-notifications/build/Notifications.types"; } from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.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 SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager"; import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai"; import { Provider as JotaiProvider, useAtom } from "jotai";
@@ -57,6 +57,7 @@ import { I18nextProvider } from "react-i18next";
import { Appearance } from "react-native"; import { Appearance } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store"; import { store } from "@/utils/store";
import "react-native-reanimated"; import "react-native-reanimated";
@@ -81,14 +82,9 @@ SplashScreen.setOptions({
fade: true, fade: true,
}); });
function redirect(notification: typeof Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
function useNotificationObserver() { function useNotificationObserver() {
const router = useRouter();
useEffect(() => { useEffect(() => {
if (Platform.isTV) return; if (Platform.isTV) return;
@@ -99,14 +95,17 @@ function useNotificationObserver() {
if (!isMounted || !response?.notification) { if (!isMounted || !response?.notification) {
return; return;
} }
redirect(response?.notification); const url = response?.notification.request.content.data?.url;
if (url) {
router.push(url);
}
}, },
); );
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, []); }, [router]);
} }
if (!Platform.isTV) { if (!Platform.isTV) {
@@ -231,6 +230,7 @@ function Layout() {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const _segments = useSegments(); const _segments = useSegments();
const router = useRouter();
useEffect(() => { useEffect(() => {
i18n.changeLanguage( i18n.changeLanguage(
@@ -323,9 +323,6 @@ function Layout() {
responseListener.current = responseListener.current =
Notifications?.addNotificationResponseReceivedListener( Notifications?.addNotificationResponseReceivedListener(
(response: NotificationResponse) => { (response: NotificationResponse) => {
// redirect if internal notification
redirect(response?.notification);
// Currently the notifications supported by the plugin will send data for deep links. // Currently the notifications supported by the plugin will send data for deep links.
const { title, data } = response.notification.request.content; const { title, data } = response.notification.request.content;
writeInfoLog(`Notification ${title} opened`, data); writeInfoLog(`Notification ${title} opened`, data);

View File

@@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "streamyfin", "name": "streamyfin",

View File

@@ -9,13 +9,14 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } 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 { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Platform, Switch, View, type ViewProps } from "react-native"; import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -62,6 +63,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [queue, _setQueue] = useAtom(queueAtom); const [queue, _setQueue] = useAtom(queueAtom);
const { settings } = useSettings(); const { settings } = useSettings();
const router = useRouter();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false); const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, downloadedItems } = useDownload(); const { processes, startBackgroundDownload, downloadedItems } = useDownload();
@@ -170,9 +172,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
firstItem.Type !== "Episode" firstItem.Type !== "Episode"
? "/downloads" ? "/downloads"
: ({ : ({
pathname: `/downloads/${firstItem.SeriesId}`, pathname: "/series/[id]",
params: { params: {
episodeSeasonIndex: firstItem.ParentIndexNumber, id: firstItem.SeriesId!,
seasonIndex: firstItem.ParentIndexNumber?.toString(),
offline: "true",
}, },
} as Href), } as Href),
); );

View File

@@ -6,13 +6,13 @@ import {
BottomSheetScrollView, BottomSheetScrollView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { router } from "expo-router";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Linking, Platform, TouchableOpacity, View } from "react-native"; import { Linking, Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
export interface IntroSheetRef { export interface IntroSheetRef {
@@ -24,6 +24,7 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
const bottomSheetRef = useRef<BottomSheetModal>(null); const bottomSheetRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter();
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
present: () => { present: () => {

View File

@@ -26,6 +26,7 @@ import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites"; import { AddToFavorites } from "./AddToFavorites";
@@ -45,13 +46,13 @@ export type SelectedOptions = {
interface ItemContentProps { interface ItemContentProps {
item: BaseItemDto; item: BaseItemDto;
isOffline: boolean;
itemWithSources?: BaseItemDto | null; itemWithSources?: BaseItemDto | null;
} }
export const ItemContent: React.FC<ItemContentProps> = React.memo( export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline, itemWithSources }) => { ({ item, itemWithSources }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const isOffline = useOfflineMode();
const { settings } = useSettings(); const { settings } = useSettings();
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const navigation = useNavigation(); const navigation = useNavigation();
@@ -228,7 +229,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
<PlayButton <PlayButton
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
item={item} item={item}
isOffline={isOffline}
colors={itemColors} colors={itemColors}
/> />
<View className='w-1' /> <View className='w-1' />
@@ -243,11 +243,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
</View> </View>
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<SeasonEpisodesCarousel <SeasonEpisodesCarousel item={item} loading={loading} />
item={item}
loading={loading}
isOffline={isOffline}
/>
)} )}
{!isOffline && {!isOffline &&
@@ -264,7 +260,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
<CurrentSeries item={item} className='mb-2' /> <CurrentSeries item={item} className='mb-2' />
)} )}
<ItemPeopleSections item={item} isOffline={isOffline} /> <ItemPeopleSections item={item} />
{!isOffline && <SimilarItems itemId={item.Id} />} {!isOffline && <SimilarItems itemId={item.Id} />}
</> </>

View File

@@ -2,7 +2,6 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet"; import { BottomSheetView } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -24,11 +23,13 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { getDownloadedItemById } from "@/providers/Downloads/database"; import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
@@ -44,7 +45,6 @@ import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof TouchableOpacity> { interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item: BaseItemDto; item: BaseItemDto;
selectedOptions: SelectedOptions; selectedOptions: SelectedOptions;
isOffline?: boolean;
colors?: ThemeColors; colors?: ThemeColors;
} }
@@ -54,9 +54,9 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
isOffline,
colors, colors,
}: Props) => { }: Props) => {
const isOffline = useOfflineMode();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
@@ -300,6 +300,19 @@ export const PlayButton: React.FC<Props> = ({
// Check if item is downloaded // Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined; 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 (downloadedItem) {
if (Platform.OS === "android") { if (Platform.OS === "android") {
// Show bottom sheet for Android // Show bottom sheet for Android

View File

@@ -1,6 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
@@ -14,6 +13,7 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";

View File

@@ -7,7 +7,6 @@ import { RoundButton } from "./RoundButton";
interface Props extends ViewProps { interface Props extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
isOffline?: boolean;
size?: "default" | "large"; size?: "default" | "large";
} }

View File

@@ -7,7 +7,6 @@ import {
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
@@ -26,6 +25,7 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";

View File

@@ -1,8 +1,8 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur"; import { BlurView, type BlurViewProps } from "expo-blur";
import { useRouter } from "expo-router";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { Pressable, type PressableProps } from "react-native-gesture-handler"; import { Pressable, type PressableProps } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter";
interface Props extends BlurViewProps { interface Props extends BlurViewProps {
background?: "blur" | "transparent"; background?: "blur" | "transparent";

View File

@@ -1,7 +1,8 @@
import { useRouter, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { type PropsWithChildren } from "react"; import { type PropsWithChildren } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";

View File

@@ -1,14 +1,16 @@
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; 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 { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useDownload } from "@/providers/DownloadProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
isOffline?: boolean;
} }
export const itemRouter = (item: BaseItemDto, from: string) => { export const itemRouter = (item: BaseItemDto, from: string) => {
@@ -134,26 +136,20 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item, item,
isOffline = false,
children, children,
...props ...props
}) => { }) => {
const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]); const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item); const { isFavorite, toggleFavorite } = useFavorite(item);
const router = useRouter();
const isOffline = useOfflineMode();
const { deleteFile } = useDownload();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
const handlePress = useCallback(() => { 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. // Force music libraries to navigate via the explicit string route.
// This avoids losing the dynamic [libraryId] param when going through a nested navigator. // This avoids losing the dynamic [libraryId] param when going through a nested navigator.
if ("CollectionType" in item && item.CollectionType === "music") { if ("CollectionType" in item && item.CollectionType === "music") {
@@ -163,7 +159,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const navigation = getItemNavigation(item, from); const navigation = getItemNavigation(item, from);
router.push(navigation as any); router.push(navigation as any);
}, [from, isOffline, item, router]); }, [from, item, router]);
const showActionSheet = useCallback(() => { const showActionSheet = useCallback(() => {
if ( if (
@@ -179,14 +175,19 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
"Mark as Played", "Mark as Played",
"Mark as Not Played", "Mark as Not Played",
isFavorite ? "Unmark as Favorite" : "Mark as Favorite", isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
...(isOffline ? ["Delete Download"] : []),
"Cancel", "Cancel",
]; ];
const cancelButtonIndex = options.length - 1; const cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = isOffline
? cancelButtonIndex - 1
: undefined;
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
options, options,
cancelButtonIndex, cancelButtonIndex,
destructiveButtonIndex,
}, },
async (selectedIndex) => { async (selectedIndex) => {
if (selectedIndex === 0) { if (selectedIndex === 0) {
@@ -195,6 +196,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
await markAsPlayedStatus(false); await markAsPlayedStatus(false);
} else if (selectedIndex === 2) { } else if (selectedIndex === 2) {
toggleFavorite(); toggleFavorite();
} else if (isOffline && selectedIndex === 3 && item.Id) {
deleteFile(item.Id);
} }
}, },
); );
@@ -203,6 +206,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
isFavorite, isFavorite,
markAsPlayedStatus, markAsPlayedStatus,
toggleFavorite, toggleFavorite,
isOffline,
deleteFile,
item.Id,
]); ]);
if ( if (

View File

@@ -1,6 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
@@ -11,6 +10,7 @@ import {
} from "react-native"; } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator"; import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";

View File

@@ -61,7 +61,6 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
return ( return (
<TouchableItemRouter <TouchableItemRouter
item={item} item={item}
isOffline={true}
onLongPress={showActionSheet} onLongPress={showActionSheet}
className='flex flex-col mb-4' className='flex flex-col mb-4'
> >

View File

@@ -67,7 +67,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
}, [showActionSheetWithOptions, handleDeleteFile]); }, [showActionSheetWithOptions, handleDeleteFile]);
return ( return (
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline> <TouchableItemRouter onLongPress={showActionSheet} item={item}>
{base64Image ? ( {base64Image ? (
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'> <View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
<Image <Image

View File

@@ -2,11 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { router } from "expo-router";
import type React from "react"; import type React from "react";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { DownloadSize } from "@/components/downloads/DownloadSize"; import { DownloadSize } from "@/components/downloads/DownloadSize";
import useRouter from "@/hooks/useAppRouter";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -14,6 +14,7 @@ import { Text } from "../common/Text";
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => { export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const { deleteItems } = useDownload(); const { deleteItems } = useDownload();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const router = useRouter();
const base64Image = useMemo(() => { const base64Image = useMemo(() => {
return storage.getString(items[0].SeriesId!); return storage.getString(items[0].SeriesId!);
@@ -46,7 +47,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)} onPress={() =>
router.push({
pathname: "/series/[id]",
params: { id: items[0].SeriesId!, offline: "true" },
})
}
onLongPress={showActionSheet} onLongPress={showActionSheet}
> >
{base64Image ? ( {base64Image ? (

View File

@@ -2,7 +2,6 @@ import type { Api } from "@jellyfin/sdk";
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
@@ -10,6 +9,7 @@ import { Text, View } from "react-native";
// PNG ASSET // PNG ASSET
import heart from "@/assets/icons/heart.fill.png"; import heart from "@/assets/icons/heart.fill.png";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList"; import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList";

View File

@@ -12,7 +12,7 @@ import {
getUserViewsApi, getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query"; 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 { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -33,6 +33,7 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection"; import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";

View File

@@ -12,7 +12,7 @@ import {
getUserViewsApi, getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query"; 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 { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -35,6 +35,7 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection"; import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";

View File

@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { Dimensions, View, type ViewProps } from "react-native"; import { Dimensions, View, type ViewProps } from "react-native";
@@ -16,6 +16,7 @@ import Carousel, {
type ICarouselInstance, type ICarouselInstance,
Pagination, Pagination,
} from "react-native-reanimated-carousel"; } from "react-native-reanimated-carousel";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";

View File

@@ -21,7 +21,6 @@ interface Props extends ViewProps {
queryKey: QueryKey; queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[]>; queryFn: QueryFunction<BaseItemDto[]>;
hideIfEmpty?: boolean; hideIfEmpty?: boolean;
isOffline?: boolean;
scrollY?: number; // For lazy loading scrollY?: number; // For lazy loading
enableLazyLoading?: boolean; // Enable/disable lazy loading enableLazyLoading?: boolean; // Enable/disable lazy loading
} }
@@ -33,7 +32,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
queryFn, queryFn,
queryKey, queryKey,
hideIfEmpty = false, hideIfEmpty = false,
isOffline = false,
scrollY = 0, scrollY = 0,
enableLazyLoading = false, enableLazyLoading = false,
...props ...props
@@ -106,7 +104,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
<TouchableItemRouter <TouchableItemRouter
item={item} item={item}
key={item.Id} key={item.Id}
isOffline={isOffline}
className={`mr-2 className={`mr-2
${orientation === "horizontal" ? "w-44" : "w-28"} ${orientation === "horizontal" ? "w-44" : "w-28"}
`} `}

View File

@@ -8,17 +8,14 @@ import { InteractionManager, View, type ViewProps } from "react-native";
import { MoreMoviesWithActor } from "@/components/MoreMoviesWithActor"; import { MoreMoviesWithActor } from "@/components/MoreMoviesWithActor";
import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CastAndCrew } from "@/components/series/CastAndCrew";
import { useItemPeopleQuery } from "@/hooks/useItemPeopleQuery"; import { useItemPeopleQuery } from "@/hooks/useItemPeopleQuery";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; item: BaseItemDto;
isOffline: boolean;
} }
export const ItemPeopleSections: React.FC<Props> = ({ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
item, const isOffline = useOfflineMode();
isOffline,
...props
}) => {
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
useEffect(() => { useEffect(() => {

View File

@@ -1,8 +1,9 @@
import { useRouter, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import Poster from "@/components/posters/Poster"; import Poster from "@/components/posters/Poster";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
interface Props { interface Props {

View File

@@ -1,9 +1,10 @@
import { router, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { useCallback } from "react"; import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native"; import { TouchableOpacity, type ViewProps } from "react-native";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard"; import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { import {
COMPANY_LOGO_IMAGE_FILTER, COMPANY_LOGO_IMAGE_FILTER,
@@ -16,6 +17,7 @@ const CompanySlide: React.FC<
> = ({ slide, data, ...props }) => { > = ({ slide, data, ...props }) => {
const segments = useSegments(); const segments = useSegments();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const router = useRouter();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
const navigate = useCallback( const navigate = useCallback(

View File

@@ -1,10 +1,11 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { router, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { useCallback } from "react"; import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native"; import { TouchableOpacity, type ViewProps } from "react-native";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard"; import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import useRouter from "@/hooks/useAppRouter";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces"; 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<SlideProps & ViewProps> = ({ slide, ...props }) => { const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments(); const segments = useSegments();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const router = useRouter();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
const navigate = useCallback( const navigate = useCallback(

View File

@@ -1,6 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { import {
@@ -23,6 +22,7 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; import { useMusicPlayer } from "@/providers/MusicPlayerProvider";

View File

@@ -1,10 +1,10 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";

View File

@@ -1,10 +1,10 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";

View File

@@ -1,10 +1,10 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";

View File

@@ -3,11 +3,11 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { getLocalPath } from "@/providers/AudioStorage"; import { getLocalPath } from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";

View File

@@ -6,12 +6,12 @@ import {
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useMemo, useRef } from "react"; import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, StyleSheet, TouchableOpacity, View } from "react-native"; import { Alert, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useDeletePlaylist } from "@/hooks/usePlaylistMutations"; import { useDeletePlaylist } from "@/hooks/usePlaylistMutations";
interface Props { interface Props {

View File

@@ -7,7 +7,6 @@ import {
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { import React, {
useCallback, useCallback,
@@ -25,6 +24,7 @@ import {
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
import { import {
audioStorageEvents, audioStorageEvents,

View File

@@ -2,13 +2,14 @@ import type {
BaseItemDto, BaseItemDto,
BaseItemPerson, BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { router, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values"; import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { HorizontalScroll } from "../common/HorizontalScroll"; import { HorizontalScroll } from "../common/HorizontalScroll";
@@ -24,6 +25,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const segments = useSegments(); const segments = useSegments();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const from = (segments as string[])[2]; const from = (segments as string[])[2];
const destinctPeople = useMemo(() => { const destinctPeople = useMemo(() => {

View File

@@ -1,10 +1,10 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values"; import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { HorizontalScroll } from "../common/HorizontalScroll"; import { HorizontalScroll } from "../common/HorizontalScroll";
@@ -18,6 +18,7 @@ interface Props extends ViewProps {
export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => { export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
return ( return (
<View {...props}> <View {...props}>

View File

@@ -1,7 +1,7 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter } from "expo-router";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; item: BaseItemDto;

View File

@@ -2,9 +2,9 @@ import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Button } from "../Button"; import { Button } from "../Button";

View File

@@ -54,29 +54,28 @@ export const SeasonDropdown: React.FC<Props> = ({
[state, item, keys], [state, item, keys],
); );
// Always use IndexNumber for Season objects (not keys.index which is for the item)
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
Number(a[keys.index]) - Number(b[keys.index]); Number(a.IndexNumber) - Number(b.IndexNumber);
const optionGroups = useMemo( const optionGroups = useMemo(
() => [ () => [
{ {
options: options:
seasons?.sort(sortByIndex).map((season: any) => { seasons?.sort(sortByIndex).map((season: any) => {
const title = const title = season.Name || `Season ${season.IndexNumber}`;
season[keys.title] ||
season.Name ||
`Season ${season.IndexNumber}`;
return { return {
type: "radio" as const, type: "radio" as const,
label: title, label: title,
value: season.Id || season.IndexNumber, 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), onPress: () => onSelect(season),
}; };
}) || [], }) || [],
}, },
], ],
[seasons, keys, seasonIndex, onSelect], [seasons, seasonIndex, onSelect],
); );
useEffect(() => { useEffect(() => {

View File

@@ -1,12 +1,14 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, type ViewStyle } from "react-native"; import { TouchableOpacity, type ViewStyle } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { getDownloadedEpisodesBySeasonId } from "@/utils/downloads/offline-series";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { import {
HorizontalScroll, HorizontalScroll,
@@ -17,7 +19,6 @@ import { ItemCardText } from "../ItemCardText";
interface Props { interface Props {
item?: BaseItemDto | null; item?: BaseItemDto | null;
loading?: boolean; loading?: boolean;
isOffline?: boolean;
style?: ViewStyle; style?: ViewStyle;
containerStyle?: ViewStyle; containerStyle?: ViewStyle;
} }
@@ -25,17 +26,14 @@ interface Props {
export const SeasonEpisodesCarousel: React.FC<Props> = ({ export const SeasonEpisodesCarousel: React.FC<Props> = ({
item, item,
loading, loading,
isOffline,
style, style,
containerStyle, containerStyle,
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const isOffline = useOfflineMode();
const router = useRouter();
const { getDownloadedItems } = useDownload(); const { getDownloadedItems } = useDownload();
const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const scrollRef = useRef<HorizontalScrollRef>(null); const scrollRef = useRef<HorizontalScrollRef>(null);
@@ -51,11 +49,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
queryKey: ["episodes", seasonId, isOffline], queryKey: ["episodes", seasonId, isOffline],
queryFn: async () => { queryFn: async () => {
if (isOffline) { if (isOffline) {
return downloadedFiles return getDownloadedEpisodesBySeasonId(getDownloadedItems(), seasonId!);
?.filter(
(f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
)
.map((f) => f.item);
} }
if (!api || !user?.Id || !item?.SeriesId) return []; if (!api || !user?.Id || !item?.SeriesId) return [];
const response = await getTvShowsApi(api).getEpisodes({ const response = await getTvShowsApi(api).getEpisodes({
@@ -73,7 +67,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}); });
return response.data.Items as BaseItemDto[]; return response.data.Items as BaseItemDto[];
}, },
enabled: !!api && !!user?.Id && !!seasonId, enabled: !!seasonId && (isOffline || (!!api && !!user?.Id)),
}); });
useEffect(() => { useEffect(() => {

View File

@@ -10,7 +10,13 @@ import {
SeasonDropdown, SeasonDropdown,
type SeasonIndexState, type SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; 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 { runtimeTicksToSeconds } from "@/utils/time";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -31,6 +37,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const isOffline = useOfflineMode();
const { getDownloadedItems, downloadedItems } = useDownload();
const seasonIndex = useMemo( const seasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""], () => seasonIndexState[item.Id ?? ""],
@@ -38,8 +46,12 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
); );
const { data: seasons } = useQuery({ const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id], queryKey: ["seasons", item.Id, isOffline, downloadedItems.length],
queryFn: async () => { queryFn: async () => {
if (isOffline) {
return buildOfflineSeasons(getDownloadedItems(), item.Id!);
}
if (!api || !user?.Id || !item.Id) return []; if (!api || !user?.Id || !item.Id) return [];
const response = await api.axiosInstance.get( const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.Id}/Seasons`, `${api.basePath}/Shows/${item.Id}/Seasons`,
@@ -58,8 +70,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return response.data.Items; return response.data.Items;
}, },
staleTime: 60, staleTime: isOffline ? Infinity : 60,
enabled: !!api && !!user?.Id && !!item.Id, enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
}); });
const selectedSeasonId: string | null = useMemo(() => { const selectedSeasonId: string | null = useMemo(() => {
@@ -73,9 +85,33 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return season.Id!; return season.Id!;
}, [seasons, seasonIndex]); }, [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({ const { data: episodes, isPending } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId], queryKey: [
"episodes",
item.Id,
isOffline ? selectedSeasonNumber : selectedSeasonId,
isOffline,
downloadedItems.length,
],
queryFn: async () => { queryFn: async () => {
if (isOffline) {
return getDownloadedEpisodesForSeason(
getDownloadedItems(),
item.Id!,
selectedSeasonNumber!,
);
}
if (!api || !user?.Id || !item.Id || !selectedSeasonId) { if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
return []; return [];
} }
@@ -85,7 +121,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
userId: user.Id, userId: user.Id,
seasonId: selectedSeasonId, seasonId: selectedSeasonId,
enableUserData: true, enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
}); });
@@ -97,7 +132,10 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return res.data.Items; 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 // Used for height calculation
@@ -127,7 +165,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
})); }));
}} }}
/> />
{episodes?.length ? ( {episodes?.length && !isOffline ? (
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
<DownloadItems <DownloadItems
title={t("item_card.download.download_season")} title={t("item_card.download.download_season")}
@@ -180,9 +218,11 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
{runtimeTicksToSeconds(e.RunTimeTicks)} {runtimeTicksToSeconds(e.RunTimeTicks)}
</Text> </Text>
</View> </View>
<View className='self-start ml-auto -mt-0.5'> {!isOffline && (
<DownloadSingleItem item={e} /> <View className='self-start ml-auto -mt-0.5'>
</View> <DownloadSingleItem item={e} />
</View>
)}
</View> </View>
<Text <Text

View File

@@ -1,9 +1,9 @@
import { useRouter } from "expo-router";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Linking, Switch } from "react-native"; import { Linking, Switch } from "react-native";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";

View File

@@ -1,6 +1,6 @@
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions"; import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";

View File

@@ -1,5 +1,4 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
@@ -8,6 +7,7 @@ import { Linking, Switch, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import useRouter from "@/hooks/useAppRouter";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text"; import { Text } from "../common/Text";

View File

@@ -1,5 +1,5 @@
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";

View File

@@ -1,9 +1,9 @@
import { useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import React from "react"; import React from "react";
import { View } from "react-native"; import { View } from "react-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export interface ContinueWatchingOverlayProps { export interface ContinueWatchingOverlayProps {

View File

@@ -3,7 +3,7 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { type FC, useCallback, useEffect, useState } from "react"; import { type FC, useCallback, useEffect, useState } from "react";
import { StyleSheet, useWindowDimensions, View } from "react-native"; import { StyleSheet, useWindowDimensions, View } from "react-native";
import Animated, { import Animated, {
@@ -15,12 +15,14 @@ import Animated, {
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay"; import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper"; import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay"; import { useTrickplay } from "@/hooks/useTrickplay";
import { DownloadedItem } from "@/providers/Downloads/types"; import { DownloadedItem } from "@/providers/Downloads/types";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { ticksToMs } from "@/utils/time"; import { ticksToMs } from "@/utils/time";
@@ -49,7 +51,6 @@ interface Props {
enableTrickplay?: boolean; enableTrickplay?: boolean;
togglePlay: () => void; togglePlay: () => void;
setShowControls: (shown: boolean) => void; setShowControls: (shown: boolean) => void;
offline?: boolean;
mediaSource?: MediaSourceInfo | null; mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void; seek: (ticks: number) => void;
startPictureInPicture?: () => Promise<void>; startPictureInPicture?: () => Promise<void>;
@@ -83,12 +84,12 @@ export const Controls: FC<Props> = ({
aspectRatio = "default", aspectRatio = "default",
isZoomedToFill = false, isZoomedToFill = false,
onZoomToggle, onZoomToggle,
offline = false,
api = null, api = null,
downloadedFiles = undefined, downloadedFiles = undefined,
playbackSpeed = 1.0, playbackSpeed = 1.0,
setPlaybackSpeed, setPlaybackSpeed,
}) => { }) => {
const offline = useOfflineMode();
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const router = useRouter(); const router = useRouter();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
@@ -110,7 +111,9 @@ export const Controls: FC<Props> = ({
} = useTrickplay(item); } = useTrickplay(item);
const min = useSharedValue(0); 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 // Animation values for controls
const controlsOpacity = useSharedValue(showControls ? 1 : 0); const controlsOpacity = useSharedValue(showControls ? 1 : 0);
@@ -303,7 +306,7 @@ export const Controls: FC<Props> = ({
offline, offline,
api, api,
downloadedFiles, downloadedFiles,
max.value, maxMs,
); );
const goToItemCommon = useCallback( const goToItemCommon = useCallback(

View File

@@ -2,11 +2,10 @@ import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useGlobalSearchParams } from "expo-router";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native"; 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 ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { import {
HorizontalScroll, HorizontalScroll,
@@ -19,10 +18,16 @@ import {
type SeasonIndexState, type SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import type { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; 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 { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { runtimeTicksToSeconds } from "@/utils/time"; import { runtimeTicksToSeconds } from "@/utils/time";
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
type Props = { type Props = {
item: BaseItemDto; item: BaseItemDto;
@@ -40,10 +45,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
const scrollToIndex = (index: number) => { const scrollToIndex = (index: number) => {
scrollViewRef.current?.scrollToIndex(index, 100); scrollViewRef.current?.scrollToIndex(index, 100);
}; };
const { offline } = useGlobalSearchParams<{ const isOffline = useOfflineMode();
offline: string; const { settings } = useSettings();
}>(); const insets = useSafeAreaInsets();
const isOffline = offline === "true";
// Set the initial season index // Set the initial season index
useEffect(() => { useEffect(() => {
@@ -56,10 +60,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
}, []); }, []);
const { getDownloadedItems } = useDownload(); const { getDownloadedItems } = useDownload();
const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const seasonIndex = seasonIndexState[item.ParentId ?? ""]; const seasonIndex = seasonIndexState[item.ParentId ?? ""];
@@ -68,15 +68,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
queryFn: async () => { queryFn: async () => {
if (isOffline) { if (isOffline) {
if (!item.SeriesId) return []; if (!item.SeriesId) return [];
const seriesEpisodes = downloadedFiles?.filter( const seasonNumbers = getDownloadedSeasonNumbers(
(f: DownloadedItem) => f.item.SeriesId === item.SeriesId, getDownloadedItems(),
); item.SeriesId,
const seasonNumbers = Array.from(
new Set(
seriesEpisodes
?.map((f: DownloadedItem) => f.item.ParentIndexNumber)
.filter(Boolean),
),
); );
// Create fake season objects // Create fake season objects
return seasonNumbers.map((seasonNumber) => ({ return seasonNumbers.map((seasonNumber) => ({
@@ -117,14 +111,12 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
queryKey: ["episodes", item.SeriesId, selectedSeasonId], queryKey: ["episodes", item.SeriesId, selectedSeasonId],
queryFn: async () => { queryFn: async () => {
if (isOffline) { if (isOffline) {
if (!item.SeriesId) return []; if (!item.SeriesId || typeof seasonIndex !== "number") return [];
return downloadedFiles return getDownloadedEpisodesForSeason(
?.filter( getDownloadedItems(),
(f: DownloadedItem) => item.SeriesId,
f.item.SeriesId === item.SeriesId && seasonIndex,
f.item.ParentIndexNumber === seasonIndex, );
)
.map((f: DownloadedItem) => f.item);
} }
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return []; if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({ const res = await getTvShowsApi(api).getEpisodes({
@@ -153,6 +145,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
useEffect(() => { useEffect(() => {
// Don't prefetch when offline - data is already local
if (isOffline) return;
for (const e of episodes || []) { for (const e of episodes || []) {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["item", e.Id], queryKey: ["item", e.Id],
@@ -168,7 +163,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
staleTime: 60 * 5 * 1000, staleTime: 60 * 5 * 1000,
}); });
} }
}, [episodes]); }, [episodes, isOffline]);
// Scroll to the current item when episodes are fetched // Scroll to the current item when episodes are fetched
useEffect(() => { useEffect(() => {
@@ -181,15 +176,24 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
}, [episodes, item.Id]); }, [episodes, item.Id]);
return ( return (
<SafeAreaView <View
style={{ style={{
position: "absolute", position: "absolute",
backgroundColor: "black", backgroundColor: "black",
height: "100%", height: "100%",
width: "100%", width: "100%",
paddingTop:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
paddingLeft:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
paddingRight:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
}} }}
> >
<View className='flex-row items-center p-4 z-10'> <View
style={{ padding: HEADER_LAYOUT.CONTAINER_PADDING }}
className='flex-row items-center z-10'
>
{seasons && seasons.length > 0 && !episodesLoading && episodes && ( {seasons && seasons.length > 0 && !episodesLoading && episodes && (
<SeasonDropdown <SeasonDropdown
item={item} item={item}
@@ -207,9 +211,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
onPress={async () => { onPress={async () => {
close(); 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'
> >
<Ionicons name='close' size={24} color='white' /> <Ionicons name='close' size={ICON_SIZES.HEADER} color='white' />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -274,6 +278,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
/> />
)} )}
</SafeAreaView> </View>
); );
}; };

View File

@@ -3,15 +3,15 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { type FC, useCallback, useState } from "react"; import { type FC, useCallback, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { OrientationLock } from "@/packages/expo-screen-orientation"; import { OrientationLock } from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ICON_SIZES } from "./constants"; import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView"; import DropdownView from "./dropdown/DropdownView";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector"; import { type AspectRatio } from "./VideoScalingModeSelector";
@@ -98,12 +98,13 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right: right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
padding: HEADER_LAYOUT.CONTAINER_PADDING,
}, },
]} ]}
pointerEvents={showControls ? "auto" : "none"} pointerEvents={showControls ? "auto" : "none"}
className='flex flex-row justify-between' className='flex flex-row justify-between'
> >
<View className='mr-auto p-2' pointerEvents='box-none'> <View className='mr-auto' pointerEvents='box-none'>
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
<View pointerEvents='auto'> <View pointerEvents='auto'>
<DropdownView <DropdownView

View File

@@ -15,3 +15,7 @@ export const ICON_SIZES = {
HEADER: 24, HEADER: 24,
CENTER: 50, CENTER: 50,
} as const; } as const;
export const HEADER_LAYOUT = {
CONTAINER_PADDING: 8, // p-2 = 8px (matches HeaderControls)
} as const;

View File

@@ -18,7 +18,6 @@ interface PlayerContextProps {
mediaSource: MediaSourceInfo | null | undefined; mediaSource: MediaSourceInfo | null | undefined;
isVideoLoaded: boolean; isVideoLoaded: boolean;
tracksReady: boolean; tracksReady: boolean;
offline: boolean;
downloadedItem: DownloadedItem | null; downloadedItem: DownloadedItem | null;
} }
@@ -31,7 +30,6 @@ interface PlayerProviderProps {
mediaSource: MediaSourceInfo | null | undefined; mediaSource: MediaSourceInfo | null | undefined;
isVideoLoaded: boolean; isVideoLoaded: boolean;
tracksReady: boolean; tracksReady: boolean;
offline?: boolean;
downloadedItem?: DownloadedItem | null; downloadedItem?: DownloadedItem | null;
} }
@@ -42,7 +40,6 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
mediaSource, mediaSource,
isVideoLoaded, isVideoLoaded,
tracksReady, tracksReady,
offline = false,
downloadedItem = null, downloadedItem = null,
}) => { }) => {
const value = useMemo( const value = useMemo(
@@ -52,18 +49,9 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
mediaSource, mediaSource,
isVideoLoaded, isVideoLoaded,
tracksReady, tracksReady,
offline,
downloadedItem, downloadedItem,
}), }),
[ [playerRef, item, mediaSource, isVideoLoaded, tracksReady, downloadedItem],
playerRef,
item,
mediaSource,
isVideoLoaded,
tracksReady,
offline,
downloadedItem,
],
); );
return ( return (

View File

@@ -47,7 +47,7 @@
*/ */
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client"; 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 type React from "react";
import { import {
createContext, createContext,
@@ -57,7 +57,9 @@ import {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import useRouter from "@/hooks/useAppRouter";
import type { MpvAudioTrack } from "@/modules"; import type { MpvAudioTrack } from "@/modules";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils"; import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
import type { Track } from "../types"; import type { Track } from "../types";
import { usePlayerContext, usePlayerControls } from "./PlayerContext"; import { usePlayerContext, usePlayerControls } from "./PlayerContext";
@@ -75,9 +77,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null); const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null); const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const { tracksReady, mediaSource, offline, downloadedItem } = const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
usePlayerContext();
const playerControls = usePlayerControls(); const playerControls = usePlayerControls();
const offline = useOfflineMode();
const router = useRouter();
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } = const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
useLocalSearchParams<{ useLocalSearchParams<{

View File

@@ -1,5 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
@@ -8,6 +8,8 @@ import {
PlatformDropdown, PlatformDropdown,
} from "@/components/PlatformDropdown"; } from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector"; import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import useRouter from "@/hooks/useAppRouter";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { usePlayerContext } from "../contexts/PlayerContext"; import { usePlayerContext } from "../contexts/PlayerContext";
import { useVideoContext } from "../contexts/VideoContext"; import { useVideoContext } from "../contexts/VideoContext";
@@ -38,8 +40,9 @@ const DropdownView = ({
const { item, mediaSource } = usePlayerContext(); const { item, mediaSource } = usePlayerContext();
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode();
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } = const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
useLocalSearchParams<{ useLocalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
@@ -47,15 +50,12 @@ const DropdownView = ({
mediaSourceId: string; mediaSourceId: string;
bitrateValue: string; bitrateValue: string;
playbackPosition: string; playbackPosition: string;
offline: string;
}>(); }>();
// Use ref to track playbackPosition without causing re-renders // Use ref to track playbackPosition without causing re-renders
const playbackPositionRef = useRef(playbackPosition); const playbackPositionRef = useRef(playbackPosition);
playbackPositionRef.current = playbackPosition; playbackPositionRef.current = playbackPosition;
const isOffline = offline === "true";
// Stabilize IDs to prevent unnecessary recalculations // Stabilize IDs to prevent unnecessary recalculations
const itemIdRef = useRef(item.Id); const itemIdRef = useRef(item.Id);
const mediaSourceIdRef = useRef(mediaSource?.Id); const mediaSourceIdRef = useRef(mediaSource?.Id);

View File

@@ -6,7 +6,6 @@ import {
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter } from "expo-router";
import React, { import React, {
forwardRef, forwardRef,
useCallback, useCallback,
@@ -23,6 +22,7 @@ import {
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { import {
useAddToWatchlist, useAddToWatchlist,
useRemoveFromWatchlist, useRemoveFromWatchlist,

86
hooks/useAppRouter.ts Normal file
View File

@@ -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<typeof router.push>[0]) => {
if (typeof href === "string") {
router.push(href as any);
} else {
const callerParams = (href.params ?? {}) as Record<string, unknown>;
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<typeof router.replace>[0]) => {
if (typeof href === "string") {
router.replace(href as any);
} else {
const callerParams = (href.params ?? {}) as Record<string, unknown>;
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<typeof router.setParams>[0]) => {
const callerParams = (params ?? {}) as Record<string, unknown>;
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;

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";

View File

@@ -36,12 +36,26 @@ export function useNetworkAwareQueryClient(): NetworkAwareQueryClient {
); );
return useMemo(() => { return useMemo(() => {
// Create a proxy-like object that inherits from queryClient // Use a Proxy to wrap the queryClient and override invalidateQueries.
// but overrides invalidateQueries // Object.create doesn't work because QueryClient uses private fields (#)
const wrapped = Object.create(queryClient) as NetworkAwareQueryClient; // which can only be accessed on the exact instance they were defined on.
wrapped.invalidateQueries = networkAwareInvalidate; const forceInvalidate = queryClient.invalidateQueries.bind(queryClient);
wrapped.forceInvalidateQueries =
queryClient.invalidateQueries.bind(queryClient); return new Proxy(queryClient, {
return wrapped; 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]); }, [queryClient, networkAwareInvalidate]);
} }

View File

@@ -3,7 +3,7 @@ import type {
PlaybackProgressInfo, PlaybackProgressInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; 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 { useAtomValue } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
@@ -69,6 +69,7 @@ export const usePlaybackManager = ({
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const { isConnected } = useNetworkStatus(); const { isConnected } = useNetworkStatus();
const queryClient = useQueryClient();
const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } = const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } =
useDownload(); 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 // 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 // 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 // Handle remote state update if online

View File

@@ -1,7 +1,7 @@
import { useRouter } from "expo-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert } from "react-native"; import { Alert } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useWebSocketContext } from "@/providers/WebSocketProvider"; import { useWebSocketContext } from "@/providers/WebSocketProvider";
interface UseWebSocketProps { interface UseWebSocketProps {

View File

@@ -507,7 +507,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.MPV_EVENT_FILE_LOADED -> { MPVLib.MPV_EVENT_FILE_LOADED -> {
// Add external subtitles now that file is loaded // Add external subtitles now that file is loaded
if (pendingExternalSubtitles.isNotEmpty()) { 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)) MPVLib.command(arrayOf("sub-add", subUrl))
} }
pendingExternalSubtitles = emptyList() pendingExternalSubtitles = emptyList()

View File

@@ -36,6 +36,9 @@ final class MPVLayerRenderer {
private var isRunning = false private var isRunning = false
private var isStopping = false private var isStopping = false
// KVO observation for display layer status
private var statusObservation: NSKeyValueObservation?
weak var delegate: MPVLayerRendererDelegate? weak var delegate: MPVLayerRendererDelegate?
// Thread-safe state for playback // Thread-safe state for playback
@@ -78,6 +81,37 @@ final class MPVLayerRenderer {
init(displayLayer: AVSampleBufferDisplayLayer) { init(displayLayer: AVSampleBufferDisplayLayer) {
self.displayLayer = displayLayer 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 { deinit {
@@ -150,6 +184,10 @@ final class MPVLayerRenderer {
isRunning = false isRunning = false
isStopping = true isStopping = true
// Stop observing display layer status
statusObservation?.invalidate()
statusObservation = nil
queue.sync { [weak self] in queue.sync { [weak self] in
guard let self, let handle = self.mpv else { return } 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 // Add external subtitles now that the file is loaded
let hadExternalSubs = !pendingExternalSubtitles.isEmpty let hadExternalSubs = !pendingExternalSubtitles.isEmpty
if hadExternalSubs, let handle = mpv { if hadExternalSubs, let handle = mpv {
for subUrl in pendingExternalSubtitles { for (index, subUrl) in pendingExternalSubtitles.enumerated() {
command(handle, ["sub-add", subUrl]) print("🔧 Adding external subtitle [\(index)]: \(subUrl)")
// Use commandSync to ensure subs are added in exact order (not async)
commandSync(handle, ["sub-add", subUrl])
} }
pendingExternalSubtitles = [] pendingExternalSubtitles = []
// Set subtitle after external subs are added // Set subtitle after external subs are added
@@ -532,6 +572,8 @@ final class MPVLayerRenderer {
commandSync(handle, ["seek", String(clamped), "absolute"]) commandSync(handle, ["seek", String(clamped), "absolute"])
} }
func seek(by seconds: Double) { func seek(by seconds: Double) {
guard let handle = mpv else { return } guard let handle = mpv else { return }
let newPosition = max(0, cachedPosition + seconds) let newPosition = max(0, cachedPosition + seconds)

View File

@@ -51,12 +51,13 @@ class MpvPlayerView: ExpoView {
private var currentURL: URL? private var currentURL: URL?
private var cachedPosition: Double = 0 private var cachedPosition: Double = 0
private var cachedDuration: 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 private var _isZoomedToFill: Bool = false
required init(appContext: AppContext? = nil) { required init(appContext: AppContext? = nil) {
super.init(appContext: appContext) super.init(appContext: appContext)
setupView() setupView()
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
} }
private func setupView() { private func setupView() {
@@ -361,6 +362,11 @@ extension MpvPlayerView: PiPControllerDelegate {
renderer?.syncTimebase() renderer?.syncTimebase()
// Set current time for PiP progress bar // Set current time for PiP progress bar
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration) 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) { func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) {
@@ -380,6 +386,11 @@ extension MpvPlayerView: PiPControllerDelegate {
// Ensure timebase is synced after PiP ends // Ensure timebase is synced after PiP ends
renderer?.syncTimebase() renderer?.syncTimebase()
pipController?.updatePlaybackState() pipController?.updatePlaybackState()
// Restore the user's zoom preference
if _isZoomedToFill {
displayLayer.videoGravity = .resizeAspectFill
}
} }
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) { func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {

View File

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

View File

@@ -4,7 +4,7 @@ import type { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api"; import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
import { router, useSegments } from "expo-router"; import { useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import type React from "react"; import type React from "react";
@@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next";
import { AppState, Platform } from "react-native"; import { AppState, Platform } from "react-native";
import { getDeviceName } from "react-native-device-info"; import { getDeviceName } from "react-native-device-info";
import uuid from "react-native-uuid"; import uuid from "react-native-uuid";
import useRouter from "@/hooks/useAppRouter";
import { useInterval } from "@/hooks/useInterval"; import { useInterval } from "@/hooks/useInterval";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -581,6 +582,7 @@ export const useJellyfin = (): JellyfinContextValue => {
function useProtectedRoute(user: UserDto | null, loaded = false) { function useProtectedRoute(user: UserDto | null, loaded = false) {
const segments = useSegments(); const segments = useSegments();
const router = useRouter();
useEffect(() => { useEffect(() => {
if (loaded === false) return; if (loaded === false) return;

View File

@@ -0,0 +1,37 @@
import { createContext, type ReactNode, useContext } from "react";
const UNSET = Symbol("OfflineModeNotProvided");
const OfflineModeContext = createContext<boolean | typeof UNSET>(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 (
<OfflineModeContext.Provider value={isOffline}>
{children}
</OfflineModeContext.Provider>
);
}
/**
* 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;
}

View File

@@ -1,5 +1,4 @@
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import {
createContext, createContext,
@@ -12,6 +11,7 @@ import {
useState, useState,
} from "react"; } from "react";
import { AppState, type AppStateStatus } from "react-native"; import { AppState, type AppStateStatus } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider"; import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
import { useNetworkStatus } from "@/providers/NetworkStatusProvider"; import { useNetworkStatus } from "@/providers/NetworkStatusProvider";

View File

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