refactor(tv): migrate series season selector to navigation-based modal pattern

This commit is contained in:
Fredrik Burmester
2026-01-19 20:01:00 +01:00
parent eeb4ef3008
commit 16a236393d
2 changed files with 24 additions and 213 deletions

View File

@@ -1,195 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { BlurView } from "expo-blur";
import React, { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
interface TVSeasonSelectorProps {
visible: boolean;
seasons: BaseItemDto[];
selectedSeasonIndex: number | string | null | undefined;
onSelect: (seasonIndex: number) => void;
onClose: () => void;
}
const TVSeasonCard: React.FC<{
season: BaseItemDto;
isSelected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ season, isSelected, hasTVPreferredFocus, onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const seasonName = useMemo(() => {
if (season.Name) return season.Name;
if (season.IndexNumber !== undefined) return `Season ${season.IndexNumber}`;
return "Season";
}, [season]);
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 180,
height: 85,
backgroundColor: focused
? "#fff"
: isSelected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 16,
}}
>
<Text
style={{
fontSize: 18,
color: focused ? "#000" : "#fff",
fontWeight: focused || isSelected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{seasonName}
</Text>
{isSelected && !focused && (
<View
style={{
position: "absolute",
top: 10,
right: 10,
}}
>
<Ionicons
name='checkmark'
size={18}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
};
export const TVSeasonSelector: React.FC<TVSeasonSelectorProps> = ({
visible,
seasons,
selectedSeasonIndex,
onSelect,
onClose,
}) => {
const { t } = useTranslation();
const initialFocusIndex = useMemo(() => {
const idx = seasons.findIndex(
(s) =>
s.IndexNumber === selectedSeasonIndex ||
s.Name === String(selectedSeasonIndex),
);
return idx >= 0 ? idx : 0;
}, [seasons, selectedSeasonIndex]);
if (!visible) return null;
return (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{t("item_card.select_season")}
</Text>
{/* Horizontal season cards */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{seasons.map((season, index) => (
<TVSeasonCard
key={season.Id || index}
season={season}
isSelected={
season.IndexNumber === selectedSeasonIndex ||
season.Name === String(selectedSeasonIndex)
}
hasTVPreferredFocus={index === initialFocusIndex}
onPress={() => {
onSelect(season.IndexNumber ?? index);
onClose();
}}
/>
))}
</ScrollView>
</View>
</BlurView>
</View>
);
};

View File

@@ -31,9 +31,9 @@ import {
TV_EPISODE_WIDTH,
TVEpisodeCard,
} from "@/components/series/TVEpisodeCard";
import { TVSeasonSelector } from "@/components/series/TVSeasonSelector";
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
import useRouter from "@/hooks/useAppRouter";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
@@ -227,9 +227,8 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
[item.Id, seasonIndexState],
);
// Modal state
const [openModal, setOpenModal] = useState<"season" | null>(null);
const isModalOpen = openModal !== null;
// TV option modal hook
const { showOptions } = useTVOptionModal();
// ScrollView ref for page scrolling
const mainScrollRef = useRef<ScrollView>(null);
@@ -400,6 +399,25 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
[item.Id, setSeasonIndexState],
);
// Open season modal
const handleOpenSeasonModal = useCallback(() => {
const options = seasons.map((season: BaseItemDto) => ({
label: season.Name || `Season ${season.IndexNumber}`,
value: season.IndexNumber ?? 0,
selected:
season.IndexNumber === selectedSeasonIndex ||
season.Name === String(selectedSeasonIndex),
}));
showOptions({
title: t("item_card.select_season"),
options,
onSelect: handleSeasonSelect,
cardWidth: 180,
cardHeight: 85,
});
}, [seasons, selectedSeasonIndex, showOptions, t, handleSeasonSelect]);
// Episode list item layout
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
@@ -417,13 +435,12 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
<TVEpisodeCard
episode={episode}
onPress={() => handleEpisodePress(episode)}
disabled={isModalOpen}
onFocus={handleEpisodeFocus}
onBlur={handleEpisodeBlur}
/>
</View>
),
[handleEpisodePress, isModalOpen, handleEpisodeFocus, handleEpisodeBlur],
[handleEpisodePress, handleEpisodeFocus, handleEpisodeBlur],
);
// Get play button text
@@ -545,7 +562,6 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
<TVFocusableButton
onPress={handlePlayNextEpisode}
hasTVPreferredFocus
disabled={isModalOpen}
variant='primary'
>
<Ionicons
@@ -568,8 +584,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
{seasons.length > 1 && (
<TVSeasonButton
seasonName={selectedSeasonName}
onPress={() => setOpenModal("season")}
disabled={isModalOpen}
onPress={handleOpenSeasonModal}
/>
)}
</View>
@@ -621,15 +636,6 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
/>
</View>
</ScrollView>
{/* Season selector modal */}
<TVSeasonSelector
visible={openModal === "season"}
seasons={seasons}
selectedSeasonIndex={selectedSeasonIndex}
onSelect={handleSeasonSelect}
onClose={() => setOpenModal(null)}
/>
</View>
);
};