/** * Episode List for Chromecast Player * Displays list of episodes for TV shows with thumbnails */ import { Ionicons } from "@expo/vector-icons"; import type { Api } from "@jellyfin/sdk"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { Image } from "expo-image"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { FlatList, Modal, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { truncateTitle } from "@/utils/casting/helpers"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; interface ChromecastEpisodeListProps { visible: boolean; onClose: () => void; currentItem: BaseItemDto | null; episodes: BaseItemDto[]; onSelectEpisode: (episode: BaseItemDto) => void; api: Api | null; } export const ChromecastEpisodeList: React.FC = ({ visible, onClose, currentItem, episodes, onSelectEpisode, api, }) => { const insets = useSafeAreaInsets(); const { t } = useTranslation(); const flatListRef = useRef(null); const [selectedSeason, setSelectedSeason] = useState(null); const scrollRetryCountRef = useRef(0); const scrollRetryTimeoutRef = useRef(null); const MAX_SCROLL_RETRIES = 3; // Cleanup pending retry timeout on unmount useEffect(() => { return () => { if (scrollRetryTimeoutRef.current) { clearTimeout(scrollRetryTimeoutRef.current); scrollRetryTimeoutRef.current = null; } scrollRetryCountRef.current = 0; }; }, []); // Get unique seasons from episodes const seasons = useMemo(() => { const seasonSet = new Set(); for (const ep of episodes) { if (ep.ParentIndexNumber !== undefined && ep.ParentIndexNumber !== null) { seasonSet.add(ep.ParentIndexNumber); } } return Array.from(seasonSet).sort((a, b) => a - b); }, [episodes]); // Filter episodes by selected season and exclude virtual episodes const filteredEpisodes = useMemo(() => { let eps = episodes; // Filter by season if selected if (selectedSeason !== null) { eps = eps.filter((ep) => ep.ParentIndexNumber === selectedSeason); } // Filter out virtual episodes (episodes without actual video files) // LocationType === "Virtual" means the episode doesn't have a media file eps = eps.filter((ep) => ep.LocationType !== "Virtual"); return eps; }, [episodes, selectedSeason]); // Set initial season to current episode's season useEffect(() => { if (currentItem?.ParentIndexNumber !== undefined) { setSelectedSeason(currentItem.ParentIndexNumber); } }, [currentItem]); useEffect(() => { // Reset retry counter when visibility or data changes scrollRetryCountRef.current = 0; if (scrollRetryTimeoutRef.current) { clearTimeout(scrollRetryTimeoutRef.current); } if (visible && currentItem && filteredEpisodes.length > 0) { const currentIndex = filteredEpisodes.findIndex( (ep) => ep.Id === currentItem.Id, ); if (currentIndex !== -1 && flatListRef.current) { // Delay to ensure FlatList is rendered const timeoutId = setTimeout(() => { flatListRef.current?.scrollToIndex({ index: currentIndex, animated: true, viewPosition: 0.5, // Center the item }); }, 300); return () => { clearTimeout(timeoutId); if (scrollRetryTimeoutRef.current) { clearTimeout(scrollRetryTimeoutRef.current); } }; } } }, [visible, currentItem, filteredEpisodes]); const renderEpisode = ({ item }: { item: BaseItemDto }) => { const isCurrentEpisode = item.Id === currentItem?.Id; return ( { onSelectEpisode(item); onClose(); }} style={{ flexDirection: "row", padding: 12, backgroundColor: isCurrentEpisode ? "#a855f7" : "transparent", borderRadius: 8, marginBottom: 8, }} > {/* Thumbnail */} {(() => { const imageUrl = api && item.Id ? getPrimaryImageUrl({ api, item }) : null; if (imageUrl) { return ( ); } return ( ); })()} {/* Episode info */} {item.IndexNumber != null ? `${item.IndexNumber}. ` : ""} {truncateTitle(item.Name || t("casting_player.unknown"), 30)} {item.Overview && ( {item.Overview} )} {item.ParentIndexNumber !== undefined && item.IndexNumber !== undefined && ( S{String(item.ParentIndexNumber).padStart(2, "0")}:E {String(item.IndexNumber).padStart(2, "0")} )} {item.ProductionYear && ( {item.ProductionYear} )} {item.RunTimeTicks && ( {Math.round(item.RunTimeTicks / 600000000)}{" "} {t("casting_player.minutes_short")} )} {isCurrentEpisode && ( )} ); }; return ( e.stopPropagation()} > {/* Header */} 1 ? 12 : 0, }} > {t("casting_player.episodes")} {/* Season selector */} {seasons.length > 1 && ( {seasons.map((season) => ( setSelectedSeason(season)} style={{ paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, backgroundColor: selectedSeason === season ? "#a855f7" : "#1a1a1a", }} > {t("casting_player.season", { number: season })} ))} )} {/* Episode list */} item.Id || `episode-${index}`} contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 16, }} showsVerticalScrollIndicator={false} onScrollToIndexFailed={(info) => { // Bounded retry for scroll failures if ( scrollRetryCountRef.current >= MAX_SCROLL_RETRIES || info.index >= filteredEpisodes.length ) { return; } scrollRetryCountRef.current += 1; if (scrollRetryTimeoutRef.current) { clearTimeout(scrollRetryTimeoutRef.current); } scrollRetryTimeoutRef.current = setTimeout(() => { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.5, }); }, 500); }} /> ); };