mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-23 11:38:03 +00:00
fix(tv): season modal using correct modal
This commit is contained in:
188
app/(auth)/tv-series-season-modal.tsx
Normal file
188
app/(auth)/tv-series-season-modal.tsx
Normal file
@@ -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<View>(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 (
|
||||
<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.title}>{t("item_card.select_season")}</Text>
|
||||
|
||||
{isReady && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{modalState.seasons.map((season, index) => (
|
||||
<TVOptionCard
|
||||
key={season.value}
|
||||
ref={
|
||||
index === initialSelectedIndex ? firstCardRef : undefined
|
||||
}
|
||||
label={season.label}
|
||||
selected={season.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||
onPress={() => handleSelect(season.value)}
|
||||
width={180}
|
||||
height={85}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{isReady && (
|
||||
<View style={styles.cancelButtonContainer}>
|
||||
<TVCancelButton
|
||||
onPress={handleCancel}
|
||||
label={t("common.cancel")}
|
||||
/>
|
||||
</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,
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -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<TVSeriesPageProps> = ({
|
||||
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<TVSeriesPageProps> = ({
|
||||
[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<View | null>(null);
|
||||
const [firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
|
||||
@@ -405,16 +406,6 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
[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<TVSeriesPageProps> = ({
|
||||
}));
|
||||
}, [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<TVSeriesPageProps> = ({
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Season selector modal */}
|
||||
<TVOptionSelector
|
||||
visible={isSeasonModalVisible}
|
||||
title={t("item_card.select_season")}
|
||||
options={seasonOptions}
|
||||
onSelect={handleSeasonSelect}
|
||||
onClose={handleCloseSeasonModal}
|
||||
cancelLabel={t("common.cancel")}
|
||||
cardWidth={180}
|
||||
cardHeight={85}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
34
hooks/useTVSeriesSeasonModal.ts
Normal file
34
hooks/useTVSeriesSeasonModal.ts
Normal file
@@ -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 };
|
||||
};
|
||||
14
utils/atoms/tvSeriesSeasonModal.ts
Normal file
14
utils/atoms/tvSeriesSeasonModal.ts
Normal file
@@ -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<TVSeriesSeasonModalState>(null);
|
||||
Reference in New Issue
Block a user