mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-09 03:06:17 +00:00
fix(tv): resolve jellyseerr detail page focus navigation loop
This commit is contained in:
979
components/jellyseerr/tv/TVJellyseerrPage.tsx
Normal file
979
components/jellyseerr/tv/TVJellyseerrPage.tsx
Normal file
@@ -0,0 +1,979 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { orderBy } from "lodash";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
FlatList,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import { TVButton } from "@/components/tv";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import type Season from "@/utils/jellyseerr/server/entity/Season";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import {
|
||||
hasPermission,
|
||||
Permission,
|
||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type {
|
||||
MovieResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||
|
||||
// Cast card component
|
||||
interface TVCastCardProps {
|
||||
person: {
|
||||
id: number;
|
||||
name: string;
|
||||
character?: string;
|
||||
profilePath?: string;
|
||||
};
|
||||
imageProxy: (path: string, size?: string) => string;
|
||||
onPress: () => void;
|
||||
isFirst?: boolean;
|
||||
refSetter?: (ref: View | null) => void;
|
||||
}
|
||||
|
||||
const TVCastCard: React.FC<TVCastCardProps> = ({
|
||||
person,
|
||||
imageProxy,
|
||||
onPress,
|
||||
isFirst,
|
||||
refSetter,
|
||||
}) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||
|
||||
const profileUrl = person.profilePath
|
||||
? imageProxy(person.profilePath, "w185")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={refSetter}
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={isFirst}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 140,
|
||||
alignItems: "center",
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
marginBottom: 12,
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{profileUrl ? (
|
||||
<Image
|
||||
source={{ uri: profileUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='person' size={48} color='rgba(255,255,255,0.4)' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{person.name}
|
||||
</Text>
|
||||
{person.character && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: focused
|
||||
? "rgba(255,255,255,0.7)"
|
||||
: "rgba(255,255,255,0.5)",
|
||||
textAlign: "center",
|
||||
marginTop: 4,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{person.character}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// Season card component
|
||||
interface TVSeasonCardProps {
|
||||
season: {
|
||||
id: number;
|
||||
seasonNumber: number;
|
||||
episodeCount: number;
|
||||
status: MediaStatus;
|
||||
};
|
||||
onPress: () => void;
|
||||
canRequest: boolean;
|
||||
disabled?: boolean;
|
||||
onCardFocus?: () => void;
|
||||
}
|
||||
|
||||
const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
|
||||
season,
|
||||
onPress,
|
||||
canRequest,
|
||||
disabled = false,
|
||||
onCardFocus,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
|
||||
const handleCardFocus = useCallback(() => {
|
||||
handleFocus();
|
||||
onCardFocus?.();
|
||||
}, [handleFocus, onCardFocus]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={canRequest ? onPress : undefined}
|
||||
onFocus={handleCardFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled || !canRequest}
|
||||
focusable={!disabled && canRequest}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
minWidth: 180,
|
||||
padding: 16,
|
||||
backgroundColor: focused
|
||||
? "rgba(255,255,255,0.15)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
borderRadius: 12,
|
||||
borderWidth: focused ? 2 : 1,
|
||||
borderColor: focused
|
||||
? "rgba(255,255,255,0.4)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.3 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{t("jellyseerr.season_number", {
|
||||
season_number: season.seasonNumber,
|
||||
})}
|
||||
</Text>
|
||||
<JellyseerrStatusIcon
|
||||
mediaStatus={season.status}
|
||||
showRequestIcon={canRequest}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: focused ? "rgba(255,255,255,0.8)" : "rgba(255,255,255,0.5)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.number_episodes", {
|
||||
episode_number: season.episodeCount,
|
||||
})}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const TVJellyseerrPage: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
|
||||
params as unknown as {
|
||||
mediaTitle: string;
|
||||
releaseYear: number;
|
||||
canRequest: string;
|
||||
posterSrc: string;
|
||||
mediaType: MediaType;
|
||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
const { showRequestModal } = useTVRequestModal();
|
||||
const [lastActionButtonRef, setLastActionButtonRef] = useState<View | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Scroll control ref
|
||||
const mainScrollRef = useRef<ScrollView>(null);
|
||||
const scrollPositionRef = useRef(0);
|
||||
|
||||
const {
|
||||
data: details,
|
||||
isFetching,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||
queryKey: ["jellyseerr", "detail", mediaType, result.id],
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
queryFn: async () => {
|
||||
return mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieDetails(result.id!)
|
||||
: jellyseerrApi?.tvDetails(result.id!);
|
||||
},
|
||||
});
|
||||
|
||||
const [canRequest, hasAdvancedRequestPermission] =
|
||||
useJellyseerrCanRequest(details);
|
||||
|
||||
const canManageRequests = useMemo(() => {
|
||||
if (!jellyseerrUser) return false;
|
||||
return hasPermission(
|
||||
Permission.MANAGE_REQUESTS,
|
||||
jellyseerrUser.permissions,
|
||||
);
|
||||
}, [jellyseerrUser]);
|
||||
|
||||
const pendingRequest = useMemo(() => {
|
||||
return details?.mediaInfo?.requests?.find(
|
||||
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
|
||||
);
|
||||
}, [details]);
|
||||
|
||||
// Get seasons with status for TV shows
|
||||
const seasons = useMemo(() => {
|
||||
if (!details || mediaType !== MediaType.TV) return [];
|
||||
const tvDetails = details as TvDetails;
|
||||
const mediaInfoSeasons = tvDetails.mediaInfo?.seasons?.filter(
|
||||
(s: Season) => s.seasonNumber !== 0,
|
||||
);
|
||||
const requestedSeasons =
|
||||
tvDetails.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) ??
|
||||
[];
|
||||
return (
|
||||
tvDetails.seasons?.map((season) => ({
|
||||
...season,
|
||||
status:
|
||||
mediaInfoSeasons?.find(
|
||||
(mediaSeason: Season) =>
|
||||
mediaSeason.seasonNumber === season.seasonNumber,
|
||||
)?.status ??
|
||||
requestedSeasons?.find(
|
||||
(s: Season) => s.seasonNumber === season.seasonNumber,
|
||||
)?.status ??
|
||||
MediaStatus.UNKNOWN,
|
||||
})) ?? []
|
||||
);
|
||||
}, [details, mediaType]);
|
||||
|
||||
const allSeasonsAvailable = useMemo(
|
||||
() => seasons.every((season) => season.status === MediaStatus.AVAILABLE),
|
||||
[seasons],
|
||||
);
|
||||
|
||||
// Get cast
|
||||
const cast = useMemo(() => {
|
||||
return details?.credits?.cast?.slice(0, 10) ?? [];
|
||||
}, [details]);
|
||||
|
||||
// Backdrop URL
|
||||
const backdropUrl = useMemo(() => {
|
||||
const path = details?.backdropPath || result.backdropPath;
|
||||
return path
|
||||
? jellyseerrApi?.imageProxy(path, "w1920_and_h800_multi_faces")
|
||||
: null;
|
||||
}, [details, result.backdropPath, jellyseerrApi]);
|
||||
|
||||
// Poster URL
|
||||
const posterUrl = useMemo(() => {
|
||||
if (posterSrc) return posterSrc;
|
||||
const path = details?.posterPath;
|
||||
return path ? jellyseerrApi?.imageProxy(path, "w342") : null;
|
||||
}, [posterSrc, details, jellyseerrApi]);
|
||||
|
||||
// Handlers
|
||||
const handleApproveRequest = useCallback(async () => {
|
||||
if (!pendingRequest?.id) return;
|
||||
try {
|
||||
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
||||
toast.success(t("jellyseerr.toasts.request_approved"));
|
||||
refetch();
|
||||
} catch (_error) {
|
||||
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
||||
}
|
||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||
|
||||
const handleDeclineRequest = useCallback(async () => {
|
||||
if (!pendingRequest?.id) return;
|
||||
try {
|
||||
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
||||
toast.success(t("jellyseerr.toasts.request_declined"));
|
||||
refetch();
|
||||
} catch (_error) {
|
||||
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
||||
}
|
||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||
|
||||
const handleRequest = useCallback(async () => {
|
||||
const body: MediaRequestBody = {
|
||||
mediaId: Number(result.id!),
|
||||
mediaType: mediaType!,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
...(mediaType === MediaType.TV && {
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
?.map?.((s) => s.seasonNumber),
|
||||
}),
|
||||
};
|
||||
|
||||
if (hasAdvancedRequestPermission) {
|
||||
showRequestModal({
|
||||
requestBody: body,
|
||||
title: mediaTitle,
|
||||
id: result.id!,
|
||||
mediaType: mediaType!,
|
||||
onRequested: refetch,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
requestMedia(mediaTitle, body, refetch);
|
||||
}, [
|
||||
details,
|
||||
result,
|
||||
requestMedia,
|
||||
hasAdvancedRequestPermission,
|
||||
mediaTitle,
|
||||
refetch,
|
||||
mediaType,
|
||||
showRequestModal,
|
||||
]);
|
||||
|
||||
const handleSeasonRequest = useCallback(
|
||||
(seasonNumber: number) => {
|
||||
const body: MediaRequestBody = {
|
||||
mediaId: Number(result.id!),
|
||||
mediaType: MediaType.TV,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
seasons: [seasonNumber],
|
||||
};
|
||||
|
||||
if (hasAdvancedRequestPermission) {
|
||||
showRequestModal({
|
||||
requestBody: body,
|
||||
title: mediaTitle,
|
||||
id: result.id!,
|
||||
mediaType: MediaType.TV,
|
||||
onRequested: refetch,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const seasonTitle = t("jellyseerr.season_number", {
|
||||
season_number: seasonNumber,
|
||||
});
|
||||
requestMedia(`${mediaTitle}, ${seasonTitle}`, body, refetch);
|
||||
},
|
||||
[
|
||||
details,
|
||||
result,
|
||||
hasAdvancedRequestPermission,
|
||||
requestMedia,
|
||||
mediaTitle,
|
||||
refetch,
|
||||
t,
|
||||
showRequestModal,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRequestAll = useCallback(() => {
|
||||
const body: MediaRequestBody = {
|
||||
mediaId: Number(result.id!),
|
||||
mediaType: MediaType.TV,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
seasons: seasons
|
||||
.filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
|
||||
.map((s) => s.seasonNumber),
|
||||
};
|
||||
|
||||
if (hasAdvancedRequestPermission) {
|
||||
showRequestModal({
|
||||
requestBody: body,
|
||||
title: mediaTitle,
|
||||
id: result.id!,
|
||||
mediaType: MediaType.TV,
|
||||
onRequested: refetch,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
requestMedia(`${mediaTitle}, ${t("jellyseerr.season_all")}`, body, refetch);
|
||||
}, [
|
||||
details,
|
||||
result,
|
||||
seasons,
|
||||
hasAdvancedRequestPermission,
|
||||
requestMedia,
|
||||
mediaTitle,
|
||||
refetch,
|
||||
t,
|
||||
showRequestModal,
|
||||
]);
|
||||
|
||||
// Restore scroll position when navigating within seasons section
|
||||
const handleSeasonsFocus = useCallback(() => {
|
||||
// Use requestAnimationFrame to restore scroll after TV focus engine scrolls
|
||||
requestAnimationFrame(() => {
|
||||
mainScrollRef.current?.scrollTo({
|
||||
y: scrollPositionRef.current,
|
||||
animated: false,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
const jellyfinMediaId = details?.mediaInfo?.jellyfinMediaId;
|
||||
if (!jellyfinMediaId) return;
|
||||
router.push({
|
||||
pathname:
|
||||
mediaType === MediaType.MOVIE
|
||||
? "/(auth)/(tabs)/(search)/items/page"
|
||||
: "/(auth)/(tabs)/(search)/series/[id]",
|
||||
params: { id: jellyfinMediaId },
|
||||
});
|
||||
}, [details, mediaType, router]);
|
||||
|
||||
const handleCastPress = useCallback(
|
||||
(personId: number) => {
|
||||
router.push(`/(auth)/jellyseerr/person/${personId}` as any);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const hasJellyfinMedia = !!details?.mediaInfo?.jellyfinMediaId;
|
||||
const requestedByName =
|
||||
pendingRequest?.requestedBy?.displayName ||
|
||||
pendingRequest?.requestedBy?.username ||
|
||||
pendingRequest?.requestedBy?.jellyfinUsername ||
|
||||
t("jellyseerr.unknown_user");
|
||||
|
||||
if (isLoading || isFetching) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#000",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#000" }}>
|
||||
{/* Full-screen backdrop */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
{backdropUrl ? (
|
||||
<Image
|
||||
source={{ uri: backdropUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
transition={300}
|
||||
/>
|
||||
) : (
|
||||
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
|
||||
)}
|
||||
{/* Bottom gradient */}
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||
locations={[0, 0.5, 1]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: "70%",
|
||||
}}
|
||||
/>
|
||||
{/* Left gradient */}
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,0.8)", "transparent"]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0.6, y: 0 }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "60%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Main content */}
|
||||
<ScrollView
|
||||
ref={mainScrollRef}
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + 140,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
paddingHorizontal: insets.left + 80,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
scrollEventThrottle={16}
|
||||
onScroll={(e) => {
|
||||
scrollPositionRef.current = e.nativeEvent.contentOffset.y;
|
||||
}}
|
||||
>
|
||||
{/* Top section - Poster + Content */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
minHeight: SCREEN_HEIGHT * 0.45,
|
||||
}}
|
||||
>
|
||||
{/* Left side - Poster */}
|
||||
<View
|
||||
style={{
|
||||
width: SCREEN_WIDTH * 0.22,
|
||||
marginRight: 50,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 20,
|
||||
}}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='image-outline'
|
||||
size={48}
|
||||
color='rgba(255,255,255,0.3)'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Right side - Content */}
|
||||
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||
{/* Ratings */}
|
||||
{details && (
|
||||
<JellyserrRatings
|
||||
result={
|
||||
details as MovieDetails | TvDetails | MovieResult | TvResult
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.display,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginTop: 8,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{mediaTitle}
|
||||
</Text>
|
||||
|
||||
{/* Year */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{releaseYear}
|
||||
</Text>
|
||||
|
||||
{/* Genres */}
|
||||
{details?.genres && details.genres.length > 0 && (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<GenreTags genres={details.genres.map((g) => g.name)} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Overview */}
|
||||
{(details?.overview || result.overview) && (
|
||||
<BlurView
|
||||
intensity={10}
|
||||
tint='light'
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
maxWidth: SCREEN_WIDTH * 0.45,
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
padding: 16,
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
color: "#E5E7EB",
|
||||
lineHeight: 32,
|
||||
}}
|
||||
numberOfLines={4}
|
||||
>
|
||||
{details?.overview || result.overview}
|
||||
</Text>
|
||||
</View>
|
||||
</BlurView>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{hasJellyfinMedia && (
|
||||
<TVButton
|
||||
onPress={handlePlay}
|
||||
hasTVPreferredFocus
|
||||
variant='primary'
|
||||
refSetter={!canRequest ? setLastActionButtonRef : undefined}
|
||||
>
|
||||
<Ionicons
|
||||
name='play'
|
||||
size={28}
|
||||
color='#000000'
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "bold",
|
||||
color: "#000000",
|
||||
}}
|
||||
>
|
||||
{t("common.play")}
|
||||
</Text>
|
||||
</TVButton>
|
||||
)}
|
||||
|
||||
{canRequest && (
|
||||
<TVButton
|
||||
onPress={handleRequest}
|
||||
variant='secondary'
|
||||
hasTVPreferredFocus={!hasJellyfinMedia}
|
||||
refSetter={setLastActionButtonRef}
|
||||
>
|
||||
<Ionicons
|
||||
name='add'
|
||||
size={24}
|
||||
color='#FFFFFF'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.request_button")}
|
||||
</Text>
|
||||
</TVButton>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Approve/Decline for managers */}
|
||||
{canManageRequests && pendingRequest && (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='person-outline' size={18} color='#9CA3AF' />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.requested_by", { user: requestedByName })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: "row", gap: 16 }}>
|
||||
<TVButton onPress={handleApproveRequest} variant='secondary'>
|
||||
<Ionicons
|
||||
name='checkmark'
|
||||
size={22}
|
||||
color='#FFFFFF'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.approve")}
|
||||
</Text>
|
||||
</TVButton>
|
||||
|
||||
<TVButton onPress={handleDeclineRequest} variant='secondary'>
|
||||
<Ionicons
|
||||
name='close'
|
||||
size={22}
|
||||
color='#FFFFFF'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.decline")}
|
||||
</Text>
|
||||
</TVButton>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Seasons section (TV shows only) */}
|
||||
{mediaType === MediaType.TV &&
|
||||
seasons.filter((s) => s.seasonNumber !== 0).length > 0 && (
|
||||
<View style={{ marginTop: 40, marginBottom: 32 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("item_card.seasons")}
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{ paddingVertical: 16, gap: 16 }}
|
||||
>
|
||||
{!allSeasonsAvailable && (
|
||||
<TVButton onPress={handleRequestAll} variant='secondary'>
|
||||
<Ionicons
|
||||
name='bag-add'
|
||||
size={20}
|
||||
color='#FFFFFF'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.request_all")}
|
||||
</Text>
|
||||
</TVButton>
|
||||
)}
|
||||
{orderBy(
|
||||
seasons.filter((s) => s.seasonNumber !== 0),
|
||||
"seasonNumber",
|
||||
"desc",
|
||||
).map((season) => {
|
||||
const canRequestSeason =
|
||||
season.status === MediaStatus.UNKNOWN;
|
||||
return (
|
||||
<TVSeasonCard
|
||||
key={season.id}
|
||||
season={season}
|
||||
onPress={() => handleSeasonRequest(season.seasonNumber)}
|
||||
canRequest={canRequestSeason}
|
||||
onCardFocus={handleSeasonsFocus}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Cast section */}
|
||||
{cast.length > 0 && jellyseerrApi && (
|
||||
<View style={{ marginTop: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.cast")}
|
||||
</Text>
|
||||
|
||||
{/* Focus guide for upward navigation from cast to action buttons */}
|
||||
{lastActionButtonRef && (
|
||||
<TVFocusGuideView
|
||||
destinations={[lastActionButtonRef]}
|
||||
style={{ height: 1, width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
horizontal
|
||||
data={cast}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingVertical: 16,
|
||||
gap: 28,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item, index }) => (
|
||||
<TVCastCard
|
||||
person={item}
|
||||
imageProxy={(path, size) =>
|
||||
jellyseerrApi.imageProxy(path, size || "w185")
|
||||
}
|
||||
onPress={() => handleCastPress(item.id)}
|
||||
isFirst={index === 0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
518
components/jellyseerr/tv/TVRequestModal.tsx
Normal file
518
components/jellyseerr/tv/TVRequestModal.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
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 { TVTypography } 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 { 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: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.advanced")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.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: TVTypography.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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
85
components/jellyseerr/tv/TVRequestOptionRow.tsx
Normal file
85
components/jellyseerr/tv/TVRequestOptionRow.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
|
||||
interface TVRequestOptionRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
||||
label,
|
||||
value,
|
||||
onPress,
|
||||
hasTVPreferredFocus = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({
|
||||
scaleAmount: 1.02,
|
||||
});
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: focused
|
||||
? "rgba(255,255,255,0.15)"
|
||||
: "rgba(255,255,255,0.05)",
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: focused
|
||||
? "rgba(255,255,255,0.3)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-forward'
|
||||
size={18}
|
||||
color={focused ? "#FFFFFF" : "rgba(255,255,255,0.5)"}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
115
components/jellyseerr/tv/TVToggleOptionRow.tsx
Normal file
115
components/jellyseerr/tv/TVToggleOptionRow.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import { Animated, Pressable, ScrollView, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
|
||||
interface ToggleItem {
|
||||
id: number;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
interface TVToggleChipProps {
|
||||
item: ToggleItem;
|
||||
onToggle: (id: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const TVToggleChip: React.FC<TVToggleChipProps> = ({
|
||||
item,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({
|
||||
scaleAmount: 1.08,
|
||||
});
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onToggle(item.id)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: item.selected
|
||||
? "rgba(255,255,255,0.25)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: focused
|
||||
? "#fff"
|
||||
: item.selected
|
||||
? "rgba(255,255,255,0.4)"
|
||||
: "rgba(255,255,255,0.15)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: focused ? "#000" : "#fff",
|
||||
fontWeight: item.selected || focused ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
interface TVToggleOptionRowProps {
|
||||
label: string;
|
||||
items: ToggleItem[];
|
||||
onToggle: (id: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TVToggleOptionRow: React.FC<TVToggleOptionRowProps> = ({
|
||||
label,
|
||||
items,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
}) => {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{ gap: 10, paddingVertical: 12 }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TVToggleChip
|
||||
key={item.id}
|
||||
item={item}
|
||||
onToggle={onToggle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
4
components/jellyseerr/tv/index.ts
Normal file
4
components/jellyseerr/tv/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { TVJellyseerrPage } from "./TVJellyseerrPage";
|
||||
export { TVRequestModal } from "./TVRequestModal";
|
||||
export { TVRequestOptionRow } from "./TVRequestOptionRow";
|
||||
export { TVToggleOptionRow } from "./TVToggleOptionRow";
|
||||
@@ -11,6 +11,7 @@ export interface TVButtonProps {
|
||||
style?: ViewStyle;
|
||||
scaleAmount?: number;
|
||||
square?: boolean;
|
||||
refSetter?: (ref: View | null) => void;
|
||||
}
|
||||
|
||||
const getButtonStyles = (
|
||||
@@ -31,10 +32,12 @@ const getButtonStyles = (
|
||||
};
|
||||
case "secondary":
|
||||
return {
|
||||
backgroundColor: focused ? "#7c3aed" : "rgba(124, 58, 237, 0.8)",
|
||||
shadowColor: "#a855f7",
|
||||
borderWidth: 1,
|
||||
borderColor: "transparent",
|
||||
backgroundColor: focused
|
||||
? "rgba(255, 255, 255, 0.3)"
|
||||
: "rgba(255, 255, 255, 0.15)",
|
||||
shadowColor: "#fff",
|
||||
borderWidth: 2,
|
||||
borderColor: focused ? "#fff" : "rgba(255, 255, 255, 0.2)",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
@@ -55,6 +58,7 @@ export const TVButton: React.FC<TVButtonProps> = ({
|
||||
style,
|
||||
scaleAmount = 1.05,
|
||||
square = false,
|
||||
refSetter,
|
||||
}) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount });
|
||||
@@ -63,6 +67,7 @@ export const TVButton: React.FC<TVButtonProps> = ({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={refSetter}
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
|
||||
Reference in New Issue
Block a user