mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Updates branding and naming conventions to use "Seerr" instead of "Jellyseerr" across all files, components, hooks, and translations. Renames files, functions, classes, variables, and UI text to reflect the new naming convention while maintaining identical functionality. Updates asset references including logo and screenshot images. Changes API class name, storage keys, atom names, and all related utilities to use "Seerr" prefix. Modifies translation keys and user-facing text to match the rebrand.
338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import { FlashList } from "@shopify/flash-list";
|
|
import {
|
|
type QueryObserverResult,
|
|
type RefetchOptions,
|
|
useQuery,
|
|
} from "@tanstack/react-query";
|
|
import { Image } from "expo-image";
|
|
import { t } from "i18next";
|
|
import { orderBy } from "lodash";
|
|
import type React from "react";
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import { Alert, TouchableOpacity, View } from "react-native";
|
|
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
|
import { Text } from "@/components/common/Text";
|
|
import { Tags } from "@/components/GenreTags";
|
|
import { RoundButton } from "@/components/RoundButton";
|
|
import { dateOpts } from "@/components/seerr/DetailFacts";
|
|
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
|
|
import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon";
|
|
import { useSeerr } from "@/hooks/useSeerr";
|
|
import {
|
|
MediaStatus,
|
|
MediaType,
|
|
} from "@/utils/jellyseerr/server/constants/media";
|
|
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
|
import type Season from "@/utils/jellyseerr/server/entity/Season";
|
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
|
import { Loader } from "../Loader";
|
|
|
|
const SeerrSeasonEpisodes: React.FC<{
|
|
details: TvDetails;
|
|
seasonNumber: number;
|
|
}> = ({ details, seasonNumber }) => {
|
|
const { seerrApi } = useSeerr();
|
|
|
|
const { data: seasonWithEpisodes, isLoading } = useQuery({
|
|
queryKey: ["seerr", details.id, "season", seasonNumber],
|
|
queryFn: async () => seerrApi?.tvSeason(details.id, seasonNumber),
|
|
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
|
|
});
|
|
|
|
return (
|
|
<HorizontalScroll
|
|
horizontal
|
|
loading={isLoading}
|
|
showsHorizontalScrollIndicator={false}
|
|
data={seasonWithEpisodes?.episodes}
|
|
keyExtractor={(item) => item.id.toString()}
|
|
renderItem={(item, index) => (
|
|
<RenderItem key={index} item={item} index={index} />
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const RenderItem = ({ item }: any) => {
|
|
const { seerrApi, seerrRegion: region, seerrLocale: locale } = useSeerr();
|
|
const [imageError, setImageError] = useState(false);
|
|
|
|
const upcomingAirDate = useMemo(() => {
|
|
const airDate = item.airDate;
|
|
if (airDate) {
|
|
const airDateObj = new Date(airDate);
|
|
if (new Date() < airDateObj) {
|
|
return airDateObj.toLocaleDateString(`${locale}-${region}`, dateOpts);
|
|
}
|
|
}
|
|
}, [item, locale, region]);
|
|
|
|
return (
|
|
<View className='flex flex-col w-44 mt-2'>
|
|
<View className='relative aspect-video rounded-lg overflow-hidden border border-neutral-800'>
|
|
{!imageError ? (
|
|
<>
|
|
<Image
|
|
key={item.id}
|
|
id={item.id}
|
|
source={{
|
|
uri: seerrApi?.imageProxy(item.stillPath),
|
|
}}
|
|
cachePolicy={"memory-disk"}
|
|
contentFit='cover'
|
|
className='w-full h-full'
|
|
onError={(_e) => {
|
|
setImageError(true);
|
|
}}
|
|
/>
|
|
{upcomingAirDate && (
|
|
<View className='absolute justify-center bottom-0 right-0.5 items-center'>
|
|
<View className='rounded-full bg-purple-600/30 p-1'>
|
|
<Text
|
|
className='text-center text-xs'
|
|
style={textShadowStyle.shadow}
|
|
>
|
|
{upcomingAirDate}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</>
|
|
) : (
|
|
<View className='flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900'>
|
|
<Ionicons
|
|
name='image-outline'
|
|
size={24}
|
|
color='white'
|
|
style={{ opacity: 0.4 }}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<View className='shrink mt-1'>
|
|
<Text numberOfLines={2} className=''>
|
|
{item.name}
|
|
</Text>
|
|
<Text numberOfLines={1} className='text-xs text-neutral-500'>
|
|
{`S${item.seasonNumber}:E${item.episodeNumber}`}
|
|
</Text>
|
|
</View>
|
|
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
|
|
{item.overview}
|
|
</Text>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const SeerrSeasons: React.FC<{
|
|
isLoading: boolean;
|
|
details?: TvDetails;
|
|
hasAdvancedRequest?: boolean;
|
|
onAdvancedRequest?: (data: MediaRequestBody) => void;
|
|
refetch: (
|
|
options?: RefetchOptions | undefined,
|
|
) => Promise<
|
|
QueryObserverResult<TvDetails | MovieDetails | undefined, Error>
|
|
>;
|
|
}> = ({
|
|
isLoading,
|
|
details,
|
|
refetch,
|
|
hasAdvancedRequest,
|
|
onAdvancedRequest,
|
|
}) => {
|
|
const { seerrApi, requestMedia } = useSeerr();
|
|
const [seasonStates, setSeasonStates] = useState<{ [key: number]: boolean }>(
|
|
{},
|
|
);
|
|
const seasons = useMemo(() => {
|
|
if (!details) return [];
|
|
const mediaInfoSeasons = details.mediaInfo?.seasons?.filter(
|
|
(s: Season) => s.seasonNumber !== 0,
|
|
);
|
|
const requestedSeasons =
|
|
details.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) ??
|
|
[];
|
|
return (
|
|
details.seasons?.map((season) => ({
|
|
...season,
|
|
status:
|
|
mediaInfoSeasons?.find(
|
|
(mediaSeason: Season) =>
|
|
mediaSeason.seasonNumber === season.seasonNumber,
|
|
)?.status ??
|
|
requestedSeasons?.find(
|
|
(s: Season) => s.seasonNumber === season.seasonNumber,
|
|
)?.status ??
|
|
MediaStatus.UNKNOWN,
|
|
})) ?? []
|
|
);
|
|
}, [details]);
|
|
const allSeasonsAvailable = useMemo(
|
|
() => seasons.every((season) => season.status === MediaStatus.AVAILABLE),
|
|
[seasons],
|
|
);
|
|
|
|
const requestAll = useCallback(() => {
|
|
if (details && seerrApi) {
|
|
const body: MediaRequestBody = {
|
|
mediaId: details.id,
|
|
mediaType: MediaType.TV,
|
|
tvdbId: details.externalIds?.tvdbId,
|
|
seasons: seasons
|
|
.filter(
|
|
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0,
|
|
)
|
|
.map((s) => s.seasonNumber),
|
|
};
|
|
if (hasAdvancedRequest) {
|
|
return onAdvancedRequest?.(body);
|
|
}
|
|
requestMedia(details.name, body, refetch);
|
|
}
|
|
}, [
|
|
seerrApi,
|
|
seasons,
|
|
details,
|
|
hasAdvancedRequest,
|
|
onAdvancedRequest,
|
|
requestMedia,
|
|
refetch,
|
|
]);
|
|
|
|
const promptRequestAll = useCallback(
|
|
() =>
|
|
Alert.alert(
|
|
t("seerr.confirm"),
|
|
t("seerr.are_you_sure_you_want_to_request_all_seasons"),
|
|
[
|
|
{
|
|
text: t("seerr.cancel"),
|
|
style: "cancel",
|
|
},
|
|
{
|
|
text: t("seerr.yes"),
|
|
onPress: requestAll,
|
|
},
|
|
],
|
|
),
|
|
[requestAll],
|
|
);
|
|
|
|
const requestSeason = useCallback(
|
|
async (canRequest: boolean, seasonNumber: number) => {
|
|
if (canRequest && details) {
|
|
const body: MediaRequestBody = {
|
|
mediaId: details.id,
|
|
mediaType: MediaType.TV,
|
|
tvdbId: details.externalIds?.tvdbId,
|
|
seasons: [seasonNumber],
|
|
};
|
|
if (hasAdvancedRequest) {
|
|
return onAdvancedRequest?.(body);
|
|
}
|
|
requestMedia(`${details.name}, Season ${seasonNumber}`, body, refetch);
|
|
}
|
|
},
|
|
[requestMedia, hasAdvancedRequest, onAdvancedRequest, refetch, details],
|
|
);
|
|
|
|
if (!details) return null;
|
|
|
|
if (isLoading)
|
|
return (
|
|
<View>
|
|
<View className='flex flex-row justify-between items-end px-4'>
|
|
<Text className='text-lg font-bold mb-2'>
|
|
{t("item_card.seasons")}
|
|
</Text>
|
|
{!allSeasonsAvailable && (
|
|
<RoundButton className='mb-2 pa-2' onPress={promptRequestAll}>
|
|
<Ionicons name='bag-add' color='white' size={26} />
|
|
</RoundButton>
|
|
)}
|
|
</View>
|
|
<Loader />
|
|
</View>
|
|
);
|
|
|
|
return (
|
|
<FlashList
|
|
data={orderBy(
|
|
seasons.filter((s) => s.seasonNumber !== 0),
|
|
"seasonNumber",
|
|
"desc",
|
|
)}
|
|
ListHeaderComponent={() => (
|
|
<View className='flex flex-row justify-between items-end px-4'>
|
|
<Text className='text-lg font-bold mb-2'>
|
|
{t("item_card.seasons")}
|
|
</Text>
|
|
{!allSeasonsAvailable && (
|
|
<RoundButton className='mb-2 pa-2' onPress={promptRequestAll}>
|
|
<Ionicons name='bag-add' color='white' size={26} />
|
|
</RoundButton>
|
|
)}
|
|
</View>
|
|
)}
|
|
ItemSeparatorComponent={() => <View className='h-2' />}
|
|
renderItem={({ item: season }) => (
|
|
<>
|
|
<TouchableOpacity
|
|
onPress={() =>
|
|
setSeasonStates((prevState) => ({
|
|
...prevState,
|
|
[season.seasonNumber]: !prevState?.[season.seasonNumber],
|
|
}))
|
|
}
|
|
className='px-4'
|
|
>
|
|
<View
|
|
className='flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4'
|
|
key={season.id}
|
|
>
|
|
<Tags
|
|
textClass=''
|
|
tags={[
|
|
t("seerr.season_number", {
|
|
season_number: season.seasonNumber,
|
|
}),
|
|
t("seerr.number_episodes", {
|
|
episode_number: season.episodeCount,
|
|
}),
|
|
]}
|
|
/>
|
|
{[0].map(() => {
|
|
const canRequest = season.status === MediaStatus.UNKNOWN;
|
|
return (
|
|
<SeerrStatusIcon
|
|
key={0}
|
|
onPress={() =>
|
|
requestSeason(canRequest, season.seasonNumber)
|
|
}
|
|
className={canRequest ? "bg-gray-700/40" : undefined}
|
|
mediaStatus={season.status}
|
|
showRequestIcon={canRequest}
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
</TouchableOpacity>
|
|
{seasonStates?.[season.seasonNumber] && (
|
|
<SeerrSeasonEpisodes
|
|
key={season.seasonNumber}
|
|
details={details}
|
|
seasonNumber={season.seasonNumber}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default SeerrSeasons;
|