From 26e848938423ae30f9cc6d27ea1114bf19986f80 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 22 Jan 2026 08:37:35 +0100 Subject: [PATCH] fix(tv): season modal using correct modal --- app/(auth)/tv-series-season-modal.tsx | 188 ++++++++++++++++++++++++++ components/series/TVSeriesPage.tsx | 50 ++++--- hooks/useTVSeriesSeasonModal.ts | 34 +++++ utils/atoms/tvSeriesSeasonModal.ts | 14 ++ 4 files changed, 259 insertions(+), 27 deletions(-) create mode 100644 app/(auth)/tv-series-season-modal.tsx create mode 100644 hooks/useTVSeriesSeasonModal.ts create mode 100644 utils/atoms/tvSeriesSeasonModal.ts diff --git a/app/(auth)/tv-series-season-modal.tsx b/app/(auth)/tv-series-season-modal.tsx new file mode 100644 index 00000000..05b9ca8c --- /dev/null +++ b/app/(auth)/tv-series-season-modal.tsx @@ -0,0 +1,188 @@ +import { BlurView } from "expo-blur"; +import { useAtomValue } from "jotai"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Easing, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVCancelButton, TVOptionCard } from "@/components/tv"; +import { TVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal"; +import { store } from "@/utils/store"; + +export default function TVSeriesSeasonModalPage() { + const router = useRouter(); + const modalState = useAtomValue(tvSeriesSeasonModalAtom); + const { t } = useTranslation(); + + const [isReady, setIsReady] = useState(false); + const firstCardRef = useRef(null); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + const initialSelectedIndex = useMemo(() => { + if (!modalState?.seasons) return 0; + const idx = modalState.seasons.findIndex((o) => o.selected); + return idx >= 0 ? idx : 0; + }, [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(); + + const timer = setTimeout(() => setIsReady(true), 100); + return () => { + clearTimeout(timer); + store.set(tvSeriesSeasonModalAtom, null); + }; + }, [overlayOpacity, sheetTranslateY]); + + // Focus on the selected card when ready + useEffect(() => { + if (isReady && firstCardRef.current) { + const timer = setTimeout(() => { + (firstCardRef.current as any)?.requestTVFocus?.(); + }, 50); + return () => clearTimeout(timer); + } + }, [isReady]); + + const handleSelect = (seasonIndex: number) => { + if (modalState?.onSeasonSelect) { + modalState.onSeasonSelect(seasonIndex); + } + router.back(); + }; + + const handleCancel = () => { + router.back(); + }; + + if (!modalState) { + return null; + } + + return ( + + + + + {t("item_card.select_season")} + + {isReady && ( + + {modalState.seasons.map((season, index) => ( + handleSelect(season.value)} + width={180} + height={85} + /> + ))} + + )} + + {isReady && ( + + + + )} + + + + + ); +} + +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, + overflow: "visible", + }, + title: { + fontSize: TVTypography.callout, + fontWeight: "500", + color: "rgba(255,255,255,0.6)", + marginBottom: 16, + paddingHorizontal: 48, + textTransform: "uppercase", + letterSpacing: 1, + }, + scrollView: { + overflow: "visible", + }, + scrollContent: { + paddingHorizontal: 48, + paddingVertical: 20, + gap: 12, + }, + cancelButtonContainer: { + marginTop: 16, + paddingHorizontal: 48, + alignItems: "flex-start", + }, +}); diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index e11a0684..69c8148d 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -4,7 +4,7 @@ import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { LinearGradient } from "expo-linear-gradient"; import { useSegments } from "expo-router"; -import { useAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import React, { useCallback, useEffect, @@ -29,12 +29,13 @@ import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { seasonIndexAtom } from "@/components/series/SeasonPicker"; import { TVEpisodeCard } from "@/components/series/TVEpisodeCard"; import { TVSeriesHeader } from "@/components/series/TVSeriesHeader"; -import { TVOptionSelector } from "@/components/tv/TVOptionSelector"; import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; +import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal"; import { buildOfflineSeasons, getDownloadedEpisodesForSeason, @@ -221,6 +222,9 @@ export const TVSeriesPage: React.FC = ({ const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { getDownloadedItems, downloadedItems } = useDownload(); + const { showSeasonModal } = useTVSeriesSeasonModal(); + const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom); + const isSeasonModalVisible = seasonModalState !== null; // Season state const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); @@ -229,9 +233,6 @@ export const TVSeriesPage: React.FC = ({ [item.Id, seasonIndexState], ); - // Season selector modal state - const [isSeasonModalVisible, setIsSeasonModalVisible] = useState(false); - // Focus guide refs (using useState to trigger re-renders when refs are set) const [playButtonRef, setPlayButtonRef] = useState(null); const [firstEpisodeRef, setFirstEpisodeRef] = useState(null); @@ -405,16 +406,6 @@ export const TVSeriesPage: React.FC = ({ [item.Id, setSeasonIndexState], ); - // Open season modal - const handleOpenSeasonModal = useCallback(() => { - setIsSeasonModalVisible(true); - }, []); - - // Close season modal - const handleCloseSeasonModal = useCallback(() => { - setIsSeasonModalVisible(false); - }, []); - // Season options for the modal const seasonOptions = useMemo(() => { return seasons.map((season: BaseItemDto) => ({ @@ -426,6 +417,23 @@ export const TVSeriesPage: React.FC = ({ })); }, [seasons, selectedSeasonIndex]); + // Open season modal + const handleOpenSeasonModal = useCallback(() => { + if (!item.Id) return; + showSeasonModal({ + seasons: seasonOptions, + selectedSeasonIndex, + itemId: item.Id, + onSeasonSelect: handleSeasonSelect, + }); + }, [ + item.Id, + seasonOptions, + selectedSeasonIndex, + handleSeasonSelect, + showSeasonModal, + ]); + // Get play button text const playButtonText = useMemo(() => { if (!nextUnwatchedEpisode) return t("common.play"); @@ -646,18 +654,6 @@ export const TVSeriesPage: React.FC = ({ - - {/* Season selector modal */} - ); }; diff --git a/hooks/useTVSeriesSeasonModal.ts b/hooks/useTVSeriesSeasonModal.ts new file mode 100644 index 00000000..dcd5d478 --- /dev/null +++ b/hooks/useTVSeriesSeasonModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal"; +import { store } from "@/utils/store"; + +interface ShowSeasonModalParams { + seasons: Array<{ + label: string; + value: number; + selected: boolean; + }>; + selectedSeasonIndex: number | string; + itemId: string; + onSeasonSelect: (seasonIndex: number) => void; +} + +export const useTVSeriesSeasonModal = () => { + const router = useRouter(); + + const showSeasonModal = useCallback( + (params: ShowSeasonModalParams) => { + store.set(tvSeriesSeasonModalAtom, { + seasons: params.seasons, + selectedSeasonIndex: params.selectedSeasonIndex, + itemId: params.itemId, + onSeasonSelect: params.onSeasonSelect, + }); + router.push("/(auth)/tv-series-season-modal"); + }, + [router], + ); + + return { showSeasonModal }; +}; diff --git a/utils/atoms/tvSeriesSeasonModal.ts b/utils/atoms/tvSeriesSeasonModal.ts new file mode 100644 index 00000000..99e92193 --- /dev/null +++ b/utils/atoms/tvSeriesSeasonModal.ts @@ -0,0 +1,14 @@ +import { atom } from "jotai"; + +export type TVSeriesSeasonModalState = { + seasons: Array<{ + label: string; + value: number; + selected: boolean; + }>; + selectedSeasonIndex: number | string; + itemId: string; + onSeasonSelect: (seasonIndex: number) => void; +} | null; + +export const tvSeriesSeasonModalAtom = atom(null);