Files
streamyfin/components/jellyseerr/tv/TVRequestModal.tsx

520 lines
15 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
BackHandler,
Easing,
ScrollView,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVButton, TVOptionSelector } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
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 { TVRequestOptionRow } from "./TVRequestOptionRow";
import { TVToggleOptionRow } from "./TVToggleOptionRow";
interface TVRequestModalProps {
visible: boolean;
requestBody?: MediaRequestBody;
title: string;
id: number;
mediaType: MediaType;
onClose: () => void;
onRequested: () => void;
}
export const TVRequestModal: React.FC<TVRequestModalProps> = ({
visible,
requestBody,
title,
id,
mediaType,
onClose,
onRequested,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
mediaId: Number(id),
mediaType,
userId: jellyseerrUser?.id,
});
const [activeSelector, setActiveSelector] = useState<
"profile" | "folder" | "user" | null
>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
useEffect(() => {
if (visible) {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
// Handle back button to close modal
useEffect(() => {
if (!visible) return;
const handleBackPress = () => {
// If a sub-selector is open, close it first
if (activeSelector) {
setActiveSelector(null);
} else {
onClose();
}
return true; // Prevent default back behavior
};
const subscription = BackHandler.addEventListener(
"hardwareBackPress",
handleBackPress,
);
return () => subscription.remove();
}, [visible, activeSelector, onClose]);
const { data: serviceSettings } = useQuery({
queryKey: ["jellyseerr", "request", mediaType, "service"],
queryFn: async () =>
jellyseerrApi?.service(mediaType === "movie" ? "radarr" : "sonarr"),
enabled: !!jellyseerrApi && !!jellyseerrUser && visible,
});
const { data: users } = useQuery({
queryKey: ["jellyseerr", "users"],
queryFn: async () =>
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
enabled: !!jellyseerrApi && !!jellyseerrUser && visible,
});
const defaultService = useMemo(
() => serviceSettings?.find?.((v) => v.isDefault),
[serviceSettings],
);
const { data: defaultServiceDetails } = useQuery({
queryKey: [
"jellyseerr",
"request",
mediaType,
"service",
"details",
defaultService?.id,
],
queryFn: async () => {
setRequestOverrides((prev) => ({
...prev,
serverId: defaultService?.id,
}));
return jellyseerrApi?.serviceDetails(
mediaType === "movie" ? "radarr" : "sonarr",
defaultService!.id,
);
},
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService && visible,
});
const defaultProfile: QualityProfile | undefined = useMemo(
() =>
defaultServiceDetails?.profiles.find(
(p) => p.id === defaultServiceDetails.server?.activeProfileId,
),
[defaultServiceDetails],
);
const defaultFolder: RootFolder | undefined = useMemo(
() =>
defaultServiceDetails?.rootFolders.find(
(f) => f.path === defaultServiceDetails.server?.activeDirectory,
),
[defaultServiceDetails],
);
const defaultTags: Tag[] = useMemo(() => {
return (
defaultServiceDetails?.tags.filter((t) =>
defaultServiceDetails?.server.activeTags?.includes(t.id),
) ?? []
);
}, [defaultServiceDetails]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
// Option builders
const qualityProfileOptions: TVOptionItem<number>[] = useMemo(
() =>
defaultServiceDetails?.profiles.map((profile) => ({
label: profile.name,
value: profile.id,
selected:
(requestOverrides.profileId || defaultProfile?.id) === profile.id,
})) || [],
[
defaultServiceDetails?.profiles,
defaultProfile,
requestOverrides.profileId,
],
);
const rootFolderOptions: TVOptionItem<string>[] = useMemo(
() =>
defaultServiceDetails?.rootFolders.map((folder) => ({
label: pathTitleExtractor(folder),
value: folder.path,
selected:
(requestOverrides.rootFolder || defaultFolder?.path) === folder.path,
})) || [],
[
defaultServiceDetails?.rootFolders,
defaultFolder,
requestOverrides.rootFolder,
],
);
const userOptions: TVOptionItem<number>[] = useMemo(
() =>
users?.map((user) => ({
label: user.displayName,
value: user.id,
selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id,
})) || [],
[users, jellyseerrUser, requestOverrides.userId],
);
const tagItems = useMemo(() => {
return (
defaultServiceDetails?.tags.map((tag) => ({
id: tag.id,
label: tag.label,
selected:
requestOverrides.tags?.includes(tag.id) ||
defaultTags.some((dt) => dt.id === tag.id),
})) ?? []
);
}, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]);
// Selected display values
const selectedProfileName = useMemo(() => {
const profile = defaultServiceDetails?.profiles.find(
(p) => p.id === (requestOverrides.profileId || defaultProfile?.id),
);
return profile?.name || defaultProfile?.name || t("jellyseerr.select");
}, [
defaultServiceDetails?.profiles,
requestOverrides.profileId,
defaultProfile,
t,
]);
const selectedFolderName = useMemo(() => {
const folder = defaultServiceDetails?.rootFolders.find(
(f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path),
);
return folder
? pathTitleExtractor(folder)
: defaultFolder
? pathTitleExtractor(defaultFolder)
: t("jellyseerr.select");
}, [
defaultServiceDetails?.rootFolders,
requestOverrides.rootFolder,
defaultFolder,
t,
]);
const selectedUserName = useMemo(() => {
const user = users?.find(
(u) => u.id === (requestOverrides.userId || jellyseerrUser?.id),
);
return (
user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select")
);
}, [users, requestOverrides.userId, jellyseerrUser, t]);
// Handlers
const handleProfileChange = useCallback((profileId: number) => {
setRequestOverrides((prev) => ({ ...prev, profileId }));
setActiveSelector(null);
}, []);
const handleFolderChange = useCallback((rootFolder: string) => {
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
setActiveSelector(null);
}, []);
const handleUserChange = useCallback((userId: number) => {
setRequestOverrides((prev) => ({ ...prev, userId }));
setActiveSelector(null);
}, []);
const handleTagToggle = useCallback(
(tagId: number) => {
setRequestOverrides((prev) => {
const currentTags = prev.tags || defaultTags.map((t) => t.id);
const hasTag = currentTags.includes(tagId);
return {
...prev,
tags: hasTag
? currentTags.filter((id) => id !== tagId)
: [...currentTags, tagId],
};
});
},
[defaultTags],
);
const handleRequest = useCallback(() => {
const body = {
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
profileId: defaultProfile?.id,
rootFolder: defaultFolder?.path,
tags: defaultTags.map((t) => t.id),
...requestBody,
...requestOverrides,
};
const seasonTitle =
requestBody?.seasons?.length === 1
? t("jellyseerr.season_number", {
season_number: requestBody.seasons[0],
})
: requestBody?.seasons && requestBody.seasons.length > 1
? t("jellyseerr.season_all")
: undefined;
requestMedia(
seasonTitle ? `${title}, ${seasonTitle}` : title,
body,
onRequested,
);
}, [
requestBody,
requestOverrides,
defaultProfile,
defaultFolder,
defaultTags,
defaultService,
defaultServiceDetails,
title,
requestMedia,
onRequested,
t,
]);
if (!visible) return null;
const isDataLoaded = defaultService && defaultServiceDetails && users;
return (
<>
<Animated.View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "flex-end",
zIndex: 1000,
opacity: overlayOpacity,
}}
>
<Animated.View
style={{
width: "100%",
transform: [{ translateY: sheetTranslateY }],
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{
paddingTop: 24,
paddingBottom: 50,
paddingHorizontal: 44,
overflow: "visible",
}}
>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("jellyseerr.advanced")}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
}}
>
{title}
</Text>
{isDataLoaded ? (
<ScrollView
style={{ maxHeight: 320, overflow: "visible" }}
showsVerticalScrollIndicator={false}
>
<View
style={{
gap: 12,
paddingVertical: 8,
paddingHorizontal: 4,
}}
>
<TVRequestOptionRow
label={t("jellyseerr.quality_profile")}
value={selectedProfileName}
onPress={() => setActiveSelector("profile")}
hasTVPreferredFocus
/>
<TVRequestOptionRow
label={t("jellyseerr.root_folder")}
value={selectedFolderName}
onPress={() => setActiveSelector("folder")}
/>
<TVRequestOptionRow
label={t("jellyseerr.request_as")}
value={selectedUserName}
onPress={() => setActiveSelector("user")}
/>
{tagItems.length > 0 && (
<TVToggleOptionRow
label={t("jellyseerr.tags")}
items={tagItems}
onToggle={handleTagToggle}
/>
)}
</View>
</ScrollView>
) : (
<View
style={{
height: 200,
justifyContent: "center",
alignItems: "center",
}}
>
<Text style={{ color: "rgba(255,255,255,0.5)" }}>
{t("common.loading")}
</Text>
</View>
)}
<View style={{ marginTop: 24 }}>
<TVButton
onPress={handleRequest}
variant='secondary'
disabled={!isDataLoaded}
>
<Ionicons
name='add'
size={22}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: typography.callout,
fontWeight: "bold",
color: "#FFFFFF",
}}
>
{t("jellyseerr.request_button")}
</Text>
</TVButton>
</View>
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
{/* Sub-selectors */}
<TVOptionSelector
visible={activeSelector === "profile"}
title={t("jellyseerr.quality_profile")}
options={qualityProfileOptions}
onSelect={handleProfileChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
<TVOptionSelector
visible={activeSelector === "folder"}
title={t("jellyseerr.root_folder")}
options={rootFolderOptions}
onSelect={handleFolderChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
cardWidth={280}
/>
<TVOptionSelector
visible={activeSelector === "user"}
title={t("jellyseerr.request_as")}
options={userOptions}
onSelect={handleUserChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
</>
);
};