mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
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:
15
CLAUDE.md
15
CLAUDE.md
@@ -77,6 +77,21 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error
|
||||
- File-based routing in `app/` directory
|
||||
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
|
||||
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
|
||||
- **IMPORTANT**: Always use `useAppRouter` from `@/hooks/useAppRouter` instead of `useRouter` from `expo-router`. This custom hook automatically handles offline mode state preservation across navigation:
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
const router = useRouter();
|
||||
|
||||
// ❌ Never use this
|
||||
import { useRouter } from "expo-router";
|
||||
import { router } from "expo-router";
|
||||
```
|
||||
|
||||
**Offline Mode**:
|
||||
- Use `OfflineModeProvider` from `@/providers/OfflineModeProvider` to wrap pages that support offline content
|
||||
- Use `useOfflineMode()` hook to check if current context is offline
|
||||
- The `useAppRouter` hook automatically injects `offline=true` param when navigating within an offline context
|
||||
|
||||
**Providers** (wrapping order in `app/_layout.tsx`):
|
||||
1. JotaiProvider
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||
|
||||
@@ -57,25 +58,6 @@ export default function IndexLayout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<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
|
||||
name='sessions/index'
|
||||
options={{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -13,8 +13,10 @@ import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { type DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
@@ -161,57 +163,13 @@ export default function page() {
|
||||
);
|
||||
|
||||
return (
|
||||
<OfflineModeProvider isOffline={true}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
{/* Queue card - hidden */}
|
||||
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.queue")}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-70 text-red-600'>
|
||||
{t("home.downloads.queue_hint")}
|
||||
</Text>
|
||||
<View className='flex flex-col space-y-2 mt-2'>
|
||||
{queue.map((q, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||
}
|
||||
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
|
||||
key={index}
|
||||
>
|
||||
<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>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_items_in_queue")}
|
||||
</Text>
|
||||
)}
|
||||
</View> */}
|
||||
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
@@ -228,11 +186,7 @@ export default function page() {
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
key={item.item.Id}
|
||||
>
|
||||
<TouchableItemRouter item={item.item} key={item.item.Id}>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
@@ -255,7 +209,10 @@ export default function page() {
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}>
|
||||
<View
|
||||
className='mb-2 last:mb-0'
|
||||
key={items[0].item.SeriesId}
|
||||
>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
@@ -274,17 +231,15 @@ export default function page() {
|
||||
{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>
|
||||
<Text className='text-xs font-bold'>
|
||||
{otherMedia?.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{otherMedia?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
key={item.item.Id}
|
||||
>
|
||||
<TouchableItemRouter item={item.item} key={item.item.Id}>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
@@ -301,5 +256,6 @@ export default function page() {
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</OfflineModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
@@ -11,6 +11,7 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||
import { UserInfo } from "@/components/settings/UserInfo";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export default function settings() {
|
||||
|
||||
@@ -13,6 +13,7 @@ import Animated, {
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
@@ -75,6 +76,7 @@ const Page: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<OfflineModeProvider isOffline={isOffline}>
|
||||
<View className='flex flex-1 relative'>
|
||||
<Animated.View
|
||||
pointerEvents={"none"}
|
||||
@@ -100,14 +102,9 @@ const Page: React.FC = () => {
|
||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||
</Animated.View>
|
||||
{item && (
|
||||
<ItemContent
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
itemWithSources={itemWithSources}
|
||||
/>
|
||||
)}
|
||||
{item && <ItemContent item={item} itemWithSources={itemWithSources} />}
|
||||
</View>
|
||||
</OfflineModeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -27,6 +27,7 @@ import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { ItemActions } from "@/components/series/SeriesActions";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
|
||||
@@ -14,86 +14,124 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { NextUp } from "@/components/series/NextUp";
|
||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
import {
|
||||
buildOfflineSeriesFromEpisodes,
|
||||
getDownloadedEpisodesForSeries,
|
||||
} from "@/utils/downloads/offline-series";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const params = useLocalSearchParams();
|
||||
const { id: seriesId, seasonIndex } = params as {
|
||||
const {
|
||||
id: seriesId,
|
||||
seasonIndex,
|
||||
offline: offlineParam,
|
||||
} = params as {
|
||||
id: string;
|
||||
seasonIndex: string;
|
||||
offline?: string;
|
||||
};
|
||||
|
||||
const isOffline = offlineParam === "true";
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { getDownloadedItems, downloadedItems } = useDownload();
|
||||
|
||||
// For offline mode, construct series data from downloaded episodes
|
||||
// Include downloadedItems.length so query refetches when items are deleted
|
||||
const { data: item } = useQuery({
|
||||
queryKey: ["series", seriesId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
queryKey: ["series", seriesId, isOffline, downloadedItems.length],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
return buildOfflineSeriesFromEpisodes(getDownloadedItems(), seriesId);
|
||||
}
|
||||
return await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: seriesId,
|
||||
}),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
},
|
||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||
enabled: isOffline || (!!api && !!user?.Id),
|
||||
});
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
// For offline mode, use stored base64 image
|
||||
const base64Image = useMemo(() => {
|
||||
if (isOffline) {
|
||||
return storage.getString(seriesId);
|
||||
}
|
||||
return null;
|
||||
}, [isOffline, seriesId]);
|
||||
|
||||
const backdropUrl = useMemo(() => {
|
||||
if (isOffline && base64Image) {
|
||||
return `data:image/jpeg;base64,${base64Image}`;
|
||||
}
|
||||
return getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item],
|
||||
);
|
||||
});
|
||||
}, [isOffline, base64Image, api, item]);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() =>
|
||||
getLogoImageUrlById({
|
||||
const logoUrl = useMemo(() => {
|
||||
if (isOffline) {
|
||||
return null; // No logo in offline mode
|
||||
}
|
||||
return getLogoImageUrlById({
|
||||
api,
|
||||
item,
|
||||
}),
|
||||
[item],
|
||||
);
|
||||
});
|
||||
}, [isOffline, api, item]);
|
||||
|
||||
const { data: allEpisodes, isLoading } = useQuery({
|
||||
queryKey: ["AllEpisodes", item?.Id],
|
||||
queryKey: ["AllEpisodes", seriesId, isOffline, downloadedItems.length],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item?.Id) return [];
|
||||
if (isOffline) {
|
||||
return getDownloadedEpisodesForSeries(getDownloadedItems(), seriesId);
|
||||
}
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: item.Id,
|
||||
seriesId: seriesId,
|
||||
userId: user.Id,
|
||||
enableUserData: true,
|
||||
// Note: Including trick play is necessary to enable trick play downloads
|
||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||
});
|
||||
return res?.data.Items || [];
|
||||
},
|
||||
select: (data) =>
|
||||
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
|
||||
[...(data || [])].sort(
|
||||
(a, b) =>
|
||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||
),
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||
staleTime: isOffline ? Infinity : 60,
|
||||
enabled: isOffline || (!!api && !!user?.Id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Don't show header buttons in offline mode
|
||||
if (isOffline) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
!isLoading &&
|
||||
item &&
|
||||
allEpisodes &&
|
||||
allEpisodes.length > 0 && (
|
||||
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<AddToFavorites item={item} />
|
||||
{!Platform.isTV && (
|
||||
@@ -114,16 +152,19 @@ const page: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</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 (
|
||||
<OfflineModeProvider isOffline={isOffline}>
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
backdropUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: backdropUrl,
|
||||
@@ -133,6 +174,15 @@ const page: React.FC = () => {
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
logo={
|
||||
logoUrl ? (
|
||||
@@ -151,12 +201,15 @@ const page: React.FC = () => {
|
||||
>
|
||||
<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>
|
||||
</ParallaxScrollView>
|
||||
</OfflineModeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { Image } from "expo-image";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -36,6 +36,7 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -57,6 +58,7 @@ const exampleSearches = [
|
||||
export default function search() {
|
||||
const params = useLocalSearchParams();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -19,6 +19,7 @@ import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import {
|
||||
useDeleteWatchlist,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
||||
|
||||
export default function WatchlistsLayout() {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||
import type {
|
||||
StreamystatsWatchlistAllowedItemType,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
|
||||
import type {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -8,6 +7,7 @@ import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
useStreamystatsEnabled,
|
||||
useWatchlistsQuery,
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
@@ -38,6 +37,7 @@ import { Text } from "@/components/common/Text";
|
||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMusicCast } from "@/hooks/useMusicCast";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
@@ -10,13 +10,12 @@ import {
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
import { router, useGlobalSearchParams, useNavigation } from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, useWindowDimensions, View } from "react-native";
|
||||
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
||||
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
PlaybackSpeedScope,
|
||||
updatePlaybackSpeedSettings,
|
||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
@@ -44,6 +44,9 @@ import {
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
@@ -60,6 +63,7 @@ export default function page() {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
||||
@@ -87,10 +91,9 @@ export default function page() {
|
||||
: require("react-native-volume-manager");
|
||||
|
||||
const downloadUtils = useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => downloadUtils.getDownloadedItems(),
|
||||
[downloadUtils.getDownloadedItems],
|
||||
);
|
||||
// Call directly instead of useMemo - the function reference doesn't change
|
||||
// when data updates, only when the provider initializes
|
||||
const downloadedFiles = downloadUtils.getDownloadedItems();
|
||||
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
@@ -109,7 +112,7 @@ export default function page() {
|
||||
bitrateValue: bitrateValueStr,
|
||||
offline: offlineStr,
|
||||
playbackPosition: playbackPositionFromUrl,
|
||||
} = useGlobalSearchParams<{
|
||||
} = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
@@ -677,8 +680,8 @@ export default function page() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
setIsBuffering(true);
|
||||
if (isLoading !== undefined) {
|
||||
setIsBuffering(isLoading);
|
||||
}
|
||||
},
|
||||
[playbackManager, item?.Id, progress],
|
||||
@@ -833,13 +836,13 @@ export default function page() {
|
||||
);
|
||||
|
||||
return (
|
||||
<OfflineModeProvider isOffline={offline}>
|
||||
<PlayerProvider
|
||||
playerRef={videoRef}
|
||||
item={item}
|
||||
mediaSource={stream?.mediaSource}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
tracksReady={tracksReady}
|
||||
offline={offline}
|
||||
downloadedItem={downloadedItem}
|
||||
>
|
||||
<VideoProvider>
|
||||
@@ -914,7 +917,6 @@ export default function page() {
|
||||
pause={pause}
|
||||
seek={seek}
|
||||
enableTrickplay={true}
|
||||
offline={offline}
|
||||
aspectRatio={aspectRatio}
|
||||
isZoomedToFill={isZoomedToFill}
|
||||
onZoomToggle={handleZoomToggle}
|
||||
@@ -927,5 +929,6 @@ export default function page() {
|
||||
</View>
|
||||
</VideoProvider>
|
||||
</PlayerProvider>
|
||||
</OfflineModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ import type {
|
||||
NotificationResponse,
|
||||
} from "expo-notifications/build/Notifications.types";
|
||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||
import { router, Stack, useSegments } from "expo-router";
|
||||
import { Stack, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||
@@ -57,6 +57,7 @@ import { I18nextProvider } from "react-i18next";
|
||||
import { Appearance } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { store } from "@/utils/store";
|
||||
import "react-native-reanimated";
|
||||
@@ -81,14 +82,9 @@ SplashScreen.setOptions({
|
||||
fade: true,
|
||||
});
|
||||
|
||||
function redirect(notification: typeof Notifications.Notification) {
|
||||
const url = notification.request.content.data?.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
function useNotificationObserver() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
@@ -99,14 +95,17 @@ function useNotificationObserver() {
|
||||
if (!isMounted || !response?.notification) {
|
||||
return;
|
||||
}
|
||||
redirect(response?.notification);
|
||||
const url = response?.notification.request.content.data?.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
}, [router]);
|
||||
}
|
||||
|
||||
if (!Platform.isTV) {
|
||||
@@ -231,6 +230,7 @@ function Layout() {
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const _segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(
|
||||
@@ -323,9 +323,6 @@ function Layout() {
|
||||
responseListener.current =
|
||||
Notifications?.addNotificationResponseReceivedListener(
|
||||
(response: NotificationResponse) => {
|
||||
// redirect if internal notification
|
||||
redirect(response?.notification);
|
||||
|
||||
// Currently the notifications supported by the plugin will send data for deep links.
|
||||
const { title, data } = response.notification.request.content;
|
||||
writeInfoLog(`Notification ${title} opened`, data);
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "streamyfin",
|
||||
|
||||
@@ -9,13 +9,14 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { type Href, router } from "expo-router";
|
||||
import { type Href } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -62,6 +63,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
const [user] = useAtom(userAtom);
|
||||
const [queue, _setQueue] = useAtom(queueAtom);
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||
|
||||
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
|
||||
@@ -170,9 +172,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
firstItem.Type !== "Episode"
|
||||
? "/downloads"
|
||||
: ({
|
||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||
pathname: "/series/[id]",
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||
id: firstItem.SeriesId!,
|
||||
seasonIndex: firstItem.ParentIndexNumber?.toString(),
|
||||
offline: "true",
|
||||
},
|
||||
} as Href),
|
||||
);
|
||||
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
export interface IntroSheetRef {
|
||||
@@ -24,6 +24,7 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
present: () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
@@ -45,13 +46,13 @@ export type SelectedOptions = {
|
||||
|
||||
interface ItemContentProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
itemWithSources?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
({ item, isOffline, itemWithSources }) => {
|
||||
({ item, itemWithSources }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const { settings } = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
@@ -228,7 +229,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
<PlayButton
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
colors={itemColors}
|
||||
/>
|
||||
<View className='w-1' />
|
||||
@@ -243,11 +243,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
</View>
|
||||
</View>
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel
|
||||
item={item}
|
||||
loading={loading}
|
||||
isOffline={isOffline}
|
||||
/>
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
{!isOffline &&
|
||||
@@ -264,7 +260,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
<CurrentSeries item={item} className='mb-2' />
|
||||
)}
|
||||
|
||||
<ItemPeopleSections item={item} isOffline={isOffline} />
|
||||
<ItemPeopleSections item={item} />
|
||||
|
||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -24,11 +23,13 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
@@ -44,7 +45,6 @@ import type { SelectedOptions } from "./ItemContent";
|
||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
isOffline?: boolean;
|
||||
colors?: ThemeColors;
|
||||
}
|
||||
|
||||
@@ -54,9 +54,9 @@ const MIN_PLAYBACK_WIDTH = 15;
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
isOffline,
|
||||
colors,
|
||||
}: Props) => {
|
||||
const isOffline = useOfflineMode();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const mediaStatus = useMediaStatus();
|
||||
@@ -300,6 +300,19 @@ export const PlayButton: React.FC<Props> = ({
|
||||
// Check if item is downloaded
|
||||
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||
|
||||
// If already in offline mode, play downloaded file directly
|
||||
if (isOffline && downloadedItem) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
// If online but file is downloaded, ask user which version to play
|
||||
if (downloadedItem) {
|
||||
if (Platform.OS === "android") {
|
||||
// Show bottom sheet for Android
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
@@ -14,6 +13,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
|
||||
@@ -7,7 +7,6 @@ import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
isOffline?: boolean;
|
||||
size?: "default" | "large";
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
@@ -26,6 +25,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
interface Props extends BlurViewProps {
|
||||
background?: "blur" | "transparent";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { type PropsWithChildren } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import { type PropsWithChildren, useCallback } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||
@@ -134,26 +136,20 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
||||
|
||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
item,
|
||||
isOffline = false,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
const { deleteFile } = useDownload();
|
||||
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
// For offline mode, we still need to use query params
|
||||
if (isOffline) {
|
||||
const url = `${itemRouter(item, from)}&offline=true`;
|
||||
router.push(url as any);
|
||||
return;
|
||||
}
|
||||
|
||||
// Force music libraries to navigate via the explicit string route.
|
||||
// This avoids losing the dynamic [libraryId] param when going through a nested navigator.
|
||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||
@@ -163,7 +159,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
const navigation = getItemNavigation(item, from);
|
||||
router.push(navigation as any);
|
||||
}, [from, isOffline, item, router]);
|
||||
}, [from, item, router]);
|
||||
|
||||
const showActionSheet = useCallback(() => {
|
||||
if (
|
||||
@@ -179,14 +175,19 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
"Mark as Played",
|
||||
"Mark as Not Played",
|
||||
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||
...(isOffline ? ["Delete Download"] : []),
|
||||
"Cancel",
|
||||
];
|
||||
const cancelButtonIndex = options.length - 1;
|
||||
const destructiveButtonIndex = isOffline
|
||||
? cancelButtonIndex - 1
|
||||
: undefined;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
},
|
||||
async (selectedIndex) => {
|
||||
if (selectedIndex === 0) {
|
||||
@@ -195,6 +196,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
await markAsPlayedStatus(false);
|
||||
} else if (selectedIndex === 2) {
|
||||
toggleFavorite();
|
||||
} else if (isOffline && selectedIndex === 3 && item.Id) {
|
||||
deleteFile(item.Id);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -203,6 +206,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
isFavorite,
|
||||
markAsPlayedStatus,
|
||||
toggleFavorite,
|
||||
isOffline,
|
||||
deleteFile,
|
||||
item.Id,
|
||||
]);
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
|
||||
|
||||
@@ -61,7 +61,6 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
return (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
isOffline={true}
|
||||
onLongPress={showActionSheet}
|
||||
className='flex flex-col mb-4'
|
||||
>
|
||||
|
||||
@@ -67,7 +67,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline>
|
||||
<TouchableItemRouter onLongPress={showActionSheet} item={item}>
|
||||
{base64Image ? (
|
||||
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
|
||||
<Image
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -14,6 +14,7 @@ import { Text } from "../common/Text";
|
||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const { deleteItems } = useDownload();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const router = useRouter();
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
@@ -46,7 +47,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/series/[id]",
|
||||
params: { id: items[0].SeriesId!, offline: "true" },
|
||||
})
|
||||
}
|
||||
onLongPress={showActionSheet}
|
||||
>
|
||||
{base64Image ? (
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -10,6 +9,7 @@ import { Text, View } from "react-native";
|
||||
// PNG ASSET
|
||||
import heart from "@/assets/icons/heart.fill.png";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList";
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||
import { useNavigation, useSegments } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -33,6 +33,7 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||
import { useNavigation, useSegments } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -35,6 +35,7 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Dimensions, View, type ViewProps } from "react-native";
|
||||
@@ -16,6 +16,7 @@ import Carousel, {
|
||||
type ICarouselInstance,
|
||||
Pagination,
|
||||
} from "react-native-reanimated-carousel";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
@@ -21,7 +21,6 @@ interface Props extends ViewProps {
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
hideIfEmpty?: boolean;
|
||||
isOffline?: boolean;
|
||||
scrollY?: number; // For lazy loading
|
||||
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
||||
}
|
||||
@@ -33,7 +32,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
queryFn,
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
isOffline = false,
|
||||
scrollY = 0,
|
||||
enableLazyLoading = false,
|
||||
...props
|
||||
@@ -106,7 +104,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
isOffline={isOffline}
|
||||
className={`mr-2
|
||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||
`}
|
||||
|
||||
@@ -8,17 +8,14 @@ import { InteractionManager, View, type ViewProps } from "react-native";
|
||||
import { MoreMoviesWithActor } from "@/components/MoreMoviesWithActor";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { useItemPeopleQuery } from "@/hooks/useItemPeopleQuery";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
}
|
||||
|
||||
export const ItemPeopleSections: React.FC<Props> = ({
|
||||
item,
|
||||
isOffline,
|
||||
...props
|
||||
}) => {
|
||||
export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
||||
const isOffline = useOfflineMode();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
COMPANY_LOGO_IMAGE_FILTER,
|
||||
@@ -16,6 +17,7 @@ const CompanySlide: React.FC<
|
||||
> = ({ slide, data, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const navigate = useCallback(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||
@@ -13,6 +14,7 @@ import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/consta
|
||||
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const navigate = useCallback(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import {
|
||||
@@ -23,6 +22,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { getLocalPath } from "@/providers/AudioStorage";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useDeletePlaylist } from "@/hooks/usePlaylistMutations";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import {
|
||||
audioStorageEvents,
|
||||
|
||||
@@ -2,13 +2,14 @@ import type {
|
||||
BaseItemDto,
|
||||
BaseItemPerson,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||
@@ -24,6 +25,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const segments = useSegments();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2];
|
||||
|
||||
const destinctPeople = useMemo(() => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||
@@ -18,6 +18,7 @@ interface Props extends ViewProps {
|
||||
export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter } from "expo-router";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Button } from "../Button";
|
||||
|
||||
|
||||
@@ -54,29 +54,28 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
[state, item, keys],
|
||||
);
|
||||
|
||||
// Always use IndexNumber for Season objects (not keys.index which is for the item)
|
||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
|
||||
Number(a[keys.index]) - Number(b[keys.index]);
|
||||
Number(a.IndexNumber) - Number(b.IndexNumber);
|
||||
|
||||
const optionGroups = useMemo(
|
||||
() => [
|
||||
{
|
||||
options:
|
||||
seasons?.sort(sortByIndex).map((season: any) => {
|
||||
const title =
|
||||
season[keys.title] ||
|
||||
season.Name ||
|
||||
`Season ${season.IndexNumber}`;
|
||||
const title = season.Name || `Season ${season.IndexNumber}`;
|
||||
return {
|
||||
type: "radio" as const,
|
||||
label: title,
|
||||
value: season.Id || season.IndexNumber,
|
||||
selected: Number(season[keys.index]) === Number(seasonIndex),
|
||||
// Compare season's IndexNumber with the selected seasonIndex
|
||||
selected: Number(season.IndexNumber) === Number(seasonIndex),
|
||||
onPress: () => onSelect(season),
|
||||
};
|
||||
}) || [],
|
||||
},
|
||||
],
|
||||
[seasons, keys, seasonIndex, onSelect],
|
||||
[seasons, seasonIndex, onSelect],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, type ViewStyle } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { getDownloadedEpisodesBySeasonId } from "@/utils/downloads/offline-series";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
@@ -17,7 +19,6 @@ import { ItemCardText } from "../ItemCardText";
|
||||
interface Props {
|
||||
item?: BaseItemDto | null;
|
||||
loading?: boolean;
|
||||
isOffline?: boolean;
|
||||
style?: ViewStyle;
|
||||
containerStyle?: ViewStyle;
|
||||
}
|
||||
@@ -25,17 +26,14 @@ interface Props {
|
||||
export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
item,
|
||||
loading,
|
||||
isOffline,
|
||||
style,
|
||||
containerStyle,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const router = useRouter();
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
|
||||
@@ -51,11 +49,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
queryKey: ["episodes", seasonId, isOffline],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
return downloadedFiles
|
||||
?.filter(
|
||||
(f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
|
||||
)
|
||||
.map((f) => f.item);
|
||||
return getDownloadedEpisodesBySeasonId(getDownloadedItems(), seasonId!);
|
||||
}
|
||||
if (!api || !user?.Id || !item?.SeriesId) return [];
|
||||
const response = await getTvShowsApi(api).getEpisodes({
|
||||
@@ -73,7 +67,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
});
|
||||
return response.data.Items as BaseItemDto[];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!seasonId,
|
||||
enabled: !!seasonId && (isOffline || (!!api && !!user?.Id)),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -10,7 +10,13 @@ import {
|
||||
SeasonDropdown,
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import {
|
||||
buildOfflineSeasons,
|
||||
getDownloadedEpisodesForSeason,
|
||||
} from "@/utils/downloads/offline-series";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -31,6 +37,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||
const { t } = useTranslation();
|
||||
const isOffline = useOfflineMode();
|
||||
const { getDownloadedItems, downloadedItems } = useDownload();
|
||||
|
||||
const seasonIndex = useMemo(
|
||||
() => seasonIndexState[item.Id ?? ""],
|
||||
@@ -38,8 +46,12 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
);
|
||||
|
||||
const { data: seasons } = useQuery({
|
||||
queryKey: ["seasons", item.Id],
|
||||
queryKey: ["seasons", item.Id, isOffline, downloadedItems.length],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
return buildOfflineSeasons(getDownloadedItems(), item.Id!);
|
||||
}
|
||||
|
||||
if (!api || !user?.Id || !item.Id) return [];
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/Shows/${item.Id}/Seasons`,
|
||||
@@ -58,8 +70,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
|
||||
return response.data.Items;
|
||||
},
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item.Id,
|
||||
staleTime: isOffline ? Infinity : 60,
|
||||
enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
|
||||
});
|
||||
|
||||
const selectedSeasonId: string | null = useMemo(() => {
|
||||
@@ -73,9 +85,33 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
return season.Id!;
|
||||
}, [seasons, seasonIndex]);
|
||||
|
||||
// For offline mode, we use season index number instead of ID
|
||||
const selectedSeasonNumber = useMemo(() => {
|
||||
if (!isOffline) return null;
|
||||
const season = seasons?.find(
|
||||
(s: BaseItemDto) =>
|
||||
s.IndexNumber === seasonIndex || s.Name === seasonIndex,
|
||||
);
|
||||
return season?.IndexNumber ?? null;
|
||||
}, [isOffline, seasons, seasonIndex]);
|
||||
|
||||
const { data: episodes, isPending } = useQuery({
|
||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||
queryKey: [
|
||||
"episodes",
|
||||
item.Id,
|
||||
isOffline ? selectedSeasonNumber : selectedSeasonId,
|
||||
isOffline,
|
||||
downloadedItems.length,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
return getDownloadedEpisodesForSeason(
|
||||
getDownloadedItems(),
|
||||
item.Id!,
|
||||
selectedSeasonNumber!,
|
||||
);
|
||||
}
|
||||
|
||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
|
||||
return [];
|
||||
}
|
||||
@@ -85,7 +121,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
userId: user.Id,
|
||||
seasonId: selectedSeasonId,
|
||||
enableUserData: true,
|
||||
// Note: Including trick play is necessary to enable trick play downloads
|
||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||
});
|
||||
|
||||
@@ -97,7 +132,10 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
|
||||
return res.data.Items;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
staleTime: isOffline ? Infinity : 0,
|
||||
enabled: isOffline
|
||||
? !!item.Id && selectedSeasonNumber !== null
|
||||
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
});
|
||||
|
||||
// Used for height calculation
|
||||
@@ -127,7 +165,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{episodes?.length ? (
|
||||
{episodes?.length && !isOffline ? (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<DownloadItems
|
||||
title={t("item_card.download.download_season")}
|
||||
@@ -180,9 +218,11 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
{!isOffline && (
|
||||
<View className='self-start ml-auto -mt-0.5'>
|
||||
<DownloadSingleItem item={e} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Switch } from "react-native";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
@@ -8,6 +7,7 @@ import { Linking, Switch, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export interface ContinueWatchingOverlayProps {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} 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 { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import Animated, {
|
||||
@@ -15,12 +15,14 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
@@ -49,7 +51,6 @@ interface Props {
|
||||
enableTrickplay?: boolean;
|
||||
togglePlay: () => void;
|
||||
setShowControls: (shown: boolean) => void;
|
||||
offline?: boolean;
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
seek: (ticks: number) => void;
|
||||
startPictureInPicture?: () => Promise<void>;
|
||||
@@ -83,12 +84,12 @@ export const Controls: FC<Props> = ({
|
||||
aspectRatio = "default",
|
||||
isZoomedToFill = false,
|
||||
onZoomToggle,
|
||||
offline = false,
|
||||
api = null,
|
||||
downloadedFiles = undefined,
|
||||
playbackSpeed = 1.0,
|
||||
setPlaybackSpeed,
|
||||
}) => {
|
||||
const offline = useOfflineMode();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
@@ -110,7 +111,9 @@ export const Controls: FC<Props> = ({
|
||||
} = useTrickplay(item);
|
||||
|
||||
const min = useSharedValue(0);
|
||||
const max = useSharedValue(ticksToMs(item.RunTimeTicks || 0));
|
||||
// Regular value for use during render (avoids Reanimated warning)
|
||||
const maxMs = ticksToMs(item.RunTimeTicks || 0);
|
||||
const max = useSharedValue(maxMs);
|
||||
|
||||
// Animation values for controls
|
||||
const controlsOpacity = useSharedValue(showControls ? 1 : 0);
|
||||
@@ -303,7 +306,7 @@ export const Controls: FC<Props> = ({
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
max.value,
|
||||
maxMs,
|
||||
);
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
|
||||
@@ -2,11 +2,10 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useGlobalSearchParams } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
@@ -19,10 +18,16 @@ import {
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import type { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
getDownloadedEpisodesForSeason,
|
||||
getDownloadedSeasonNumbers,
|
||||
} from "@/utils/downloads/offline-series";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
@@ -40,10 +45,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
const scrollToIndex = (index: number) => {
|
||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||
};
|
||||
const { offline } = useGlobalSearchParams<{
|
||||
offline: string;
|
||||
}>();
|
||||
const isOffline = offline === "true";
|
||||
const isOffline = useOfflineMode();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Set the initial season index
|
||||
useEffect(() => {
|
||||
@@ -56,10 +60,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}, []);
|
||||
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
|
||||
@@ -68,15 +68,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
if (!item.SeriesId) return [];
|
||||
const seriesEpisodes = downloadedFiles?.filter(
|
||||
(f: DownloadedItem) => f.item.SeriesId === item.SeriesId,
|
||||
);
|
||||
const seasonNumbers = Array.from(
|
||||
new Set(
|
||||
seriesEpisodes
|
||||
?.map((f: DownloadedItem) => f.item.ParentIndexNumber)
|
||||
.filter(Boolean),
|
||||
),
|
||||
const seasonNumbers = getDownloadedSeasonNumbers(
|
||||
getDownloadedItems(),
|
||||
item.SeriesId,
|
||||
);
|
||||
// Create fake season objects
|
||||
return seasonNumbers.map((seasonNumber) => ({
|
||||
@@ -117,14 +111,12 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
if (!item.SeriesId) return [];
|
||||
return downloadedFiles
|
||||
?.filter(
|
||||
(f: DownloadedItem) =>
|
||||
f.item.SeriesId === item.SeriesId &&
|
||||
f.item.ParentIndexNumber === seasonIndex,
|
||||
)
|
||||
.map((f: DownloadedItem) => f.item);
|
||||
if (!item.SeriesId || typeof seasonIndex !== "number") return [];
|
||||
return getDownloadedEpisodesForSeason(
|
||||
getDownloadedItems(),
|
||||
item.SeriesId,
|
||||
seasonIndex,
|
||||
);
|
||||
}
|
||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
@@ -153,6 +145,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
// Don't prefetch when offline - data is already local
|
||||
if (isOffline) return;
|
||||
|
||||
for (const e of episodes || []) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["item", e.Id],
|
||||
@@ -168,7 +163,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
staleTime: 60 * 5 * 1000,
|
||||
});
|
||||
}
|
||||
}, [episodes]);
|
||||
}, [episodes, isOffline]);
|
||||
|
||||
// Scroll to the current item when episodes are fetched
|
||||
useEffect(() => {
|
||||
@@ -181,15 +176,24 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}, [episodes, item.Id]);
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
backgroundColor: "black",
|
||||
height: "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 && (
|
||||
<SeasonDropdown
|
||||
item={item}
|
||||
@@ -207,9 +211,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
onPress={async () => {
|
||||
close();
|
||||
}}
|
||||
className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2 ml-auto'
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2 ml-auto'
|
||||
>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
<Ionicons name='close' size={ICON_SIZES.HEADER} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -274,6 +278,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,15 +3,15 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ICON_SIZES } from "./constants";
|
||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
@@ -98,12 +98,13 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
right:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
||||
},
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className='flex flex-row justify-between'
|
||||
>
|
||||
<View className='mr-auto p-2' pointerEvents='box-none'>
|
||||
<View className='mr-auto' pointerEvents='box-none'>
|
||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||
<View pointerEvents='auto'>
|
||||
<DropdownView
|
||||
|
||||
@@ -15,3 +15,7 @@ export const ICON_SIZES = {
|
||||
HEADER: 24,
|
||||
CENTER: 50,
|
||||
} as const;
|
||||
|
||||
export const HEADER_LAYOUT = {
|
||||
CONTAINER_PADDING: 8, // p-2 = 8px (matches HeaderControls)
|
||||
} as const;
|
||||
|
||||
@@ -18,7 +18,6 @@ interface PlayerContextProps {
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean;
|
||||
tracksReady: boolean;
|
||||
offline: boolean;
|
||||
downloadedItem: DownloadedItem | null;
|
||||
}
|
||||
|
||||
@@ -31,7 +30,6 @@ interface PlayerProviderProps {
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean;
|
||||
tracksReady: boolean;
|
||||
offline?: boolean;
|
||||
downloadedItem?: DownloadedItem | null;
|
||||
}
|
||||
|
||||
@@ -42,7 +40,6 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
tracksReady,
|
||||
offline = false,
|
||||
downloadedItem = null,
|
||||
}) => {
|
||||
const value = useMemo(
|
||||
@@ -52,18 +49,9 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
tracksReady,
|
||||
offline,
|
||||
downloadedItem,
|
||||
}),
|
||||
[
|
||||
playerRef,
|
||||
item,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
tracksReady,
|
||||
offline,
|
||||
downloadedItem,
|
||||
],
|
||||
[playerRef, item, mediaSource, isVideoLoaded, tracksReady, downloadedItem],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
*/
|
||||
|
||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import {
|
||||
createContext,
|
||||
@@ -57,7 +57,9 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import type { MpvAudioTrack } from "@/modules";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
||||
import type { Track } from "../types";
|
||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||
@@ -75,9 +77,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
|
||||
const { tracksReady, mediaSource, offline, downloadedItem } =
|
||||
usePlayerContext();
|
||||
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
|
||||
const playerControls = usePlayerControls();
|
||||
const offline = useOfflineMode();
|
||||
const router = useRouter();
|
||||
|
||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { usePlayerContext } from "../contexts/PlayerContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
@@ -38,8 +40,9 @@ const DropdownView = ({
|
||||
const { item, mediaSource } = usePlayerContext();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
@@ -47,15 +50,12 @@ const DropdownView = ({
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
playbackPosition: string;
|
||||
offline: string;
|
||||
}>();
|
||||
|
||||
// Use ref to track playbackPosition without causing re-renders
|
||||
const playbackPositionRef = useRef(playbackPosition);
|
||||
playbackPositionRef.current = playbackPosition;
|
||||
|
||||
const isOffline = offline === "true";
|
||||
|
||||
// Stabilize IDs to prevent unnecessary recalculations
|
||||
const itemIdRef = useRef(item.Id);
|
||||
const mediaSourceIdRef = useRef(mediaSource?.Id);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
useAddToWatchlist,
|
||||
useRemoveFromWatchlist,
|
||||
|
||||
86
hooks/useAppRouter.ts
Normal file
86
hooks/useAppRouter.ts
Normal 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;
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
|
||||
@@ -36,12 +36,26 @@ export function useNetworkAwareQueryClient(): NetworkAwareQueryClient {
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
// Create a proxy-like object that inherits from queryClient
|
||||
// but overrides invalidateQueries
|
||||
const wrapped = Object.create(queryClient) as NetworkAwareQueryClient;
|
||||
wrapped.invalidateQueries = networkAwareInvalidate;
|
||||
wrapped.forceInvalidateQueries =
|
||||
queryClient.invalidateQueries.bind(queryClient);
|
||||
return wrapped;
|
||||
// Use a Proxy to wrap the queryClient and override invalidateQueries.
|
||||
// Object.create doesn't work because QueryClient uses private fields (#)
|
||||
// which can only be accessed on the exact instance they were defined on.
|
||||
const forceInvalidate = queryClient.invalidateQueries.bind(queryClient);
|
||||
|
||||
return new Proxy(queryClient, {
|
||||
get(target, prop) {
|
||||
if (prop === "invalidateQueries") {
|
||||
return networkAwareInvalidate;
|
||||
}
|
||||
if (prop === "forceInvalidateQueries") {
|
||||
return forceInvalidate;
|
||||
}
|
||||
const value = Reflect.get(target, prop, target);
|
||||
// Bind methods to the original target to preserve private field access
|
||||
if (typeof value === "function") {
|
||||
return value.bind(target);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
}) as NetworkAwareQueryClient;
|
||||
}, [queryClient, networkAwareInvalidate]);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
PlaybackProgressInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
@@ -69,6 +69,7 @@ export const usePlaybackManager = ({
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { isConnected } = useNetworkStatus();
|
||||
const queryClient = useQueryClient();
|
||||
const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } =
|
||||
useDownload();
|
||||
|
||||
@@ -186,6 +187,9 @@ export const usePlaybackManager = ({
|
||||
},
|
||||
},
|
||||
});
|
||||
// Force invalidate queries so they refetch from updated local database
|
||||
queryClient.invalidateQueries({ queryKey: ["item", itemId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["episodes"] });
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
@@ -226,6 +230,9 @@ export const usePlaybackManager = ({
|
||||
},
|
||||
},
|
||||
});
|
||||
// Force invalidate queries so they refetch from updated local database
|
||||
queryClient.invalidateQueries({ queryKey: ["item", itemId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["episodes"] });
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
@@ -268,6 +275,9 @@ export const usePlaybackManager = ({
|
||||
},
|
||||
},
|
||||
});
|
||||
// Force invalidate queries so they refetch from updated local database
|
||||
queryClient.invalidateQueries({ queryKey: ["item", itemId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["episodes"] });
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||
|
||||
interface UseWebSocketProps {
|
||||
|
||||
@@ -507,7 +507,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
MPVLib.MPV_EVENT_FILE_LOADED -> {
|
||||
// Add external subtitles now that file is loaded
|
||||
if (pendingExternalSubtitles.isNotEmpty()) {
|
||||
for (subUrl in pendingExternalSubtitles) {
|
||||
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
|
||||
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
|
||||
MPVLib.command(arrayOf("sub-add", subUrl))
|
||||
}
|
||||
pendingExternalSubtitles = emptyList()
|
||||
|
||||
@@ -36,6 +36,9 @@ final class MPVLayerRenderer {
|
||||
private var isRunning = false
|
||||
private var isStopping = false
|
||||
|
||||
// KVO observation for display layer status
|
||||
private var statusObservation: NSKeyValueObservation?
|
||||
|
||||
weak var delegate: MPVLayerRendererDelegate?
|
||||
|
||||
// Thread-safe state for playback
|
||||
@@ -78,6 +81,37 @@ final class MPVLayerRenderer {
|
||||
|
||||
init(displayLayer: AVSampleBufferDisplayLayer) {
|
||||
self.displayLayer = displayLayer
|
||||
observeDisplayLayerStatus()
|
||||
}
|
||||
|
||||
|
||||
/// Watches for display layer failures and auto-recovers.
|
||||
///
|
||||
/// iOS aggressively kills VideoToolbox decoder sessions when the app is
|
||||
/// backgrounded, the screen is locked, or system resources are low.
|
||||
/// This causes the video to go black - especially problematic for PiP.
|
||||
///
|
||||
/// This KVO observer detects when the display layer status becomes `.failed`
|
||||
/// and automatically reinitializes the hardware decoder to restore video.
|
||||
private func observeDisplayLayerStatus() {
|
||||
statusObservation = displayLayer.observe(\.status, options: [.new]) { [weak self] layer, _ in
|
||||
guard let self else { return }
|
||||
|
||||
if layer.status == .failed {
|
||||
print("🔧 Display layer failed - auto-resetting decoder")
|
||||
self.queue.async {
|
||||
self.performDecoderReset()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Actually performs the decoder reset (called by observer or manually)
|
||||
private func performDecoderReset() {
|
||||
guard let handle = mpv else { return }
|
||||
print("🔧 Resetting decoder: status=\(displayLayer.status.rawValue), requiresFlush=\(displayLayer.requiresFlushToResumeDecoding)")
|
||||
commandSync(handle, ["set", "hwdec", "no"])
|
||||
commandSync(handle, ["set", "hwdec", "auto"])
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -150,6 +184,10 @@ final class MPVLayerRenderer {
|
||||
isRunning = false
|
||||
isStopping = true
|
||||
|
||||
// Stop observing display layer status
|
||||
statusObservation?.invalidate()
|
||||
statusObservation = nil
|
||||
|
||||
queue.sync { [weak self] in
|
||||
guard let self, let handle = self.mpv else { return }
|
||||
|
||||
@@ -339,8 +377,10 @@ final class MPVLayerRenderer {
|
||||
// Add external subtitles now that the file is loaded
|
||||
let hadExternalSubs = !pendingExternalSubtitles.isEmpty
|
||||
if hadExternalSubs, let handle = mpv {
|
||||
for subUrl in pendingExternalSubtitles {
|
||||
command(handle, ["sub-add", subUrl])
|
||||
for (index, subUrl) in pendingExternalSubtitles.enumerated() {
|
||||
print("🔧 Adding external subtitle [\(index)]: \(subUrl)")
|
||||
// Use commandSync to ensure subs are added in exact order (not async)
|
||||
commandSync(handle, ["sub-add", subUrl])
|
||||
}
|
||||
pendingExternalSubtitles = []
|
||||
// Set subtitle after external subs are added
|
||||
@@ -532,6 +572,8 @@ final class MPVLayerRenderer {
|
||||
commandSync(handle, ["seek", String(clamped), "absolute"])
|
||||
}
|
||||
|
||||
|
||||
|
||||
func seek(by seconds: Double) {
|
||||
guard let handle = mpv else { return }
|
||||
let newPosition = max(0, cachedPosition + seconds)
|
||||
|
||||
@@ -51,12 +51,13 @@ class MpvPlayerView: ExpoView {
|
||||
private var currentURL: URL?
|
||||
private var cachedPosition: Double = 0
|
||||
private var cachedDuration: Double = 0
|
||||
private var intendedPlayState: Bool = false // For PiP - ignores transient states during seek
|
||||
private var intendedPlayState: Bool = false
|
||||
private var _isZoomedToFill: Bool = false
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupView()
|
||||
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
|
||||
}
|
||||
|
||||
private func setupView() {
|
||||
@@ -361,6 +362,11 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
renderer?.syncTimebase()
|
||||
// Set current time for PiP progress bar
|
||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||
|
||||
// Reset to fit for PiP (zoomed video doesn't display correctly in PiP)
|
||||
if _isZoomedToFill {
|
||||
displayLayer.videoGravity = .resizeAspect
|
||||
}
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) {
|
||||
@@ -380,6 +386,11 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
// Ensure timebase is synced after PiP ends
|
||||
renderer?.syncTimebase()
|
||||
pipController?.updatePlaybackState()
|
||||
|
||||
// Restore the user's zoom preference
|
||||
if _isZoomedToFill {
|
||||
displayLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
@@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import { getDeviceName } from "react-native-device-info";
|
||||
import uuid from "react-native-uuid";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -581,6 +582,7 @@ export const useJellyfin = (): JellyfinContextValue => {
|
||||
|
||||
function useProtectedRoute(user: UserDto | null, loaded = false) {
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded === false) return;
|
||||
|
||||
37
providers/OfflineModeProvider.tsx
Normal file
37
providers/OfflineModeProvider.tsx
Normal 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;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
createContext,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||
|
||||
|
||||
114
utils/downloads/offline-series.ts
Normal file
114
utils/downloads/offline-series.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user