mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Merge branch 'pr/231'
This commit is contained in:
@@ -1,14 +1,11 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -35,11 +32,17 @@ export default function IndexLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="downloads"
|
name="downloads/index"
|
||||||
options={{
|
options={{
|
||||||
title: "Downloads",
|
title: "Downloads",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="downloads/[seriesId]"
|
||||||
|
options={{
|
||||||
|
title: "TV-Series",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
94
app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx
Normal file
94
app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
import {useDownload} from "@/providers/DownloadProvider";
|
||||||
|
import {router, useLocalSearchParams, useNavigation} from "expo-router";
|
||||||
|
import React, {useEffect, useMemo, useState} from "react";
|
||||||
|
import {ScrollView, View} from "react-native";
|
||||||
|
import {EpisodeCard} from "@/components/downloads/EpisodeCard";
|
||||||
|
import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {SeasonDropdown, SeasonIndexState} from "@/components/series/SeasonDropdown";
|
||||||
|
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 {downloadedFiles} = useDownload();
|
||||||
|
|
||||||
|
const series = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return downloadedFiles
|
||||||
|
?.filter((f) => f.item.SeriesId == seriesId)
|
||||||
|
?.sort((a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!)
|
||||||
|
|| [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
const seasonIndex = seasonIndexState[series?.[0]?.item?.ParentId ?? ""] || episodeSeasonIndex || "";
|
||||||
|
|
||||||
|
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||||
|
const seasons: Record<string, BaseItemDto[]> = {};
|
||||||
|
|
||||||
|
series?.forEach((episode) => {
|
||||||
|
if (!seasons[episode.item.ParentIndexNumber!]) {
|
||||||
|
seasons[episode.item.ParentIndexNumber!] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
seasons[episode.item.ParentIndexNumber!].push(episode.item);
|
||||||
|
});
|
||||||
|
return seasons[seasonIndex]
|
||||||
|
?.sort((a, b) => a.IndexNumber! - b.IndexNumber!)
|
||||||
|
?? []
|
||||||
|
}, [series, seasonIndex]);
|
||||||
|
|
||||||
|
const initialSeasonIndex = useMemo(() =>
|
||||||
|
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ?? series?.[0]?.item?.ParentIndexNumber,
|
||||||
|
[groupBySeason]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (series.length > 0) {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: series[0].item.SeriesName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
storage.delete(seriesId);
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{series.length > 0 && <View className="my-4 flex flex-row items-center justify-start">
|
||||||
|
<SeasonDropdown
|
||||||
|
item={series[0].item}
|
||||||
|
seasons={series.map(s => s.item)}
|
||||||
|
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">
|
||||||
|
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
|
||||||
|
</View>
|
||||||
|
</View>}
|
||||||
|
<ScrollView key={seasonIndex}>
|
||||||
|
{groupBySeason.map((episode) => (
|
||||||
|
<View className="px-4 flex flex-col my-4">
|
||||||
|
<EpisodeCard item={episode}/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -117,12 +117,28 @@ export default function page() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{groupedBySeries?.map((items, index) => (
|
{groupedBySeries.length > 0 && (
|
||||||
<SeriesCard
|
<View className="mb-4">
|
||||||
items={items.map((i) => i.item)}
|
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||||
key={items[0].item.SeriesId}
|
<Text className="text-lg font-bold">TV-Series</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>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className="px-4 flex flex-row">
|
||||||
|
{groupedBySeries?.map((items) => (
|
||||||
|
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
|
||||||
|
<SeriesCard
|
||||||
|
items={items.map((i) => i.item)}
|
||||||
|
key={items[0].item.SeriesId}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{downloadedFiles?.length === 0 && (
|
{downloadedFiles?.length === 0 && (
|
||||||
<View className="flex px-4">
|
<View className="flex px-4">
|
||||||
<Text className="opacity-50">No downloaded items</Text>
|
<Text className="opacity-50">No downloaded items</Text>
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { router, useFocusEffect } from "expo-router";
|
import {Href, router, useFocusEffect} from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
@@ -206,7 +206,16 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
) : isDownloaded ? (
|
) : isDownloaded ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/downloads");
|
router.push(
|
||||||
|
item.Type !== "Episode"
|
||||||
|
? "/downloads"
|
||||||
|
: {
|
||||||
|
pathname: `/downloads/${item.SeriesId}`,
|
||||||
|
params: {
|
||||||
|
episodeSeasonIndex: item.ParentIndexNumber
|
||||||
|
}
|
||||||
|
} as Href
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import React, { useCallback, useMemo, useRef } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
ActionSheetProvider,
|
ActionSheetProvider,
|
||||||
@@ -8,12 +8,12 @@ import {
|
|||||||
} from "@expo/react-native-action-sheet";
|
} from "@expo/react-native-action-sheet";
|
||||||
|
|
||||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
import {runtimeTicksToSeconds} from "@/utils/time";
|
||||||
|
|
||||||
interface EpisodeCardProps {
|
interface EpisodeCardProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -31,7 +31,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(item.Id!);
|
return storage.getString(item.Id!);
|
||||||
}, []);
|
}, [item]);
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(() => {
|
||||||
openFile(item);
|
openFile(item);
|
||||||
@@ -76,32 +76,47 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleOpenFile}
|
onPress={handleOpenFile}
|
||||||
onLongPress={showActionSheet}
|
onLongPress={showActionSheet}
|
||||||
className="flex flex-col w-44 mr-2"
|
className="flex flex-col mr-2"
|
||||||
>
|
>
|
||||||
{base64Image ? (
|
<View className="flex flex-row items-start mb-2">
|
||||||
<View className="w-44 aspect-video rounded-lg overflow-hidden">
|
<View className="mr-2">
|
||||||
<Image
|
{base64Image ? (
|
||||||
source={{
|
<View className="w-44 aspect-video rounded-lg overflow-hidden">
|
||||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
<Image
|
||||||
}}
|
source={{
|
||||||
style={{
|
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||||
width: "100%",
|
}}
|
||||||
height: "100%",
|
style={{
|
||||||
resizeMode: "cover",
|
width: "100%",
|
||||||
}}
|
height: "100%",
|
||||||
/>
|
resizeMode: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
|
||||||
|
<Ionicons
|
||||||
|
name="image-outline"
|
||||||
|
size={24}
|
||||||
|
color="gray"
|
||||||
|
className="self-center mt-16"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : (
|
<View className="w-56 flex flex-col">
|
||||||
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
|
<Text numberOfLines={2} className="">
|
||||||
<Ionicons
|
{item.Name}
|
||||||
name="image-outline"
|
</Text>
|
||||||
size={24}
|
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||||
color="gray"
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
className="self-center mt-16"
|
</Text>
|
||||||
/>
|
<Text className="text-xs text-neutral-500">
|
||||||
|
{runtimeTicksToSeconds(item.RunTimeTicks)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
</View>
|
||||||
<ItemCardText item={item} />
|
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">{item.Overview}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,55 +1,51 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { ScrollView, View } from "react-native";
|
import {TouchableOpacity, View} from "react-native";
|
||||||
import { EpisodeCard } from "./EpisodeCard";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useMemo } from "react";
|
import React, {useMemo} from "react";
|
||||||
import { SeasonPicker } from "../series/SeasonPicker";
|
import {storage} from "@/utils/mmkv";
|
||||||
|
import {Image} from "expo-image";
|
||||||
|
import {Ionicons} from "@expo/vector-icons";
|
||||||
|
import {router} from "expo-router";
|
||||||
|
|
||||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
|
||||||
const groupBySeason = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
const seasons: Record<string, BaseItemDto[]> = {};
|
return storage.getString(items[0].SeriesId!);
|
||||||
|
}, []);
|
||||||
items.forEach((item) => {
|
|
||||||
if (!seasons[item.SeasonName!]) {
|
|
||||||
seasons[item.SeasonName!] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
seasons[item.SeasonName!].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.values(seasons).sort(
|
|
||||||
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
|
|
||||||
);
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => {
|
|
||||||
return a.IndexNumber! > b.IndexNumber! ? 1 : -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<TouchableOpacity onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}>
|
||||||
<View className="flex flex-row items-center justify-between px-4">
|
{base64Image ? (
|
||||||
<Text className="text-lg font-bold shrink">{items[0].SeriesName}</Text>
|
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
<Image
|
||||||
<Text className="text-xs font-bold">{items.length}</Text>
|
source={{
|
||||||
|
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
resizeMode: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1">
|
||||||
|
<Text className="text-xs font-bold">{items.length}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
) : (
|
||||||
|
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
|
||||||
|
<Ionicons
|
||||||
|
name="image-outline"
|
||||||
|
size={24}
|
||||||
|
color="gray"
|
||||||
|
className="self-center mt-16"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text className="opacity-50 mb-2 px-4">TV-Series</Text>
|
<View className="w-28 mt-2 flex flex-col">
|
||||||
{groupBySeason.map((seasonItems, seasonIndex) => (
|
<Text numberOfLines={2} className="">{items[0].SeriesName}</Text>
|
||||||
<View key={seasonIndex}>
|
<Text className="text-xs opacity-50">{items[0].ProductionYear}</Text>
|
||||||
<Text className="mb-2 font-semibold px-4">
|
</View>
|
||||||
{seasonItems[0].SeasonName}
|
</TouchableOpacity>
|
||||||
</Text>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className="px-4 flex flex-row">
|
|
||||||
{seasonItems.sort(sortByIndex)?.map((item, index) => (
|
|
||||||
<EpisodeCard item={item} key={index} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
112
components/series/SeasonDropdown.tsx
Normal file
112
components/series/SeasonDropdown.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {useEffect, useMemo} from "react";
|
||||||
|
import {TouchableOpacity, View} from "react-native";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import {Text} from "../common/Text";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
seasons: BaseItemDto[];
|
||||||
|
initialSeasonIndex?: number;
|
||||||
|
state: SeasonIndexState;
|
||||||
|
onSelect: (season: BaseItemDto) => void
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeasonKeys = {
|
||||||
|
id: keyof BaseItemDto,
|
||||||
|
title: keyof BaseItemDto,
|
||||||
|
index: keyof BaseItemDto
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SeasonIndexState = {
|
||||||
|
[seriesId: string]: number | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SeasonDropdown: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
seasons,
|
||||||
|
initialSeasonIndex,
|
||||||
|
state,
|
||||||
|
onSelect
|
||||||
|
}) => {
|
||||||
|
const keys = useMemo<SeasonKeys>(() =>
|
||||||
|
item.Type === "Episode" ? {
|
||||||
|
id: "ParentId",
|
||||||
|
title: "SeasonName",
|
||||||
|
index: "ParentIndexNumber"
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: "Id",
|
||||||
|
title: "Name",
|
||||||
|
index: "IndexNumber"
|
||||||
|
}, [item]
|
||||||
|
);
|
||||||
|
const seasonIndex = useMemo(() => state[item[keys.id] ?? ""], [state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||||
|
let initialIndex: number | undefined;
|
||||||
|
|
||||||
|
if (initialSeasonIndex !== undefined) {
|
||||||
|
// Use the provided initialSeasonIndex if it exists in the seasons
|
||||||
|
const seasonExists = seasons.some(
|
||||||
|
(season: any) => season[keys.index] === initialSeasonIndex
|
||||||
|
);
|
||||||
|
if (seasonExists) {
|
||||||
|
initialIndex = initialSeasonIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialIndex === undefined) {
|
||||||
|
// Fall back to the previous logic if initialIndex is not set
|
||||||
|
const season1 = seasons.find((season: any) => season[keys.index] === 1);
|
||||||
|
const season0 = seasons.find((season: any) => season[keys.index] === 0);
|
||||||
|
const firstSeason = season1 || season0 || seasons[0];
|
||||||
|
onSelect(firstSeason)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialIndex !== undefined) {
|
||||||
|
const initialSeason = seasons.find((season: any) =>
|
||||||
|
season[keys.index] === initialIndex
|
||||||
|
)
|
||||||
|
|
||||||
|
if (initialSeason) onSelect(initialSeason!)
|
||||||
|
else throw Error("Initial index could not be found!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [seasons, seasonIndex, item[keys.id], initialSeasonIndex]);
|
||||||
|
|
||||||
|
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => a[keys.index] - b[keys.index];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<View className="flex flex-row px-4">
|
||||||
|
<TouchableOpacity
|
||||||
|
className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
|
<Text>Season {seasonIndex}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
|
||||||
|
{seasons?.sort(sortByIndex).map((season: any) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={season[keys.title]}
|
||||||
|
onSelect={() => onSelect(season)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>{season[keys.title]}</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,30 +2,23 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { DownloadItem } from "../DownloadItem";
|
import { DownloadItem } from "../DownloadItem";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import {SeasonDropdown, SeasonIndexState} from "@/components/series/SeasonDropdown";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
initialSeasonIndex?: number;
|
initialSeasonIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SeasonIndexState = {
|
|
||||||
[seriesId: string]: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||||
|
|
||||||
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||||
@@ -35,8 +28,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
|
|
||||||
const seasonIndex = seasonIndexState[item.Id ?? ""];
|
const seasonIndex = seasonIndexState[item.Id ?? ""];
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { data: seasons } = useQuery({
|
const { data: seasons } = useQuery({
|
||||||
queryKey: ["seasons", item.Id],
|
queryKey: ["seasons", item.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -61,37 +52,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
enabled: !!api && !!user?.Id && !!item.Id,
|
enabled: !!api && !!user?.Id && !!item.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
|
||||||
let initialIndex: number | undefined;
|
|
||||||
|
|
||||||
if (initialSeasonIndex !== undefined) {
|
|
||||||
// Use the provided initialSeasonIndex if it exists in the seasons
|
|
||||||
const seasonExists = seasons.some(
|
|
||||||
(season: any) => season.IndexNumber === initialSeasonIndex
|
|
||||||
);
|
|
||||||
if (seasonExists) {
|
|
||||||
initialIndex = initialSeasonIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialIndex === undefined) {
|
|
||||||
// Fall back to the previous logic if initialIndex is not set
|
|
||||||
const season1 = seasons.find((season: any) => season.IndexNumber === 1);
|
|
||||||
const season0 = seasons.find((season: any) => season.IndexNumber === 0);
|
|
||||||
const firstSeason = season1 || season0 || seasons[0];
|
|
||||||
initialIndex = firstSeason.IndexNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialIndex !== undefined) {
|
|
||||||
setSeasonIndexState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[item.Id ?? ""]: initialIndex,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]);
|
|
||||||
|
|
||||||
const selectedSeasonId: string | null = useMemo(
|
const selectedSeasonId: string | null = useMemo(
|
||||||
() =>
|
() =>
|
||||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||||
@@ -148,39 +108,16 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
minHeight: 144 * nrOfEpisodes,
|
minHeight: 144 * nrOfEpisodes,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
<SeasonDropdown
|
||||||
<DropdownMenu.Trigger>
|
item={item}
|
||||||
<View className="flex flex-row px-4">
|
seasons={seasons}
|
||||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
state={seasonIndexState}
|
||||||
<Text>Season {seasonIndex}</Text>
|
onSelect={(season) => {
|
||||||
</TouchableOpacity>
|
setSeasonIndexState((prev) => ({
|
||||||
</View>
|
...prev,
|
||||||
</DropdownMenu.Trigger>
|
[item.Id ?? ""]: season.IndexNumber,
|
||||||
<DropdownMenu.Content
|
}));
|
||||||
loop={true}
|
}} />
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
|
|
||||||
{seasons?.map((season: any) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={season.Name}
|
|
||||||
onSelect={() => {
|
|
||||||
setSeasonIndexState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[item.Id ?? ""]: season.IndexNumber,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
<View className="px-4 flex flex-col my-4">
|
<View className="px-4 flex flex-col my-4">
|
||||||
{isFetching ? (
|
{isFetching ? (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useAtomValue } from "jotai";
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import useImageStorage from "./useImageStorage";
|
import useImageStorage from "./useImageStorage";
|
||||||
|
import useDownloadHelper from "@/utils/download";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||||
@@ -28,6 +29,7 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { saveImage } = useImageStorage();
|
const { saveImage } = useImageStorage();
|
||||||
|
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||||
|
|
||||||
const startRemuxing = useCallback(
|
const startRemuxing = useCallback(
|
||||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
||||||
@@ -35,6 +37,7 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
if (!api) throw new Error("API is not defined");
|
if (!api) throw new Error("API is not defined");
|
||||||
if (!item.Id) throw new Error("Item must have an Id");
|
if (!item.Id) throw new Error("Item must have an Id");
|
||||||
|
|
||||||
|
await saveSeriesPrimaryImage(item);
|
||||||
const itemImage = getItemImage({
|
const itemImage = getItemImage({
|
||||||
item,
|
item,
|
||||||
api,
|
api,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import * as Notifications from "expo-notifications";
|
|||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import useImageStorage from "@/hooks/useImageStorage";
|
import useImageStorage from "@/hooks/useImageStorage";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import useDownloadHelper from "@/utils/download";
|
||||||
|
|
||||||
export type DownloadedItem = {
|
export type DownloadedItem = {
|
||||||
item: Partial<BaseItemDto>;
|
item: Partial<BaseItemDto>;
|
||||||
@@ -66,6 +67,7 @@ function useDownloadProvider() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const {saveSeriesPrimaryImage} = useDownloadHelper();
|
||||||
const { saveImage } = useImageStorage();
|
const { saveImage } = useImageStorage();
|
||||||
|
|
||||||
const [processes, setProcesses] = useState<JobStatus[]>([]);
|
const [processes, setProcesses] = useState<JobStatus[]>([]);
|
||||||
@@ -311,6 +313,7 @@ function useDownloadProvider() {
|
|||||||
const fileExtension = mediaSource.TranscodingContainer;
|
const fileExtension = mediaSource.TranscodingContainer;
|
||||||
const deviceId = await getOrSetDeviceId();
|
const deviceId = await getOrSetDeviceId();
|
||||||
|
|
||||||
|
await saveSeriesPrimaryImage(item);
|
||||||
const itemImage = getItemImage({
|
const itemImage = getItemImage({
|
||||||
item,
|
item,
|
||||||
api,
|
api,
|
||||||
|
|||||||
21
utils/download.ts
Normal file
21
utils/download.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import {getPrimaryImageUrlById} from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
|
import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import useImageStorage from "@/hooks/useImageStorage";
|
||||||
|
import {apiAtom} from "@/providers/JellyfinProvider";
|
||||||
|
import {useAtom} from "jotai";
|
||||||
|
import {storage} from "@/utils/mmkv";
|
||||||
|
|
||||||
|
const useDownloadHelper = () => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const {saveImage} = useImageStorage();
|
||||||
|
|
||||||
|
const saveSeriesPrimaryImage = async (item: BaseItemDto) => {
|
||||||
|
if (item.Type === "Episode" && item.SeriesId && !storage.getString(item.SeriesId)) {
|
||||||
|
await saveImage(item.SeriesId, getPrimaryImageUrlById({ api, id: item.SeriesId }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { saveSeriesPrimaryImage }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDownloadHelper;
|
||||||
Reference in New Issue
Block a user