mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-20 10:08:06 +00:00
refactor(tv): migrate series season selector to navigation-based modal pattern
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user