fix: resolve merge conflict in useSeerr.ts - keep improved BCP 47 locale logic

This commit is contained in:
Uruk
2026-01-12 11:40:13 +01:00
384 changed files with 114648 additions and 9463 deletions

40
components/seerr/Cast.tsx Normal file
View 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;

View File

@@ -0,0 +1,208 @@
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, 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, dateOpts);
}
}, [details]);
const revenue = useMemo(
() =>
(details as MovieDetails)?.revenue?.toLocaleString?.(locale, {
style: "currency",
currency: "USD",
}),
[details],
);
const budget = useMemo(
() =>
(details as MovieDetails)?.budget?.toLocaleString?.(locale, {
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,
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;

View 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>
);
};

View 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;

View 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;

View File

@@ -0,0 +1,440 @@
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>
{defaultServiceDetails?.tags &&
defaultServiceDetails.tags.length > 0 && (
<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;

View 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>
);
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;