From 506d8b14dc015990e47df50b78db8d6bfaef6657 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH] fix(tv): wrap actor page in scrollview to fix focus navigation between sections --- app/(auth)/tv-season-select-modal.tsx | 443 ++++++++++++++++++ app/_layout.tsx | 8 + components/jellyseerr/tv/TVJellyseerrPage.tsx | 250 +++------- components/persons/TVActorPage.tsx | 8 +- hooks/useTVSeasonSelectModal.ts | 23 + translations/en.json | 4 + utils/atoms/tvSeasonSelectModal.ts | 18 + 7 files changed, 566 insertions(+), 188 deletions(-) create mode 100644 app/(auth)/tv-season-select-modal.tsx create mode 100644 hooks/useTVSeasonSelectModal.ts create mode 100644 utils/atoms/tvSeasonSelectModal.ts diff --git a/app/(auth)/tv-season-select-modal.tsx b/app/(auth)/tv-season-select-modal.tsx new file mode 100644 index 00000000..09b46cc5 --- /dev/null +++ b/app/(auth)/tv-season-select-modal.tsx @@ -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 = ({ + 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 ( + + + {/* Checkmark for selected */} + + {selected && ( + + )} + + + {/* Season info */} + + + {t("jellyseerr.season_number", { + season_number: season.seasonNumber, + })} + + + + {t("jellyseerr.number_episodes", { + episode_number: season.episodeCount, + })} + + {statusInfo && ( + + + + )} + + + + + ); +}; + +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>( + 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 ( + + + + + {t("jellyseerr.select_seasons")} + {modalState.title} + + {/* Season cards horizontal scroll */} + + {sortedSeasons.map((season, index) => { + const canRequestSeason = season.status === MediaStatus.UNKNOWN; + return ( + handleToggleSeason(season.seasonNumber)} + canRequest={canRequestSeason} + hasTVPreferredFocus={index === firstRequestableIndex} + /> + ); + })} + + + {/* Request button */} + + + + + {t("jellyseerr.request_selected")} + {selectedSeasons.size > 0 && ` (${selectedSeasons.size})`} + + + + + + + + ); +} + +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", + }, +}); diff --git a/app/_layout.tsx b/app/_layout.tsx index 64312a80..9a377803 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -453,6 +453,14 @@ function Layout() { animation: "fade", }} /> + = ({ ); }; -// 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 = ({ - 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 ( - - - - - - {t("jellyseerr.season_number", { - season_number: season.seasonNumber, - })} - - - - - {t("jellyseerr.number_episodes", { - episode_number: season.episodeCount, - })} - - - - - ); -}; - 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(null); const [firstCastCardRef, setFirstCastCardRef] = useState(null); - // Scroll control ref - const mainScrollRef = useRef(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 */} { paddingHorizontal: insets.left + 80, }} showsVerticalScrollIndicator={false} - scrollEventThrottle={16} - onScroll={(e) => { - scrollPositionRef.current = e.nativeEvent.contentOffset.y; - }} > {/* Top section - Poster + Content */} { {/* Request All button for TV series */} {mediaType === MediaType.TV && seasons.filter((s) => s.seasonNumber !== 0).length > 0 && - !allSeasonsAvailable && ( + hasRequestableSeasons && ( { )} - {/* 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 ( - handleSeasonRequest(season.seasonNumber)} - canRequest={canRequestSeason} - onCardFocus={handleSeasonsFocus} - /> - ); - })} + seasons.filter((s) => s.seasonNumber !== 0).length > 0 && + hasRequestableSeasons && ( + + + + + {t("jellyseerr.request_seasons")} + + + + )} {/* Approve/Decline for managers */} diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx index 104c3d18..b731ab98 100644 --- a/components/persons/TVActorPage.tsx +++ b/components/persons/TVActorPage.tsx @@ -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 = ({ personId }) => { {/* Main content area */} - {/* Top section - Actor image + Info */} @@ -607,7 +611,7 @@ export const TVActorPage: React.FC = ({ personId }) => { )} - + ); }; diff --git a/hooks/useTVSeasonSelectModal.ts b/hooks/useTVSeasonSelectModal.ts new file mode 100644 index 00000000..7b2f4f20 --- /dev/null +++ b/hooks/useTVSeasonSelectModal.ts @@ -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; + +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 }; +}; diff --git a/translations/en.json b/translations/en.json index 9481f39d..2a629267 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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.", diff --git a/utils/atoms/tvSeasonSelectModal.ts b/utils/atoms/tvSeasonSelectModal.ts new file mode 100644 index 00000000..d43a13ec --- /dev/null +++ b/utils/atoms/tvSeasonSelectModal.ts @@ -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(null);