mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 20:18:29 +01:00
878 lines
26 KiB
TypeScript
878 lines
26 KiB
TypeScript
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 React, { useCallback, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Animated,
|
|
Dimensions,
|
|
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 { Loader } from "@/components/Loader";
|
|
import { JellyserrRatings } from "@/components/Ratings";
|
|
import { TVButton } from "@/components/tv";
|
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
import useRouter from "@/hooks/useAppRouter";
|
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
|
import { useTVSeasonSelectModal } from "@/hooks/useTVSeasonSelectModal";
|
|
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;
|
|
refSetter?: (ref: View | null) => void;
|
|
}
|
|
|
|
const TVCastCard: React.FC<TVCastCardProps> = ({
|
|
person,
|
|
imageProxy,
|
|
onPress,
|
|
refSetter,
|
|
}) => {
|
|
const typography = useScaledTVTypography();
|
|
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}
|
|
>
|
|
<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: typography.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>
|
|
);
|
|
};
|
|
|
|
export const TVJellyseerrPage: React.FC = () => {
|
|
const typography = useScaledTVTypography();
|
|
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 { showSeasonSelectModal } = useTVSeasonSelectModal();
|
|
|
|
// Refs for TVFocusGuideView destinations (useState triggers re-render when set)
|
|
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
|
|
const [firstCastCardRef, setFirstCastCardRef] = useState<View | null>(null);
|
|
|
|
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],
|
|
);
|
|
|
|
// Check if there are any requestable seasons (status === UNKNOWN)
|
|
const hasRequestableSeasons = useMemo(
|
|
() =>
|
|
seasons.some(
|
|
(season) =>
|
|
season.seasonNumber !== 0 && season.status === MediaStatus.UNKNOWN,
|
|
),
|
|
[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 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,
|
|
]);
|
|
|
|
const handleOpenSeasonSelectModal = useCallback(() => {
|
|
showSeasonSelectModal({
|
|
seasons: seasons.filter((s) => s.seasonNumber !== 0),
|
|
title: mediaTitle,
|
|
mediaId: Number(result.id!),
|
|
tvdbId: details?.externalIds?.tvdbId,
|
|
hasAdvancedRequestPermission,
|
|
onRequested: refetch,
|
|
});
|
|
}, [
|
|
seasons,
|
|
mediaTitle,
|
|
result,
|
|
details,
|
|
hasAdvancedRequestPermission,
|
|
refetch,
|
|
showSeasonSelectModal,
|
|
]);
|
|
|
|
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
|
|
style={{ flex: 1 }}
|
|
contentContainerStyle={{
|
|
paddingTop: insets.top + 140,
|
|
paddingBottom: insets.bottom + 60,
|
|
paddingHorizontal: insets.left + 80,
|
|
}}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* 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: typography.display,
|
|
fontWeight: "bold",
|
|
color: "#FFFFFF",
|
|
marginTop: 8,
|
|
marginBottom: 12,
|
|
}}
|
|
numberOfLines={2}
|
|
>
|
|
{mediaTitle}
|
|
</Text>
|
|
|
|
{/* Year */}
|
|
<Text
|
|
style={{
|
|
fontSize: typography.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: typography.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={setPlayButtonRef}
|
|
>
|
|
<Ionicons
|
|
name='play'
|
|
size={28}
|
|
color='#000000'
|
|
style={{ marginRight: 10 }}
|
|
/>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.callout,
|
|
fontWeight: "bold",
|
|
color: "#000000",
|
|
}}
|
|
>
|
|
{t("common.play")}
|
|
</Text>
|
|
</TVButton>
|
|
)}
|
|
|
|
{/* Request button - only show for movies, TV series use Request All + season cards */}
|
|
{canRequest && mediaType === MediaType.MOVIE && (
|
|
<TVButton
|
|
onPress={handleRequest}
|
|
variant='secondary'
|
|
hasTVPreferredFocus={!hasJellyfinMedia}
|
|
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
|
|
scaleAmount={1.01}
|
|
>
|
|
<Ionicons
|
|
name='add'
|
|
size={24}
|
|
color='#FFFFFF'
|
|
style={{ marginRight: 8 }}
|
|
/>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.callout,
|
|
fontWeight: "bold",
|
|
color: "#FFFFFF",
|
|
}}
|
|
>
|
|
{t("jellyseerr.request_button")}
|
|
</Text>
|
|
</TVButton>
|
|
)}
|
|
|
|
{/* Request All button for TV series */}
|
|
{mediaType === MediaType.TV &&
|
|
seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
|
|
hasRequestableSeasons && (
|
|
<TVButton
|
|
onPress={handleRequestAll}
|
|
variant='secondary'
|
|
hasTVPreferredFocus={!hasJellyfinMedia}
|
|
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
|
|
>
|
|
<View
|
|
style={{
|
|
height: 40,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name='bag-add'
|
|
size={20}
|
|
color='#FFFFFF'
|
|
style={{ marginRight: 8 }}
|
|
/>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.callout,
|
|
fontWeight: "600",
|
|
color: "#FFFFFF",
|
|
}}
|
|
>
|
|
{t("jellyseerr.request_all")}
|
|
</Text>
|
|
</View>
|
|
</TVButton>
|
|
)}
|
|
|
|
{/* Request Seasons button for TV series */}
|
|
{mediaType === MediaType.TV &&
|
|
seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
|
|
hasRequestableSeasons && (
|
|
<TVButton
|
|
onPress={handleOpenSeasonSelectModal}
|
|
variant='secondary'
|
|
>
|
|
<View
|
|
style={{
|
|
height: 40,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name='list'
|
|
size={20}
|
|
color='#FFFFFF'
|
|
style={{ marginRight: 8 }}
|
|
/>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.callout,
|
|
fontWeight: "600",
|
|
color: "#FFFFFF",
|
|
}}
|
|
>
|
|
{t("jellyseerr.request_seasons")}
|
|
</Text>
|
|
</View>
|
|
</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: typography.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: typography.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: typography.callout,
|
|
fontWeight: "600",
|
|
color: "#FFFFFF",
|
|
}}
|
|
>
|
|
{t("jellyseerr.decline")}
|
|
</Text>
|
|
</TVButton>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Cast section */}
|
|
{cast.length > 0 && jellyseerrApi && (
|
|
<View style={{ marginTop: 24 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.heading,
|
|
fontWeight: "bold",
|
|
color: "#FFFFFF",
|
|
marginBottom: 16,
|
|
}}
|
|
>
|
|
{t("jellyseerr.cast")}
|
|
</Text>
|
|
|
|
{/* Focus guides for bidirectional navigation - stacked together */}
|
|
{/* Downward: action buttons → first cast card */}
|
|
{firstCastCardRef && (
|
|
<TVFocusGuideView
|
|
destinations={[firstCastCardRef]}
|
|
style={{
|
|
height: 1,
|
|
width: SCREEN_WIDTH,
|
|
marginLeft: -(insets.left + 80),
|
|
}}
|
|
/>
|
|
)}
|
|
{/* Upward: cast → action buttons */}
|
|
{playButtonRef && (
|
|
<TVFocusGuideView
|
|
destinations={[playButtonRef]}
|
|
style={{
|
|
height: 1,
|
|
width: SCREEN_WIDTH,
|
|
marginLeft: -(insets.left + 80),
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={{ overflow: "visible" }}
|
|
contentContainerStyle={{
|
|
paddingVertical: 16,
|
|
gap: 28,
|
|
}}
|
|
>
|
|
{cast.map((person, index) => (
|
|
<TVCastCard
|
|
key={person.id}
|
|
person={person}
|
|
imageProxy={(path, size) =>
|
|
jellyseerrApi.imageProxy(path, size || "w185")
|
|
}
|
|
onPress={() => handleCastPress(person.id)}
|
|
refSetter={index === 0 ? setFirstCastCardRef : undefined}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
};
|