mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-21 02:28:08 +00:00
fix(tv): wrap actor page in scrollview to fix focus navigation between sections
This commit is contained in:
443
app/(auth)/tv-season-select-modal.tsx
Normal file
443
app/(auth)/tv-season-select-modal.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { orderBy } from "lodash";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
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 { tvSeasonSelectModalAtom } from "@/utils/atoms/tvSeasonSelectModal";
|
||||
import {
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
interface TVSeasonToggleCardProps {
|
||||
season: {
|
||||
id: number;
|
||||
seasonNumber: number;
|
||||
episodeCount: number;
|
||||
status: MediaStatus;
|
||||
};
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
canRequest: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
|
||||
const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
||||
season,
|
||||
selected,
|
||||
onToggle,
|
||||
canRequest,
|
||||
hasTVPreferredFocus,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||
|
||||
// Get status icon and color based on MediaStatus
|
||||
const getStatusIcon = (): {
|
||||
icon: keyof typeof MaterialCommunityIcons.glyphMap;
|
||||
color: string;
|
||||
} | null => {
|
||||
switch (season.status) {
|
||||
case MediaStatus.PROCESSING:
|
||||
return { icon: "clock", color: "#6366f1" };
|
||||
case MediaStatus.AVAILABLE:
|
||||
return { icon: "check", color: "#22c55e" };
|
||||
case MediaStatus.PENDING:
|
||||
return { icon: "bell", color: "#eab308" };
|
||||
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||
return { icon: "minus", color: "#22c55e" };
|
||||
case MediaStatus.BLACKLISTED:
|
||||
return { icon: "eye-off", color: "#ef4444" };
|
||||
default:
|
||||
return canRequest ? { icon: "plus", color: "#22c55e" } : null;
|
||||
}
|
||||
};
|
||||
|
||||
const statusInfo = getStatusIcon();
|
||||
const isDisabled = !canRequest;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={canRequest ? onToggle : undefined}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={isDisabled}
|
||||
focusable={!isDisabled}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
styles.seasonCard,
|
||||
{
|
||||
backgroundColor: focused
|
||||
? "#FFFFFF"
|
||||
: selected
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
borderWidth: focused ? 0 : 1,
|
||||
borderColor: selected
|
||||
? "rgba(255,255,255,0.4)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Checkmark for selected */}
|
||||
<View style={styles.checkmarkContainer}>
|
||||
{selected && (
|
||||
<Ionicons
|
||||
name='checkmark-circle'
|
||||
size={24}
|
||||
color={focused ? "#22c55e" : "#FFFFFF"}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Season info */}
|
||||
<View style={styles.seasonInfo}>
|
||||
<Text
|
||||
style={[
|
||||
styles.seasonTitle,
|
||||
{ color: focused ? "#000000" : "#FFFFFF" },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{t("jellyseerr.season_number", {
|
||||
season_number: season.seasonNumber,
|
||||
})}
|
||||
</Text>
|
||||
<View style={styles.episodeRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.episodeCount,
|
||||
{
|
||||
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{t("jellyseerr.number_episodes", {
|
||||
episode_number: season.episodeCount,
|
||||
})}
|
||||
</Text>
|
||||
{statusInfo && (
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: statusInfo.color },
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={statusInfo.icon}
|
||||
size={14}
|
||||
color='#FFFFFF'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default function TVSeasonSelectModalPage() {
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvSeasonSelectModalAtom);
|
||||
const { t } = useTranslation();
|
||||
const { requestMedia } = useJellyseerr();
|
||||
const { showRequestModal } = useTVRequestModal();
|
||||
|
||||
// Selected seasons - initially select all requestable (UNKNOWN status) seasons
|
||||
const [selectedSeasons, setSelectedSeasons] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||
|
||||
// Initialize selected seasons when modal state changes
|
||||
useEffect(() => {
|
||||
if (modalState?.seasons) {
|
||||
const requestableSeasons = modalState.seasons
|
||||
.filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
|
||||
.map((s) => s.seasonNumber);
|
||||
setSelectedSeasons(new Set(requestableSeasons));
|
||||
}
|
||||
}, [modalState?.seasons]);
|
||||
|
||||
// Animate in on mount
|
||||
useEffect(() => {
|
||||
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();
|
||||
|
||||
return () => {
|
||||
store.set(tvSeasonSelectModalAtom, null);
|
||||
};
|
||||
}, [overlayOpacity, sheetTranslateY]);
|
||||
|
||||
// Sort seasons by season number (ascending)
|
||||
const sortedSeasons = useMemo(() => {
|
||||
if (!modalState?.seasons) return [];
|
||||
return orderBy(
|
||||
modalState.seasons.filter((s) => s.seasonNumber !== 0),
|
||||
"seasonNumber",
|
||||
"asc",
|
||||
);
|
||||
}, [modalState?.seasons]);
|
||||
|
||||
// Find the index of the first requestable season for initial focus
|
||||
const firstRequestableIndex = useMemo(() => {
|
||||
return sortedSeasons.findIndex((s) => s.status === MediaStatus.UNKNOWN);
|
||||
}, [sortedSeasons]);
|
||||
|
||||
const handleToggleSeason = useCallback((seasonNumber: number) => {
|
||||
setSelectedSeasons((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(seasonNumber)) {
|
||||
newSet.delete(seasonNumber);
|
||||
} else {
|
||||
newSet.add(seasonNumber);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRequestSelected = useCallback(() => {
|
||||
if (!modalState || selectedSeasons.size === 0) return;
|
||||
|
||||
const seasonsArray = Array.from(selectedSeasons);
|
||||
const body: MediaRequestBody = {
|
||||
mediaId: modalState.mediaId,
|
||||
mediaType: MediaType.TV,
|
||||
tvdbId: modalState.tvdbId,
|
||||
seasons: seasonsArray,
|
||||
};
|
||||
|
||||
if (modalState.hasAdvancedRequestPermission) {
|
||||
// Close this modal and open the advanced request modal
|
||||
router.back();
|
||||
showRequestModal({
|
||||
requestBody: body,
|
||||
title: modalState.title,
|
||||
id: modalState.mediaId,
|
||||
mediaType: MediaType.TV,
|
||||
onRequested: modalState.onRequested,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the title based on selected seasons
|
||||
const seasonTitle =
|
||||
seasonsArray.length === 1
|
||||
? t("jellyseerr.season_number", { season_number: seasonsArray[0] })
|
||||
: seasonsArray.length === sortedSeasons.length
|
||||
? t("jellyseerr.season_all")
|
||||
: t("jellyseerr.n_selected", { count: seasonsArray.length });
|
||||
|
||||
requestMedia(`${modalState.title}, ${seasonTitle}`, body, () => {
|
||||
modalState.onRequested();
|
||||
router.back();
|
||||
});
|
||||
}, [
|
||||
modalState,
|
||||
selectedSeasons,
|
||||
sortedSeasons.length,
|
||||
requestMedia,
|
||||
router,
|
||||
t,
|
||||
showRequestModal,
|
||||
]);
|
||||
|
||||
if (!modalState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
<Text style={styles.heading}>{t("jellyseerr.select_seasons")}</Text>
|
||||
<Text style={styles.subtitle}>{modalState.title}</Text>
|
||||
|
||||
{/* Season cards horizontal scroll */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{sortedSeasons.map((season, index) => {
|
||||
const canRequestSeason = season.status === MediaStatus.UNKNOWN;
|
||||
return (
|
||||
<TVSeasonToggleCard
|
||||
key={season.id}
|
||||
season={season}
|
||||
selected={selectedSeasons.has(season.seasonNumber)}
|
||||
onToggle={() => handleToggleSeason(season.seasonNumber)}
|
||||
canRequest={canRequestSeason}
|
||||
hasTVPreferredFocus={index === firstRequestableIndex}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{/* Request button */}
|
||||
<View style={styles.buttonContainer}>
|
||||
<TVButton
|
||||
onPress={handleRequestSelected}
|
||||
variant='secondary'
|
||||
disabled={selectedSeasons.size === 0}
|
||||
>
|
||||
<Ionicons
|
||||
name='add'
|
||||
size={22}
|
||||
color='#FFFFFF'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={styles.buttonText}>
|
||||
{t("jellyseerr.request_selected")}
|
||||
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
|
||||
</Text>
|
||||
</TVButton>
|
||||
</View>
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
sheetContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
paddingHorizontal: 44,
|
||||
overflow: "visible",
|
||||
},
|
||||
heading: {
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: TVTypography.callout,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 24,
|
||||
},
|
||||
scrollView: {
|
||||
overflow: "visible",
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 4,
|
||||
gap: 16,
|
||||
},
|
||||
seasonCard: {
|
||||
width: 160,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
checkmarkContainer: {
|
||||
height: 24,
|
||||
marginBottom: 8,
|
||||
},
|
||||
seasonInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
seasonTitle: {
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
},
|
||||
episodeRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
episodeCount: {
|
||||
fontSize: 14,
|
||||
},
|
||||
statusBadge: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
buttonContainer: {
|
||||
marginTop: 24,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
});
|
||||
@@ -453,6 +453,14 @@ function Layout() {
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-season-select-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
|
||||
@@ -4,8 +4,7 @@ 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 React, { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
@@ -19,7 +18,6 @@ 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";
|
||||
@@ -28,6 +26,7 @@ import { TVTypography } 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,
|
||||
@@ -158,114 +157,6 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Season card component
|
||||
interface TVSeasonCardProps {
|
||||
season: {
|
||||
id: number;
|
||||
seasonNumber: number;
|
||||
episodeCount: number;
|
||||
status: MediaStatus;
|
||||
};
|
||||
onPress: () => void;
|
||||
canRequest: boolean;
|
||||
disabled?: boolean;
|
||||
onCardFocus?: () => void;
|
||||
refSetter?: (ref: View | null) => void;
|
||||
}
|
||||
|
||||
const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
|
||||
season,
|
||||
onPress,
|
||||
canRequest,
|
||||
disabled = false,
|
||||
onCardFocus,
|
||||
refSetter,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
|
||||
const handleCardFocus = useCallback(() => {
|
||||
handleFocus();
|
||||
onCardFocus?.();
|
||||
}, [handleFocus, onCardFocus]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={refSetter}
|
||||
onPress={canRequest ? onPress : undefined}
|
||||
onFocus={handleCardFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled || !canRequest}
|
||||
focusable={!disabled && canRequest}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
minWidth: 180,
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 32,
|
||||
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={{ height: 40, justifyContent: "center" }}>
|
||||
<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>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const TVJellyseerrPage: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
@@ -283,15 +174,12 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
|
||||
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);
|
||||
|
||||
// Scroll control ref
|
||||
const mainScrollRef = useRef<ScrollView>(null);
|
||||
const scrollPositionRef = useRef(0);
|
||||
|
||||
const {
|
||||
data: details,
|
||||
isFetching,
|
||||
@@ -352,11 +240,21 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
);
|
||||
}, [details, mediaType]);
|
||||
|
||||
const allSeasonsAvailable = useMemo(
|
||||
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) ?? [];
|
||||
@@ -435,43 +333,6 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
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!),
|
||||
@@ -506,16 +367,24 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
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 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;
|
||||
@@ -610,7 +479,6 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
|
||||
{/* Main content */}
|
||||
<ScrollView
|
||||
ref={mainScrollRef}
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + 140,
|
||||
@@ -618,10 +486,6 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
paddingHorizontal: insets.left + 80,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
scrollEventThrottle={16}
|
||||
onScroll={(e) => {
|
||||
scrollPositionRef.current = e.nativeEvent.contentOffset.y;
|
||||
}}
|
||||
>
|
||||
{/* Top section - Poster + Content */}
|
||||
<View
|
||||
@@ -811,7 +675,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
{/* Request All button for TV series */}
|
||||
{mediaType === MediaType.TV &&
|
||||
seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
|
||||
!allSeasonsAvailable && (
|
||||
hasRequestableSeasons && (
|
||||
<TVButton
|
||||
onPress={handleRequestAll}
|
||||
variant='secondary'
|
||||
@@ -844,25 +708,39 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
</TVButton>
|
||||
)}
|
||||
|
||||
{/* Individual season cards for TV series */}
|
||||
{/* Request Seasons button for TV series */}
|
||||
{mediaType === MediaType.TV &&
|
||||
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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
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: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.request_seasons")}
|
||||
</Text>
|
||||
</View>
|
||||
</TVButton>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Approve/Decline for managers */}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Easing,
|
||||
FlatList,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -400,11 +401,14 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
||||
</View>
|
||||
|
||||
{/* Main content area */}
|
||||
<View
|
||||
<ScrollView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||
paddingBottom: 60,
|
||||
}}
|
||||
>
|
||||
{/* Top section - Actor image + Info */}
|
||||
@@ -607,7 +611,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
23
hooks/useTVSeasonSelectModal.ts
Normal file
23
hooks/useTVSeasonSelectModal.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
type TVSeasonSelectModalState,
|
||||
tvSeasonSelectModalAtom,
|
||||
} from "@/utils/atoms/tvSeasonSelectModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
type ShowSeasonSelectModalParams = NonNullable<TVSeasonSelectModalState>;
|
||||
|
||||
export const useTVSeasonSelectModal = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const showSeasonSelectModal = useCallback(
|
||||
(params: ShowSeasonSelectModalParams) => {
|
||||
store.set(tvSeasonSelectModalAtom, params);
|
||||
router.push("/(auth)/tv-season-select-modal");
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return { showSeasonSelectModal };
|
||||
};
|
||||
@@ -756,6 +756,10 @@
|
||||
"unknown_user": "Unknown User",
|
||||
"select": "Select",
|
||||
"request_all": "Request All",
|
||||
"request_seasons": "Request Seasons",
|
||||
"select_seasons": "Select Seasons",
|
||||
"request_selected": "Request Selected",
|
||||
"n_selected": "{{count}} selected",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
||||
|
||||
18
utils/atoms/tvSeasonSelectModal.ts
Normal file
18
utils/atoms/tvSeasonSelectModal.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { atom } from "jotai";
|
||||
import type { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||
|
||||
export type TVSeasonSelectModalState = {
|
||||
seasons: Array<{
|
||||
id: number;
|
||||
seasonNumber: number;
|
||||
episodeCount: number;
|
||||
status: MediaStatus;
|
||||
}>;
|
||||
title: string;
|
||||
mediaId: number;
|
||||
tvdbId?: number;
|
||||
hasAdvancedRequestPermission: boolean;
|
||||
onRequested: () => void;
|
||||
} | null;
|
||||
|
||||
export const tvSeasonSelectModalAtom = atom<TVSeasonSelectModalState>(null);
|
||||
Reference in New Issue
Block a user