mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-09 02:21:56 +01:00
refactor: rename Jellyseerr to Seerr throughout codebase
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.
This commit is contained in:
40
components/seerr/Cast.tsx
Normal file
40
components/seerr/Cast.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import type React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import PersonPoster from "@/components/seerr/PersonPoster";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
|
||||
const CastSlide: React.FC<
|
||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||
> = ({ details, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
details?.credits?.cast &&
|
||||
details?.credits?.cast?.length > 0 && (
|
||||
<View {...props}>
|
||||
<Text className='text-lg font-bold mb-2 px-4'>{t("seerr.cast")}</Text>
|
||||
<FlashList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={details?.credits.cast}
|
||||
ItemSeparatorComponent={() => <View className='w-2' />}
|
||||
keyExtractor={(item) => item?.id?.toString()}
|
||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||
renderItem={({ item }) => (
|
||||
<PersonPoster
|
||||
id={item.id.toString()}
|
||||
posterPath={item.profilePath}
|
||||
name={item.name}
|
||||
subName={item.character}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default CastSlide;
|
||||
214
components/seerr/DetailFacts.tsx
Normal file
214
components/seerr/DetailFacts.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import CountryFlag from "react-native-country-flag";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
import type { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
|
||||
interface Release {
|
||||
certification: string;
|
||||
iso_639_1?: string;
|
||||
note?: string;
|
||||
release_date: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
export const dateOpts: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
};
|
||||
|
||||
const Facts: React.FC<
|
||||
{ title: string; facts?: string[] | React.ReactNode[] } & ViewProps
|
||||
> = ({ title, facts, ...props }) =>
|
||||
facts &&
|
||||
facts?.length > 0 && (
|
||||
<View className='flex flex-col justify-between py-2' {...props}>
|
||||
<Text className='font-bold text-start'>{title}</Text>
|
||||
|
||||
<View className='flex flex-col items-end'>
|
||||
{facts.map((f, idx) =>
|
||||
typeof f === "string" ? <Text key={idx}>{f}</Text> : f,
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
||||
title,
|
||||
fact,
|
||||
...props
|
||||
}) => fact && <Facts title={title} facts={[fact]} {...props} />;
|
||||
|
||||
const DetailFacts: React.FC<
|
||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||
> = ({ details, className, ...props }) => {
|
||||
const { seerrRegion: region, seerrLocale: locale } = useSeerr();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const releases = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.releases?.results.find(
|
||||
(r: TmdbRelease) => r.iso_3166_1 === region,
|
||||
)?.release_dates as TmdbRelease["release_dates"],
|
||||
[details],
|
||||
);
|
||||
|
||||
// Release date types:
|
||||
// 1. Premiere
|
||||
// 2. Theatrical (limited)
|
||||
// 3. Theatrical
|
||||
// 4. Digital
|
||||
// 5. Physical
|
||||
// 6. TV
|
||||
const filteredReleases = useMemo(
|
||||
() =>
|
||||
uniqBy(
|
||||
releases?.filter((r: Release) => r.type > 2 && r.type < 6),
|
||||
"type",
|
||||
),
|
||||
[releases],
|
||||
);
|
||||
|
||||
const firstAirDate = useMemo(() => {
|
||||
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
||||
if (firstAirDate) {
|
||||
return new Date(firstAirDate).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts,
|
||||
);
|
||||
}
|
||||
}, [details]);
|
||||
|
||||
const nextAirDate = useMemo(() => {
|
||||
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
||||
const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
|
||||
if (nextAirDate && firstAirDate !== nextAirDate) {
|
||||
return new Date(nextAirDate).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts,
|
||||
);
|
||||
}
|
||||
}, [details]);
|
||||
|
||||
const revenue = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.revenue?.toLocaleString?.(
|
||||
`${locale}-${region}`,
|
||||
{ style: "currency", currency: "USD" },
|
||||
),
|
||||
[details],
|
||||
);
|
||||
|
||||
const budget = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.budget?.toLocaleString?.(
|
||||
`${locale}-${region}`,
|
||||
{ style: "currency", currency: "USD" },
|
||||
),
|
||||
[details],
|
||||
);
|
||||
|
||||
const streamingProviders = useMemo(
|
||||
() =>
|
||||
details?.watchProviders?.find(
|
||||
(provider) => provider.iso_3166_1 === region,
|
||||
)?.flatrate,
|
||||
[details],
|
||||
);
|
||||
|
||||
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
|
||||
|
||||
const spokenLanguage = useMemo(
|
||||
() =>
|
||||
details?.spokenLanguages.find(
|
||||
(lng) => lng.iso_639_1 === details.originalLanguage,
|
||||
)?.name,
|
||||
[details],
|
||||
);
|
||||
|
||||
return (
|
||||
details && (
|
||||
<View className='p-4'>
|
||||
<Text className='text-lg font-bold'>{t("seerr.details")}</Text>
|
||||
<View
|
||||
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
|
||||
{...props}
|
||||
>
|
||||
<Fact title={t("seerr.status")} fact={details?.status} />
|
||||
<Fact
|
||||
title={t("seerr.original_title")}
|
||||
fact={(details as TvDetails)?.originalName}
|
||||
/>
|
||||
{details.keywords.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID,
|
||||
) && <Fact title={t("seerr.series_type")} fact='Anime' />}
|
||||
<Facts
|
||||
title={t("seerr.release_dates")}
|
||||
facts={filteredReleases?.map?.((r: Release, idx) => (
|
||||
<View key={idx} className='flex flex-row space-x-2 items-center'>
|
||||
{r.type === 3 ? (
|
||||
// Theatrical
|
||||
<Ionicons name='ticket' size={16} color='white' />
|
||||
) : r.type === 4 ? (
|
||||
// Digital
|
||||
<Ionicons name='cloud' size={16} color='white' />
|
||||
) : (
|
||||
// Physical
|
||||
<MaterialCommunityIcons
|
||||
name='record-circle-outline'
|
||||
size={16}
|
||||
color='white'
|
||||
/>
|
||||
)}
|
||||
<Text>
|
||||
{new Date(r.release_date).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
/>
|
||||
<Fact title={t("seerr.first_air_date")} fact={firstAirDate} />
|
||||
<Fact title={t("seerr.next_air_date")} fact={nextAirDate} />
|
||||
<Fact title={t("seerr.revenue")} fact={revenue} />
|
||||
<Fact title={t("seerr.budget")} fact={budget} />
|
||||
<Fact title={t("seerr.original_language")} fact={spokenLanguage} />
|
||||
<Facts
|
||||
title={t("seerr.production_country")}
|
||||
facts={details?.productionCountries?.map((n, idx) => (
|
||||
<View key={idx} className='flex flex-row items-center space-x-2'>
|
||||
<CountryFlag isoCode={n.iso_3166_1} size={10} />
|
||||
<Text>{n.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
/>
|
||||
<Facts
|
||||
title={t("seerr.studios")}
|
||||
facts={uniqBy(details?.productionCompanies, "name")?.map(
|
||||
(n) => n.name,
|
||||
)}
|
||||
/>
|
||||
<Facts
|
||||
title={t("seerr.network")}
|
||||
facts={networks?.map((n) => n.name)}
|
||||
/>
|
||||
<Facts
|
||||
title={t("seerr.currently_streaming_on")}
|
||||
facts={streamingProviders?.map((s) => s.name)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailFacts;
|
||||
21
components/seerr/GridSkeleton.tsx
Normal file
21
components/seerr/GridSkeleton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
}
|
||||
// Dev note might be a good idea to standardize skeletons across the app and have one "file" for it.
|
||||
export const GridSkeleton: React.FC<Props> = ({ index }) => {
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
className='flex flex-col mr-2 h-auto'
|
||||
style={{ width: "30.5%" }}
|
||||
>
|
||||
<View className='relative rounded-lg overflow-hidden border border-neutral-900 w-full mt-4 aspect-[10/15] bg-neutral-800' />
|
||||
<View className='mt-2 flex flex-col w-full'>
|
||||
<View className='h-4 bg-neutral-800 rounded mb-1' />
|
||||
<View className='h-3 bg-neutral-800 rounded w-1/2' />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
171
components/seerr/ParallaxSlideShow.tsx
Normal file
171
components/seerr/ParallaxSlideShow.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import type React from "react";
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Animated, View, type ViewProps } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { GridSkeleton } from "./GridSkeleton";
|
||||
|
||||
const ANIMATION_ENTER = 250;
|
||||
const ANIMATION_EXIT = 250;
|
||||
const BACKDROP_DURATION = 5000;
|
||||
|
||||
type Render = React.ComponentType<any> | React.ReactElement | null | undefined;
|
||||
|
||||
interface Props<T> {
|
||||
data: T[];
|
||||
images: string[];
|
||||
logo?: React.ReactElement;
|
||||
HeaderContent?: () => React.ReactElement;
|
||||
MainContent?: () => React.ReactElement;
|
||||
listHeader: string;
|
||||
renderItem: (item: T, index: number) => Render;
|
||||
keyExtractor: (item: T) => string;
|
||||
onEndReached?: (() => void) | null | undefined;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ParallaxSlideShow = <T,>({
|
||||
data,
|
||||
images,
|
||||
logo,
|
||||
HeaderContent,
|
||||
MainContent,
|
||||
listHeader,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
onEndReached,
|
||||
isLoading = false,
|
||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const enterAnimation = useCallback(
|
||||
() =>
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: ANIMATION_ENTER,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
[fadeAnim],
|
||||
);
|
||||
|
||||
const exitAnimation = useCallback(
|
||||
() =>
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: ANIMATION_EXIT,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
[fadeAnim],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (images?.length) {
|
||||
enterAnimation().start();
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
Animated.sequence([enterAnimation(), exitAnimation()]).start(() => {
|
||||
fadeAnim.setValue(0);
|
||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
|
||||
});
|
||||
}, BACKDROP_DURATION);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}
|
||||
}, [
|
||||
fadeAnim,
|
||||
images,
|
||||
enterAnimation,
|
||||
exitAnimation,
|
||||
setCurrentIndex,
|
||||
currentIndex,
|
||||
]);
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex-1 relative'
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className='flex-1 opacity-100'
|
||||
headerHeight={300}
|
||||
onEndReached={onEndReached}
|
||||
headerImage={
|
||||
<Animated.Image
|
||||
key={images?.[currentIndex]}
|
||||
id={images?.[currentIndex]}
|
||||
source={{
|
||||
uri: images?.[currentIndex],
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: fadeAnim,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
logo={logo}
|
||||
>
|
||||
<View className='flex flex-col space-y-4 px-4'>
|
||||
<View className='flex flex-row justify-between w-full'>
|
||||
<View className='flex flex-col w-full'>{HeaderContent?.()}</View>
|
||||
</View>
|
||||
{MainContent?.()}
|
||||
<View>
|
||||
{isLoading ? (
|
||||
<View>
|
||||
<Text className='text-lg font-bold my-2'>{listHeader}</Text>
|
||||
<View className='px-4'>
|
||||
<View className='flex flex-row flex-wrap'>
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
<GridSkeleton key={i} index={i} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<FlashList
|
||||
data={data}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
No results
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
ListHeaderComponent={
|
||||
<Text className='text-lg font-bold my-2'>{listHeader}</Text>
|
||||
}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
//@ts-expect-error
|
||||
renderItem={({ item, index }) => renderItem(item, index)}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={3}
|
||||
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParallaxSlideShow;
|
||||
45
components/seerr/PersonPoster.tsx
Normal file
45
components/seerr/PersonPoster.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
posterPath?: string;
|
||||
name: string;
|
||||
subName?: string;
|
||||
}
|
||||
|
||||
const PersonPoster: React.FC<Props & ViewProps> = ({
|
||||
id,
|
||||
posterPath,
|
||||
name,
|
||||
subName,
|
||||
...props
|
||||
}) => {
|
||||
const { seerrApi } = useSeerr();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(auth)/(tabs)/${from}/seerr/person/${id}`)}
|
||||
>
|
||||
<View className='flex flex-col w-28' {...props}>
|
||||
<Poster
|
||||
id={id}
|
||||
url={seerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")}
|
||||
/>
|
||||
<Text className='mt-2'>{name}</Text>
|
||||
{subName && <Text className='text-xs opacity-50'>{subName}</Text>}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonPoster;
|
||||
437
components/seerr/RequestModal.tsx
Normal file
437
components/seerr/RequestModal.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { forwardRef, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import type {
|
||||
QualityProfile,
|
||||
RootFolder,
|
||||
Tag,
|
||||
} from "@/utils/jellyseerr/server/api/servarr/base";
|
||||
import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import { writeDebugLog } from "@/utils/log";
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
title: string;
|
||||
requestBody?: MediaRequestBody;
|
||||
type: MediaType;
|
||||
isAnime?: boolean;
|
||||
is4k?: boolean;
|
||||
onRequested?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const RequestModal = forwardRef<
|
||||
BottomSheetModalMethods,
|
||||
Props & Omit<ViewProps, "id">
|
||||
>(
|
||||
(
|
||||
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
|
||||
ref,
|
||||
) => {
|
||||
const { seerrApi, seerrUser, requestMedia } = useSeerr();
|
||||
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
||||
mediaId: Number(id),
|
||||
mediaType: type,
|
||||
userId: seerrUser?.id,
|
||||
});
|
||||
|
||||
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
|
||||
const [rootFolderOpen, setRootFolderOpen] = useState(false);
|
||||
const [tagsOpen, setTagsOpen] = useState(false);
|
||||
const [usersOpen, setUsersOpen] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Reset all dropdown states when modal closes
|
||||
const handleDismiss = useCallback(() => {
|
||||
setQualityProfileOpen(false);
|
||||
setRootFolderOpen(false);
|
||||
setTagsOpen(false);
|
||||
setUsersOpen(false);
|
||||
onDismiss?.();
|
||||
}, [onDismiss]);
|
||||
|
||||
const { data: serviceSettings } = useQuery({
|
||||
queryKey: ["seerr", "request", type, "service"],
|
||||
queryFn: async () =>
|
||||
seerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
|
||||
enabled: !!seerrApi && !!seerrUser,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ["seerr", "users"],
|
||||
queryFn: async () => seerrApi?.user({ take: 1000, sort: "displayname" }),
|
||||
enabled: !!seerrApi && !!seerrUser,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
const defaultService = useMemo(
|
||||
() => serviceSettings?.find?.((v) => v.isDefault),
|
||||
[serviceSettings],
|
||||
);
|
||||
|
||||
const { data: defaultServiceDetails } = useQuery({
|
||||
queryKey: [
|
||||
"seerr",
|
||||
"request",
|
||||
type,
|
||||
"service",
|
||||
"details",
|
||||
defaultService?.id,
|
||||
],
|
||||
queryFn: async () => {
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
serverId: defaultService?.id,
|
||||
}));
|
||||
return seerrApi?.serviceDetails(
|
||||
type === "movie" ? "radarr" : "sonarr",
|
||||
defaultService!.id,
|
||||
);
|
||||
},
|
||||
enabled: !!seerrApi && !!seerrUser && !!defaultService,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
const defaultProfile: QualityProfile = useMemo(
|
||||
() =>
|
||||
defaultServiceDetails?.profiles.find(
|
||||
(p) =>
|
||||
p.id ===
|
||||
(isAnime
|
||||
? defaultServiceDetails.server?.activeAnimeProfileId
|
||||
: defaultServiceDetails.server?.activeProfileId),
|
||||
),
|
||||
[defaultServiceDetails],
|
||||
);
|
||||
|
||||
const defaultFolder: RootFolder = useMemo(
|
||||
() =>
|
||||
defaultServiceDetails?.rootFolders.find(
|
||||
(f) =>
|
||||
f.path ===
|
||||
(isAnime
|
||||
? defaultServiceDetails?.server.activeAnimeDirectory
|
||||
: defaultServiceDetails.server?.activeDirectory),
|
||||
),
|
||||
[defaultServiceDetails],
|
||||
);
|
||||
|
||||
const defaultTags: Tag[] = useMemo(() => {
|
||||
const tags =
|
||||
defaultServiceDetails?.tags.filter((t) =>
|
||||
(isAnime
|
||||
? defaultServiceDetails?.server.activeAnimeTags
|
||||
: defaultServiceDetails?.server.activeTags
|
||||
)?.includes(t.id),
|
||||
) ?? [];
|
||||
return tags;
|
||||
}, [defaultServiceDetails]);
|
||||
|
||||
const seasonTitle = useMemo(() => {
|
||||
if (!requestBody?.seasons || requestBody.seasons.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (requestBody.seasons.length > 1) {
|
||||
return t("seerr.season_all");
|
||||
}
|
||||
return t("seerr.season_number", {
|
||||
season_number: requestBody.seasons[0],
|
||||
});
|
||||
}, [requestBody?.seasons]);
|
||||
|
||||
const pathTitleExtractor = (item: RootFolder) =>
|
||||
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||
|
||||
const qualityProfileOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options:
|
||||
defaultServiceDetails?.profiles.map((profile) => ({
|
||||
type: "radio" as const,
|
||||
label: profile.name,
|
||||
value: profile.id.toString(),
|
||||
selected:
|
||||
(requestOverrides.profileId || defaultProfile?.id) ===
|
||||
profile.id,
|
||||
onPress: () =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
profileId: profile.id,
|
||||
})),
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[
|
||||
defaultServiceDetails?.profiles,
|
||||
defaultProfile,
|
||||
requestOverrides.profileId,
|
||||
],
|
||||
);
|
||||
|
||||
const rootFolderOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options:
|
||||
defaultServiceDetails?.rootFolders.map((folder) => ({
|
||||
type: "radio" as const,
|
||||
label: pathTitleExtractor(folder),
|
||||
value: folder.id.toString(),
|
||||
selected:
|
||||
(requestOverrides.rootFolder || defaultFolder?.path) ===
|
||||
folder.path,
|
||||
onPress: () =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
rootFolder: folder.path,
|
||||
})),
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[
|
||||
defaultServiceDetails?.rootFolders,
|
||||
defaultFolder,
|
||||
requestOverrides.rootFolder,
|
||||
],
|
||||
);
|
||||
|
||||
const tagsOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options:
|
||||
defaultServiceDetails?.tags.map((tag) => ({
|
||||
type: "toggle" as const,
|
||||
label: tag.label,
|
||||
value:
|
||||
requestOverrides.tags?.includes(tag.id) ||
|
||||
defaultTags.some((dt) => dt.id === tag.id),
|
||||
onToggle: () =>
|
||||
setRequestOverrides((prev) => {
|
||||
const currentTags = prev.tags || defaultTags.map((t) => t.id);
|
||||
const hasTag = currentTags.includes(tag.id);
|
||||
return {
|
||||
...prev,
|
||||
tags: hasTag
|
||||
? currentTags.filter((id) => id !== tag.id)
|
||||
: [...currentTags, tag.id],
|
||||
};
|
||||
}),
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[defaultServiceDetails?.tags, defaultTags, requestOverrides.tags],
|
||||
);
|
||||
|
||||
const usersOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options:
|
||||
users?.map((user) => ({
|
||||
type: "radio" as const,
|
||||
label: user.displayName,
|
||||
value: user.id.toString(),
|
||||
selected: (requestOverrides.userId || seerrUser?.id) === user.id,
|
||||
onPress: () =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
userId: user.id,
|
||||
})),
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[users, seerrUser, requestOverrides.userId],
|
||||
);
|
||||
|
||||
const request = useCallback(() => {
|
||||
const body = {
|
||||
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||
profileId: defaultProfile?.id,
|
||||
rootFolder: defaultFolder?.path,
|
||||
tags: defaultTags.map((t) => t.id),
|
||||
...requestBody,
|
||||
...requestOverrides,
|
||||
};
|
||||
|
||||
writeDebugLog("Sending Seerr advanced request", body);
|
||||
|
||||
requestMedia(
|
||||
seasonTitle ? `${title}, ${seasonTitle}` : title,
|
||||
body,
|
||||
onRequested,
|
||||
);
|
||||
}, [
|
||||
requestBody,
|
||||
requestOverrides,
|
||||
defaultProfile,
|
||||
defaultFolder,
|
||||
defaultTags,
|
||||
]);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={ref}
|
||||
enableDynamicSizing
|
||||
enableDismissOnClose
|
||||
onDismiss={handleDismiss}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={(sheetProps: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...sheetProps}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
)}
|
||||
stackBehavior='push'
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("seerr.advanced")}
|
||||
</Text>
|
||||
{seasonTitle && (
|
||||
<Text className='text-neutral-300'>{seasonTitle}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
{defaultService && defaultServiceDetails && users && (
|
||||
<>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("seerr.quality_profile")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={qualityProfileOptions}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{defaultServiceDetails.profiles.find(
|
||||
(p) =>
|
||||
p.id ===
|
||||
(requestOverrides.profileId ||
|
||||
defaultProfile?.id),
|
||||
)?.name || defaultProfile?.name}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("seerr.quality_profile")}
|
||||
open={qualityProfileOpen}
|
||||
onOpenChange={setQualityProfileOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("seerr.root_folder")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={rootFolderOptions}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{defaultServiceDetails.rootFolders.find(
|
||||
(f) =>
|
||||
f.path ===
|
||||
(requestOverrides.rootFolder ||
|
||||
defaultFolder?.path),
|
||||
)
|
||||
? pathTitleExtractor(
|
||||
defaultServiceDetails.rootFolders.find(
|
||||
(f) =>
|
||||
f.path ===
|
||||
(requestOverrides.rootFolder ||
|
||||
defaultFolder?.path),
|
||||
)!,
|
||||
)
|
||||
: pathTitleExtractor(defaultFolder!)}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("seerr.root_folder")}
|
||||
open={rootFolderOpen}
|
||||
onOpenChange={setRootFolderOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("seerr.tags")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={tagsOptions}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{requestOverrides.tags
|
||||
? defaultServiceDetails.tags
|
||||
.filter((t) =>
|
||||
requestOverrides.tags!.includes(t.id),
|
||||
)
|
||||
.map((t) => t.label)
|
||||
.join(", ") ||
|
||||
defaultTags.map((t) => t.label).join(", ")
|
||||
: defaultTags.map((t) => t.label).join(", ")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("seerr.tags")}
|
||||
open={tagsOpen}
|
||||
onOpenChange={setTagsOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("seerr.request_as")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={usersOptions}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{users.find(
|
||||
(u) =>
|
||||
u.id ===
|
||||
(requestOverrides.userId || seerrUser?.id),
|
||||
)?.displayName || seerrUser!.displayName}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("seerr.request_as")}
|
||||
open={usersOpen}
|
||||
onOpenChange={setUsersOpen}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Button className='mt-auto' onPress={request} color='purple'>
|
||||
{t("seerr.request_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default RequestModal;
|
||||
202
components/seerr/SeerrIndexPage.tsx
Normal file
202
components/seerr/SeerrIndexPage.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { orderBy, uniqBy } from "lodash";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import {
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Discover from "@/components/seerr/discover/Discover";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type {
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
|
||||
import { Text } from "../common/Text";
|
||||
import SeerrPoster from "../posters/SeerrPoster";
|
||||
import { LoadingSkeleton } from "../search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "../search/SearchItemWrapper";
|
||||
import PersonPoster from "./PersonPoster";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
searchQuery: string;
|
||||
sortType?: SeerrSearchSort;
|
||||
order?: "asc" | "desc";
|
||||
}
|
||||
|
||||
export enum SeerrSearchSort {
|
||||
DEFAULT = 0,
|
||||
VOTE_COUNT_AND_AVERAGE = 1,
|
||||
POPULARITY = 2,
|
||||
}
|
||||
|
||||
export const SeerrIndexPage: React.FC<Props> = ({
|
||||
searchQuery,
|
||||
sortType,
|
||||
order,
|
||||
}) => {
|
||||
const { seerrApi } = useSeerr();
|
||||
const opacity = useSharedValue(1);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
data: seerrDiscoverSettings,
|
||||
isFetching: f1,
|
||||
isLoading: l1,
|
||||
} = useReactNavigationQuery({
|
||||
queryKey: ["search", "seerr", "discoverSettings", searchQuery],
|
||||
queryFn: async () => seerrApi?.discoverSettings(),
|
||||
enabled: !!seerrApi && searchQuery.length === 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: seerrResults,
|
||||
isFetching: f2,
|
||||
isLoading: l2,
|
||||
} = useReactNavigationQuery({
|
||||
queryKey: ["search", "seerr", "results", searchQuery],
|
||||
queryFn: async () => {
|
||||
const params = {
|
||||
query: new URLSearchParams(searchQuery || "").toString(),
|
||||
};
|
||||
return await Promise.all([
|
||||
seerrApi?.search({ ...params, page: 1 }),
|
||||
seerrApi?.search({ ...params, page: 2 }),
|
||||
seerrApi?.search({ ...params, page: 3 }),
|
||||
seerrApi?.search({ ...params, page: 4 }),
|
||||
]).then((all) =>
|
||||
uniqBy(
|
||||
all.flatMap((v) => v?.results || []),
|
||||
"id",
|
||||
),
|
||||
);
|
||||
},
|
||||
enabled: !!seerrApi && searchQuery.length > 0,
|
||||
});
|
||||
|
||||
useAnimatedReaction(
|
||||
() => f1 || f2 || l1 || l2,
|
||||
(isLoading) => {
|
||||
if (isLoading) {
|
||||
opacity.value = withTiming(1, { duration: 200 });
|
||||
} else {
|
||||
opacity.value = withTiming(0, { duration: 200 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const sortingType = useMemo(() => {
|
||||
if (!sortType) return;
|
||||
switch (Number(SeerrSearchSort[sortType])) {
|
||||
case SeerrSearchSort.VOTE_COUNT_AND_AVERAGE:
|
||||
return ["voteCount", "voteAverage"];
|
||||
case SeerrSearchSort.POPULARITY:
|
||||
return ["voteCount", "popularity"];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [sortType, order]);
|
||||
|
||||
const seerrMovieResults = useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
seerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.MOVIE,
|
||||
) as MovieResult[],
|
||||
sortingType || [
|
||||
(m) => m.title.toLowerCase() === searchQuery.toLowerCase(),
|
||||
],
|
||||
order || "desc",
|
||||
),
|
||||
[seerrResults, sortingType, order],
|
||||
);
|
||||
|
||||
const seerrTvResults = useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
seerrResults?.filter((r) => r.mediaType === MediaType.TV) as TvResult[],
|
||||
sortingType || [
|
||||
(t) => t.name.toLowerCase() === searchQuery.toLowerCase(),
|
||||
],
|
||||
order || "desc",
|
||||
),
|
||||
[seerrResults, sortingType, order],
|
||||
);
|
||||
|
||||
const seerrPersonResults = useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
seerrResults?.filter((r) => r.mediaType === "person") as PersonResult[],
|
||||
sortingType || [
|
||||
(p) => p.name.toLowerCase() === searchQuery.toLowerCase(),
|
||||
],
|
||||
order || "desc",
|
||||
),
|
||||
[seerrResults, sortingType, order],
|
||||
);
|
||||
|
||||
if (!searchQuery.length)
|
||||
return (
|
||||
<View className='flex flex-col'>
|
||||
<Discover sliders={seerrDiscoverSettings} />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<LoadingSkeleton isLoading={f1 || f2 || l1 || l2} />
|
||||
|
||||
{!seerrMovieResults?.length &&
|
||||
!seerrTvResults?.length &&
|
||||
!seerrPersonResults?.length &&
|
||||
!f1 &&
|
||||
!f2 &&
|
||||
!l1 &&
|
||||
!l2 && (
|
||||
<View>
|
||||
<Text className='text-center text-lg font-bold mt-4'>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text className='text-xs text-purple-600 text-center'>
|
||||
"{searchQuery}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||
<SearchItemWrapper
|
||||
header={t("search.request_movies")}
|
||||
items={seerrMovieResults}
|
||||
renderItem={(item: MovieResult) => (
|
||||
<SeerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
header={t("search.request_series")}
|
||||
items={seerrTvResults}
|
||||
renderItem={(item: TvResult) => (
|
||||
<SeerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
header={t("search.actors")}
|
||||
items={seerrPersonResults}
|
||||
renderItem={(item: PersonResult) => (
|
||||
<PersonPoster
|
||||
className='mr-2'
|
||||
key={item.id}
|
||||
id={item.id.toString()}
|
||||
name={item.name}
|
||||
posterPath={item.profilePath}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
34
components/seerr/SeerrMediaIcon.tsx
Normal file
34
components/seerr/SeerrMediaIcon.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Feather, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { useMemo } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
|
||||
const SeerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
|
||||
mediaType,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const style = useMemo(
|
||||
() =>
|
||||
mediaType === MediaType.MOVIE
|
||||
? "bg-blue-600/90 border-blue-400/40"
|
||||
: "bg-purple-600/90 border-purple-400/40",
|
||||
[mediaType],
|
||||
);
|
||||
return (
|
||||
mediaType && (
|
||||
<View
|
||||
className={`${className} border ${style} rounded-full p-1`}
|
||||
{...props}
|
||||
>
|
||||
{mediaType === MediaType.MOVIE ? (
|
||||
<MaterialCommunityIcons name='movie-open' size={16} color='white' />
|
||||
) : (
|
||||
<Feather size={16} name='tv' color='white' />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default SeerrMediaIcon;
|
||||
77
components/seerr/SeerrStatusIcon.tsx
Normal file
77
components/seerr/SeerrStatusIcon.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||
|
||||
interface Props {
|
||||
mediaStatus?: MediaStatus;
|
||||
showRequestIcon: boolean;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const SeerrStatusIcon: React.FC<Props & ViewProps> = ({
|
||||
mediaStatus,
|
||||
showRequestIcon,
|
||||
onPress,
|
||||
...props
|
||||
}) => {
|
||||
const [badgeIcon, setBadgeIcon] =
|
||||
useState<keyof typeof MaterialCommunityIcons.glyphMap>();
|
||||
const [badgeStyle, setBadgeStyle] = useState<string>();
|
||||
|
||||
// Match similar to what Jellyseerr is currently using
|
||||
// https://github.com/Fallenbagel/jellyseerr/blob/8a097d5195749c8d1dca9b473b8afa96a50e2fe2/src/components/Common/StatusBadgeMini/index.tsx#L33C1-L62C4
|
||||
useEffect(() => {
|
||||
switch (mediaStatus) {
|
||||
case MediaStatus.PROCESSING:
|
||||
setBadgeStyle(
|
||||
"bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100",
|
||||
);
|
||||
setBadgeIcon("clock");
|
||||
break;
|
||||
case MediaStatus.AVAILABLE:
|
||||
setBadgeStyle(
|
||||
"bg-purple-500 border-green-400 ring-green-400 text-green-100",
|
||||
);
|
||||
setBadgeIcon("check");
|
||||
break;
|
||||
case MediaStatus.PENDING:
|
||||
setBadgeStyle(
|
||||
"bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100",
|
||||
);
|
||||
setBadgeIcon("bell");
|
||||
break;
|
||||
case MediaStatus.BLACKLISTED:
|
||||
setBadgeStyle("bg-red-500 border-white-400 ring-white-400 text-white");
|
||||
setBadgeIcon("eye-off");
|
||||
break;
|
||||
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||
setBadgeStyle(
|
||||
"bg-green-500 border-green-400 ring-green-400 text-green-100",
|
||||
);
|
||||
setBadgeIcon("minus");
|
||||
break;
|
||||
default:
|
||||
if (showRequestIcon) {
|
||||
setBadgeStyle("bg-green-600");
|
||||
setBadgeIcon("plus");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [mediaStatus, showRequestIcon, setBadgeStyle, setBadgeIcon]);
|
||||
|
||||
return (
|
||||
badgeIcon && (
|
||||
<TouchableOpacity onPress={onPress} disabled={onPress === undefined}>
|
||||
<View
|
||||
className={`${badgeStyle ?? "bg-purple-600"} rounded-full h-6 w-6 flex items-center justify-center ${props.className}`}
|
||||
{...props}
|
||||
>
|
||||
<MaterialCommunityIcons name={badgeIcon} size={18} color='white' />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default SeerrStatusIcon;
|
||||
51
components/seerr/discover/CompanySlide.tsx
Normal file
51
components/seerr/discover/CompanySlide.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import {
|
||||
COMPANY_LOGO_IMAGE_FILTER,
|
||||
type Network,
|
||||
} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
import type { Studio } from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
|
||||
|
||||
const CompanySlide: React.FC<
|
||||
{ data: Network[] | Studio[] } & SlideProps & ViewProps
|
||||
> = ({ slide, data, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { seerrApi } = useSeerr();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const navigate = useCallback(
|
||||
({ id, image, name }: Network | Studio) =>
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/seerr/company/${id}` as any,
|
||||
params: { id, image, name, type: slide.type },
|
||||
}),
|
||||
[slide],
|
||||
);
|
||||
|
||||
return (
|
||||
<Slide
|
||||
{...props}
|
||||
slide={slide}
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item, _index) => (
|
||||
<TouchableOpacity className='mr-2' onPress={() => navigate(item)}>
|
||||
<GenericSlideCard
|
||||
className='w-28 rounded-lg overflow-hidden border border-neutral-900 p-4'
|
||||
id={item.id.toString()}
|
||||
url={seerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanySlide;
|
||||
74
components/seerr/discover/Discover.tsx
Normal file
74
components/seerr/discover/Discover.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { sortBy } from "lodash";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import CompanySlide from "@/components/seerr/discover/CompanySlide";
|
||||
import GenreSlide from "@/components/seerr/discover/GenreSlide";
|
||||
import MovieTvSlide from "@/components/seerr/discover/MovieTvSlide";
|
||||
import RecentRequestsSlide from "@/components/seerr/discover/RecentRequestsSlide";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import { networks } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
import { studios } from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
|
||||
|
||||
interface Props {
|
||||
sliders?: DiscoverSlider[];
|
||||
}
|
||||
|
||||
const Discover: React.FC<Props> = ({ sliders }) => {
|
||||
const hasSliders = !!sliders;
|
||||
|
||||
const sortedSliders = useMemo(
|
||||
() =>
|
||||
sortBy(
|
||||
(sliders ?? []).filter((s) => s.enabled),
|
||||
"order",
|
||||
"asc",
|
||||
),
|
||||
[sliders],
|
||||
);
|
||||
|
||||
if (!hasSliders) return null;
|
||||
|
||||
return (
|
||||
<View className='flex flex-col space-y-4 mb-8'>
|
||||
{sortedSliders.map((slide) => {
|
||||
switch (slide.type) {
|
||||
case DiscoverSliderType.RECENT_REQUESTS:
|
||||
return (
|
||||
<RecentRequestsSlide
|
||||
key={slide.id}
|
||||
slide={slide}
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
/>
|
||||
);
|
||||
case DiscoverSliderType.NETWORKS:
|
||||
return (
|
||||
<CompanySlide key={slide.id} slide={slide} data={networks} />
|
||||
);
|
||||
case DiscoverSliderType.STUDIOS:
|
||||
return <CompanySlide key={slide.id} slide={slide} data={studios} />;
|
||||
case DiscoverSliderType.MOVIE_GENRES:
|
||||
case DiscoverSliderType.TV_GENRES:
|
||||
return <GenreSlide key={slide.id} slide={slide} />;
|
||||
case DiscoverSliderType.TRENDING:
|
||||
case DiscoverSliderType.POPULAR_MOVIES:
|
||||
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||
case DiscoverSliderType.POPULAR_TV:
|
||||
case DiscoverSliderType.UPCOMING_TV:
|
||||
return (
|
||||
<MovieTvSlide
|
||||
key={slide.id}
|
||||
slide={slide}
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Discover;
|
||||
70
components/seerr/discover/GenericSlideCard.tsx
Normal file
70
components/seerr/discover/GenericSlideCard.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Image, type ImageContentFit } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import type React from "react";
|
||||
import { StyleSheet, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
export const textShadowStyle = StyleSheet.create({
|
||||
shadow: {
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 0.5,
|
||||
|
||||
elevation: 6,
|
||||
},
|
||||
});
|
||||
|
||||
const GenericSlideCard: React.FC<
|
||||
{
|
||||
id: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
colors?: readonly [string, string, ...string[]];
|
||||
contentFit?: ImageContentFit;
|
||||
} & ViewProps
|
||||
> = ({
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
colors = ["#9333ea", "transparent"],
|
||||
contentFit = "contain",
|
||||
...props
|
||||
}) => (
|
||||
<>
|
||||
<LinearGradient
|
||||
colors={colors}
|
||||
start={{ x: 0.5, y: 1.75 }}
|
||||
end={{ x: 0.5, y: 0 }}
|
||||
className='rounded-xl'
|
||||
>
|
||||
<View className='rounded-xl' {...props}>
|
||||
<Image
|
||||
key={id}
|
||||
id={id}
|
||||
source={url ? { uri: url } : null}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit={contentFit}
|
||||
style={{
|
||||
aspectRatio: "4/3",
|
||||
}}
|
||||
/>
|
||||
{title && (
|
||||
<View className='absolute justify-center top-0 left-0 right-0 bottom-0 items-center'>
|
||||
<Text
|
||||
className='text-center font-bold'
|
||||
style={textShadowStyle.shadow}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</>
|
||||
);
|
||||
|
||||
export default GenericSlideCard;
|
||||
70
components/seerr/discover/GenreSlide.tsx
Normal file
70
components/seerr/discover/GenreSlide.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
|
||||
|
||||
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { seerrApi } = useSeerr();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const navigate = useCallback(
|
||||
(genre: GenreSliderItem) =>
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/seerr/genre/${genre.id}` as any,
|
||||
params: { type: slide.type, name: genre.name },
|
||||
}),
|
||||
[slide],
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["seerr", "discover", slide.type, slide.id],
|
||||
queryFn: async () => {
|
||||
return seerrApi?.getGenreSliders(
|
||||
slide.type === DiscoverSliderType.MOVIE_GENRES
|
||||
? Endpoints.MOVIE
|
||||
: Endpoints.TV,
|
||||
);
|
||||
},
|
||||
enabled: !!seerrApi,
|
||||
});
|
||||
|
||||
return (
|
||||
data && (
|
||||
<Slide
|
||||
{...props}
|
||||
slide={slide}
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item, _index) => (
|
||||
<TouchableOpacity className='mr-2' onPress={() => navigate(item)}>
|
||||
<GenericSlideCard
|
||||
className='w-28 rounded-lg overflow-hidden border border-neutral-900'
|
||||
id={item.id.toString()}
|
||||
title={item.name}
|
||||
colors={["transparent", "transparent"]}
|
||||
contentFit={"cover"}
|
||||
url={seerrApi?.imageProxy(
|
||||
item.backdrops?.[0],
|
||||
`w780_filter(duotone,${
|
||||
genreColorMap[item.id] ?? genreColorMap[0]
|
||||
})`,
|
||||
)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default GenreSlide;
|
||||
87
components/seerr/discover/MovieTvSlide.tsx
Normal file
87
components/seerr/discover/MovieTvSlide.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { uniqBy } from "lodash";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { ViewProps } from "react-native";
|
||||
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||
import { type DiscoverEndpoint, Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
|
||||
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
slide,
|
||||
...props
|
||||
}) => {
|
||||
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
|
||||
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ["seerr", "discover", slide.id],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
let endpoint: DiscoverEndpoint | undefined;
|
||||
let params: any = {
|
||||
page: Number(pageParam),
|
||||
};
|
||||
|
||||
switch (slide.type) {
|
||||
case DiscoverSliderType.TRENDING:
|
||||
endpoint = Endpoints.DISCOVER_TRENDING;
|
||||
break;
|
||||
case DiscoverSliderType.POPULAR_MOVIES:
|
||||
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||
endpoint = Endpoints.DISCOVER_MOVIES;
|
||||
if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
|
||||
params = {
|
||||
...params,
|
||||
primaryReleaseDateGte: new Date().toISOString().split("T")[0],
|
||||
};
|
||||
break;
|
||||
case DiscoverSliderType.POPULAR_TV:
|
||||
case DiscoverSliderType.UPCOMING_TV:
|
||||
endpoint = Endpoints.DISCOVER_TV;
|
||||
if (slide.type === DiscoverSliderType.UPCOMING_TV)
|
||||
params = {
|
||||
...params,
|
||||
firstAirDateGte: new Date().toISOString().split("T")[0],
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return endpoint ? seerrApi?.discover(endpoint, params) : null;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||
1,
|
||||
enabled: !!seerrApi,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const flatData = useMemo(
|
||||
() =>
|
||||
uniqBy(
|
||||
data?.pages
|
||||
?.filter((p) => p?.results.length)
|
||||
.flatMap((p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r))),
|
||||
"id",
|
||||
),
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
flatData &&
|
||||
flatData?.length > 0 && (
|
||||
<Slide
|
||||
{...props}
|
||||
slide={slide}
|
||||
data={flatData}
|
||||
keyExtractor={(item) => item!.id.toString()}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) fetchNextPage();
|
||||
}}
|
||||
renderItem={(item) => <SeerrPoster item={item} key={item?.id} />}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default MovieTvSlide;
|
||||
93
components/seerr/discover/RecentRequestsSlide.tsx
Normal file
93
components/seerr/discover/RecentRequestsSlide.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type React from "react";
|
||||
import type { ViewProps } from "react-native";
|
||||
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
|
||||
|
||||
type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
|
||||
profileName: string;
|
||||
canRemove: boolean;
|
||||
};
|
||||
|
||||
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
|
||||
request,
|
||||
}) => {
|
||||
const { seerrApi } = useSeerr();
|
||||
|
||||
const { data: details } = useQuery({
|
||||
queryKey: [
|
||||
"seerr",
|
||||
"detail",
|
||||
request.media.mediaType,
|
||||
request.media.tmdbId,
|
||||
],
|
||||
queryFn: async () => {
|
||||
return request.media.mediaType === MediaType.MOVIE
|
||||
? seerrApi?.movieDetails(request.media.tmdbId)
|
||||
: seerrApi?.tvDetails(request.media.tmdbId);
|
||||
},
|
||||
enabled: !!seerrApi,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: refreshedRequest } = useQuery({
|
||||
queryKey: ["seerr", "requests", request.media.mediaType, request.id],
|
||||
queryFn: async () => seerrApi?.getRequest(request.id),
|
||||
enabled: !!seerrApi,
|
||||
refetchOnMount: true,
|
||||
refetchInterval: 5000,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<SeerrPoster
|
||||
horizontal
|
||||
showDownloadInfo
|
||||
item={details}
|
||||
mediaRequest={refreshedRequest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
slide,
|
||||
...props
|
||||
}) => {
|
||||
const { seerrApi } = useSeerr();
|
||||
|
||||
const { data: requests } = useQuery({
|
||||
queryKey: ["seerr", "recent_requests"],
|
||||
queryFn: async () => seerrApi?.requests(),
|
||||
enabled: !!seerrApi,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
requests &&
|
||||
requests.results.length > 0 && (
|
||||
<Slide
|
||||
{...props}
|
||||
slide={slide}
|
||||
data={
|
||||
requests.results.map((item) => ({
|
||||
...item,
|
||||
profileName: item.profileName ?? "Unknown",
|
||||
canRemove: Boolean(item.canRemove),
|
||||
})) as ExtendedMediaRequest[]
|
||||
}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item: ExtendedMediaRequest) => (
|
||||
<RequestCard request={item} />
|
||||
)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentRequestsSlide;
|
||||
59
components/seerr/discover/Slide.tsx
Normal file
59
components/seerr/discover/Slide.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { t } from "i18next";
|
||||
import type React from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { View, type ViewProps, type ViewStyle } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
|
||||
export interface SlideProps {
|
||||
slide: DiscoverSlider;
|
||||
contentContainerStyle?: ViewStyle;
|
||||
}
|
||||
|
||||
interface Props<T> extends SlideProps {
|
||||
data: T[];
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
) => React.ComponentType<any> | React.ReactElement | null | undefined;
|
||||
keyExtractor: (item: T) => string;
|
||||
onEndReached?: (() => void) | null | undefined;
|
||||
}
|
||||
|
||||
const Slide = <T,>({
|
||||
data,
|
||||
slide,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
onEndReached,
|
||||
contentContainerStyle,
|
||||
...props
|
||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className='font-bold text-lg mb-2 px-4'>
|
||||
{t(`search.${DiscoverSliderType[slide.type].toString().toLowerCase()}`)}
|
||||
</Text>
|
||||
<FlashList
|
||||
horizontal
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
...(contentContainerStyle ? contentContainerStyle : {}),
|
||||
}}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={keyExtractor}
|
||||
data={data}
|
||||
onEndReachedThreshold={1}
|
||||
onEndReached={onEndReached}
|
||||
//@ts-expect-error
|
||||
renderItem={({ item, index }) =>
|
||||
item ? renderItem(item, index) : null
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Slide;
|
||||
Reference in New Issue
Block a user