import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; import { useCallback, useEffect, useRef, useState } from "react"; import { FlatList, View, type ViewProps } from "react-native"; import ContinueWatchingPoster, { TV_LANDSCAPE_WIDTH, } from "@/components/ContinueWatchingPoster.tv"; import { Text } from "@/components/common/Text"; import MoviePoster, { TV_POSTER_WIDTH, } from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; import { useScaledTVTypography } from "@/constants/TVTypography"; const ITEM_GAP = 16; const SCALE_PADDING = 20; // TV-specific ItemCardText with larger fonts const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { const typography = useScaledTVTypography(); return ( {item.Type === "Episode" ? ( <> {item.Name} {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} {" - "} {item.SeriesName} ) : item.Type === "MusicArtist" ? ( {item.Name} ) : item.Type === "MusicAlbum" ? ( <> {item.Name} {item.AlbumArtist || item.Artists?.join(", ")} ) : item.Type === "Audio" ? ( <> {item.Name} {item.Artists?.join(", ") || item.AlbumArtist} ) : item.Type === "Playlist" ? ( <> {item.Name} {item.ChildCount} tracks ) : item.Type === "Person" ? ( {item.Name} ) : ( <> {item.Name} {item.ProductionYear} )} ); }; interface TVSearchSectionProps extends ViewProps { title: string; items: BaseItemDto[]; orientation?: "horizontal" | "vertical"; disabled?: boolean; isFirstSection?: boolean; onItemPress: (item: BaseItemDto) => void; imageUrlGetter?: (item: BaseItemDto) => string | undefined; } export const TVSearchSection: React.FC = ({ title, items, orientation = "vertical", disabled = false, isFirstSection = false, onItemPress, imageUrlGetter, ...props }) => { const typography = useScaledTVTypography(); const flatListRef = useRef>(null); const [focusedCount, setFocusedCount] = useState(0); const prevFocusedCount = useRef(0); // When section loses all focus, scroll back to start useEffect(() => { if (prevFocusedCount.current > 0 && focusedCount === 0) { flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); } prevFocusedCount.current = focusedCount; }, [focusedCount]); const handleItemFocus = useCallback(() => { setFocusedCount((c) => c + 1); }, []); const handleItemBlur = useCallback(() => { setFocusedCount((c) => Math.max(0, c - 1)); }, []); const itemWidth = orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH; const getItemLayout = useCallback( (_data: ArrayLike | null | undefined, index: number) => ({ length: itemWidth + ITEM_GAP, offset: (itemWidth + ITEM_GAP) * index, index, }), [itemWidth], ); const renderItem = useCallback( ({ item, index }: { item: BaseItemDto; index: number }) => { const isFirstItem = isFirstSection && index === 0; const isHorizontal = orientation === "horizontal"; const renderPoster = () => { // Music Artist - circular avatar if (item.Type === "MusicArtist") { const imageUrl = imageUrlGetter?.(item); return ( {imageUrl ? ( ) : ( 👤 )} ); } // Music Album, Audio, Playlist - square images if ( item.Type === "MusicAlbum" || item.Type === "Audio" || item.Type === "Playlist" ) { const imageUrl = imageUrlGetter?.(item); const icon = item.Type === "Playlist" ? "🎶" : item.Type === "Audio" ? "🎵" : "🎵"; return ( {imageUrl ? ( ) : ( {icon} )} ); } // Person (Actor) if (item.Type === "Person") { return ; } // Episode rendering if (item.Type === "Episode" && isHorizontal) { return ; } if (item.Type === "Episode" && !isHorizontal) { return ; } // Movie rendering if (item.Type === "Movie" && isHorizontal) { return ; } if (item.Type === "Movie" && !isHorizontal) { return ; } // Series rendering if (item.Type === "Series" && !isHorizontal) { return ; } if (item.Type === "Series" && isHorizontal) { return ; } // BoxSet (Collection) if (item.Type === "BoxSet" && !isHorizontal) { return ; } if (item.Type === "BoxSet" && isHorizontal) { return ; } // Default fallback return isHorizontal ? ( ) : ( ); }; // Special width for music artists (circular) const actualItemWidth = item.Type === "MusicArtist" ? 160 : itemWidth; return ( onItemPress(item)} hasTVPreferredFocus={isFirstItem && !disabled} onFocus={handleItemFocus} onBlur={handleItemBlur} disabled={disabled} > {renderPoster()} ); }, [ orientation, isFirstSection, itemWidth, onItemPress, handleItemFocus, handleItemBlur, disabled, imageUrlGetter, ], ); if (!items || items.length === 0) return null; return ( {/* Section Header */} {title} item.Id!} renderItem={renderItem} showsHorizontalScrollIndicator={false} initialNumToRender={5} maxToRenderPerBatch={3} windowSize={5} removeClippedSubviews={false} getItemLayout={getItemLayout} style={{ overflow: "visible" }} contentContainerStyle={{ paddingVertical: SCALE_PADDING, paddingHorizontal: SCALE_PADDING, }} /> ); };