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