mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-11 15:02:57 +01:00
Removes IDE-specific configuration files and establishes Bun as the primary package manager. Updates project documentation to emphasize Bun usage throughout the development workflow and enhances VS Code settings for better TypeScript performance. Optimizes EAS build configuration with caching strategies and resource allocation improvements. Cleans up unused imports and improves TypeScript configuration for better development experience.
210 lines
6.5 KiB
TypeScript
210 lines
6.5 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { atom, useAtom } from "jotai";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { View } from "react-native";
|
|
import {
|
|
SeasonDropdown,
|
|
type SeasonIndexState,
|
|
} from "@/components/series/SeasonDropdown";
|
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
|
import { Text } from "../common/Text";
|
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
|
import { DownloadItems, DownloadSingleItem } from "../DownloadItem";
|
|
import { Loader } from "../Loader";
|
|
import { PlayedStatus } from "../PlayedStatus";
|
|
|
|
type Props = {
|
|
item: BaseItemDto;
|
|
initialSeasonIndex?: number;
|
|
};
|
|
|
|
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
|
|
|
export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|
const [api] = useAtom(apiAtom);
|
|
const [user] = useAtom(userAtom);
|
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
|
const { t } = useTranslation();
|
|
|
|
const seasonIndex = useMemo(
|
|
() => seasonIndexState[item.Id ?? ""],
|
|
[item, seasonIndexState],
|
|
);
|
|
|
|
const { data: seasons } = useQuery({
|
|
queryKey: ["seasons", item.Id],
|
|
queryFn: async () => {
|
|
if (!api || !user?.Id || !item.Id) return [];
|
|
const response = await api.axiosInstance.get(
|
|
`${api.basePath}/Shows/${item.Id}/Seasons`,
|
|
{
|
|
params: {
|
|
userId: user?.Id,
|
|
itemId: item.Id,
|
|
Fields:
|
|
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount",
|
|
},
|
|
headers: {
|
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
|
},
|
|
},
|
|
);
|
|
|
|
return response.data.Items;
|
|
},
|
|
staleTime: 60,
|
|
enabled: !!api && !!user?.Id && !!item.Id,
|
|
});
|
|
|
|
const selectedSeasonId: string | null = useMemo(() => {
|
|
const season: BaseItemDto = seasons?.find(
|
|
(s: BaseItemDto) =>
|
|
s.IndexNumber === seasonIndex || s.Name === seasonIndex,
|
|
);
|
|
|
|
if (!season?.Id) return null;
|
|
|
|
return season.Id!;
|
|
}, [seasons, seasonIndex]);
|
|
|
|
const { data: episodes, isPending } = useQuery({
|
|
queryKey: ["episodes", item.Id, selectedSeasonId],
|
|
queryFn: async () => {
|
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
|
|
return [];
|
|
}
|
|
|
|
const res = await getTvShowsApi(api).getEpisodes({
|
|
seriesId: item.Id,
|
|
userId: user.Id,
|
|
seasonId: selectedSeasonId,
|
|
enableUserData: true,
|
|
// Note: Including trick play is necessary to enable trick play downloads
|
|
fields: ["Overview", "Trickplay"],
|
|
});
|
|
|
|
if (res.data.TotalRecordCount === 0)
|
|
console.warn(
|
|
"No episodes found for season with ID ~",
|
|
selectedSeasonId,
|
|
);
|
|
|
|
return res.data.Items;
|
|
},
|
|
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
|
});
|
|
|
|
const _queryClient = useQueryClient();
|
|
|
|
// Used for height calculation
|
|
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
|
|
useEffect(() => {
|
|
if (episodes && episodes.length > 0) {
|
|
setNrOfEpisodes(episodes.length);
|
|
}
|
|
}, [episodes]);
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
minHeight: 144 * nrOfEpisodes,
|
|
}}
|
|
>
|
|
<View className='flex flex-row justify-start items-center px-4'>
|
|
<SeasonDropdown
|
|
item={item}
|
|
seasons={seasons}
|
|
state={seasonIndexState}
|
|
onSelect={(season) => {
|
|
if (!item.Id) return;
|
|
setSeasonIndexState((prev) => ({
|
|
...prev,
|
|
[item.Id!]: season.IndexNumber ?? season.Name,
|
|
}));
|
|
}}
|
|
/>
|
|
{episodes?.length ? (
|
|
<View className='flex flex-row items-center space-x-2'>
|
|
<DownloadItems
|
|
title={t("item_card.download.download_season")}
|
|
className='ml-2'
|
|
items={episodes || []}
|
|
MissingDownloadIconComponent={() => (
|
|
<Ionicons name='download' size={20} color='white' />
|
|
)}
|
|
DownloadedIconComponent={() => (
|
|
<Ionicons name='download' size={20} color='#9333ea' />
|
|
)}
|
|
/>
|
|
<PlayedStatus items={episodes || []} />
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
<View className='px-4 flex flex-col mt-4'>
|
|
{isPending ? (
|
|
<View
|
|
style={{
|
|
minHeight: 144 * nrOfEpisodes,
|
|
}}
|
|
className='flex flex-col items-center justify-center'
|
|
>
|
|
<Loader />
|
|
</View>
|
|
) : (
|
|
episodes?.map((e: BaseItemDto) => (
|
|
<TouchableItemRouter
|
|
item={e}
|
|
key={e.Id}
|
|
className='flex flex-col mb-4'
|
|
>
|
|
<View className='flex flex-row items-start mb-2'>
|
|
<View className='mr-2'>
|
|
<ContinueWatchingPoster
|
|
size='small'
|
|
item={e}
|
|
useEpisodePoster
|
|
/>
|
|
</View>
|
|
<View className='shrink'>
|
|
<Text numberOfLines={2} className=''>
|
|
{e.Name}
|
|
</Text>
|
|
<Text numberOfLines={1} className='text-xs text-neutral-500'>
|
|
{`S${e.ParentIndexNumber?.toString()}:E${e.IndexNumber?.toString()}`}
|
|
</Text>
|
|
<Text className='text-xs text-neutral-500'>
|
|
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
|
</Text>
|
|
</View>
|
|
<View className='self-start ml-auto -mt-0.5'>
|
|
<DownloadSingleItem item={e} />
|
|
</View>
|
|
</View>
|
|
|
|
<Text
|
|
numberOfLines={3}
|
|
className='text-xs text-neutral-500 shrink'
|
|
>
|
|
{e.Overview}
|
|
</Text>
|
|
</TouchableItemRouter>
|
|
))
|
|
)}
|
|
{(episodes?.length || 0) === 0 ? (
|
|
<View className='flex flex-col'>
|
|
<Text className='text-neutral-500'>
|
|
{t("item_card.no_episodes_for_this_season")}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|