mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-06 09:46:17 +00:00
520 lines
15 KiB
TypeScript
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")}
|
|
/>
|
|
</>
|
|
);
|
|
};
|