import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; import { useQueryClient } from "@tanstack/react-query"; import { BlurView } from "expo-blur"; import { Image } from "expo-image"; import { LinearGradient } from "expo-linear-gradient"; import { useAtom } from "jotai"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import { Animated, BackHandler, Dimensions, Easing, Platform, Pressable, ScrollView, TVFocusGuideView, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Badge } from "@/components/Badge"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import { ItemImage } from "@/components/common/ItemImage"; import { Text } from "@/components/common/Text"; import { GenreTags } from "@/components/GenreTags"; import { TVSubtitleSheet } from "@/components/video-player/controls/TVSubtitleSheet"; import useRouter from "@/hooks/useAppRouter"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; import { runtimeTicksToMinutes } from "@/utils/time"; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); export type SelectedOptions = { bitrate: Bitrate; mediaSource: MediaSourceInfo | undefined; audioIndex: number | undefined; subtitleIndex: number; }; interface ItemContentTVProps { item?: BaseItemDto | null; itemWithSources?: BaseItemDto | null; isLoading?: boolean; } // Focusable button component for TV with Apple TV-style animations const TVFocusableButton: React.FC<{ onPress: () => void; children: React.ReactNode; hasTVPreferredFocus?: boolean; style?: any; variant?: "primary" | "secondary"; }> = ({ onPress, children, hasTVPreferredFocus, style, variant = "primary", }) => { 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 isPrimary = variant === "primary"; return ( { setFocused(true); animateTo(1.05); }} onBlur={() => { setFocused(false); animateTo(1); }} hasTVPreferredFocus={hasTVPreferredFocus} > {children} ); }; // Info row component for metadata display const _InfoRow: React.FC<{ label: string; value: string }> = ({ label, value, }) => ( {label} {value} ); // Option item for the TV selector modal type TVOptionItem = { label: string; value: T; selected: boolean; }; // TV Option Selector (Modal style - saved as backup) const _TVOptionSelectorModal = ({ visible, title, options, onSelect, onClose, }: { visible: boolean; title: string; options: TVOptionItem[]; onSelect: (value: T) => void; onClose: () => void; }) => { // Find the initially selected index const initialSelectedIndex = useMemo(() => { const idx = options.findIndex((o) => o.selected); return idx >= 0 ? idx : 0; }, [options]); if (!visible) return null; return ( {/* Header */} {title} {/* Options list */} {options.map((option, index) => ( <_TVOptionRowModal key={index} label={option.label} selected={option.selected} hasTVPreferredFocus={index === initialSelectedIndex} onPress={() => { onSelect(option.value); onClose(); }} /> ))} ); }; // Individual option row in the modal selector (backup) const _TVOptionRowModal: React.FC<{ label: string; selected: boolean; hasTVPreferredFocus?: boolean; onPress: () => void; }> = ({ label, selected, 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: 120, easing: Easing.out(Easing.quad), useNativeDriver: true, }).start(); return ( { setFocused(true); animateTo(1.02); }} onBlur={() => { setFocused(false); animateTo(1); }} hasTVPreferredFocus={hasTVPreferredFocus} style={{ marginBottom: 2 }} > {selected && } {label} ); }; // TV Option Selector - Bottom sheet with horizontal scrolling (Apple TV style) const TVOptionSelector = ({ visible, title, options, onSelect, onClose, }: { visible: boolean; title: string; options: TVOptionItem[]; onSelect: (value: T) => void; onClose: () => void; }) => { const [isReady, setIsReady] = useState(false); const firstCardRef = useRef(null); // Animation values const overlayOpacity = useRef(new Animated.Value(0)).current; const sheetTranslateY = useRef(new Animated.Value(200)).current; const initialSelectedIndex = useMemo(() => { const idx = options.findIndex((o) => o.selected); return idx >= 0 ? idx : 0; }, [options]); // Animate in when visible useEffect(() => { if (visible) { // Reset values and animate in 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(); } }, [visible, overlayOpacity, sheetTranslateY]); // Delay rendering to work around hasTVPreferredFocus timing issue useEffect(() => { if (visible) { const timer = setTimeout(() => setIsReady(true), 100); return () => clearTimeout(timer); } setIsReady(false); }, [visible]); // Programmatic focus fallback useEffect(() => { if (isReady && firstCardRef.current) { const timer = setTimeout(() => { (firstCardRef.current as any)?.requestTVFocus?.(); }, 50); return () => clearTimeout(timer); } }, [isReady]); if (!visible) return null; return ( {/* Title */} {title} {/* Horizontal options */} {isReady && ( {options.map((option, index) => ( { onSelect(option.value); onClose(); }} /> ))} )} ); }; // Option card for horizontal selector (Apple TV style) - with forwardRef for programmatic focus const TVOptionCard = React.forwardRef< View, { label: string; selected: boolean; hasTVPreferredFocus?: boolean; onPress: () => void; } >(({ label, selected, hasTVPreferredFocus, onPress }, ref) => { 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(); return ( { setFocused(true); animateTo(1.05); }} onBlur={() => { setFocused(false); animateTo(1); }} hasTVPreferredFocus={hasTVPreferredFocus} > {label} {selected && !focused && ( )} ); }); // Circular actor card with Apple TV style focus animations const TVActorCard = React.forwardRef< View, { person: { Id?: string | null; Name?: string | null; Role?: string | null; }; apiBasePath?: string; onPress: () => void; hasTVPreferredFocus?: boolean; } >(({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => { 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 imageUrl = person.Id ? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=200&fillHeight=200&quality=90` : null; return ( { setFocused(true); animateTo(1.08); }} onBlur={() => { setFocused(false); animateTo(1); }} hasTVPreferredFocus={hasTVPreferredFocus} > {/* Circular image */} {imageUrl ? ( ) : ( )} {/* Name */} {person.Name} {/* Role */} {person.Role && ( {person.Role} )} ); }); // Series/Season poster card with Apple TV style focus animations const TVSeriesSeasonCard: React.FC<{ title: string; subtitle?: string; imageUrl: string | null; onPress: () => void; hasTVPreferredFocus?: boolean; }> = ({ title, subtitle, imageUrl, onPress, hasTVPreferredFocus }) => { 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(); return ( { setFocused(true); animateTo(1.05); }} onBlur={() => { setFocused(false); animateTo(1); }} hasTVPreferredFocus={hasTVPreferredFocus} > {/* Poster image */} {imageUrl ? ( ) : ( )} {/* Title */} {title} {/* Subtitle */} {subtitle && ( {subtitle} )} ); }; // Button to open option selector const TVOptionButton = React.forwardRef< View, { label: string; value: string; onPress: () => void; hasTVPreferredFocus?: boolean; } >(({ label, value, onPress, hasTVPreferredFocus }, ref) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; const animateTo = (v: number) => Animated.timing(scale, { toValue: v, duration: 120, easing: Easing.out(Easing.quad), useNativeDriver: true, }).start(); return ( { setFocused(true); animateTo(1.02); }} onBlur={() => { setFocused(false); animateTo(1); }} hasTVPreferredFocus={hasTVPreferredFocus} > {label} {value} ); }); // Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports) export const ItemContentTV: React.FC = React.memo( ({ item, itemWithSources }) => { const [api] = useAtom(apiAtom); const [_user] = useAtom(userAtom); const isOffline = useOfflineMode(); const { settings } = useSettings(); const insets = useSafeAreaInsets(); const router = useRouter(); const { t } = useTranslation(); const queryClient = useQueryClient(); const _itemColors = useImageColorsReturn({ item }); const [selectedOptions, setSelectedOptions] = useState< SelectedOptions | undefined >(undefined); const { defaultAudioIndex, defaultBitrate, defaultMediaSource, defaultSubtitleIndex, } = useDefaultPlaySettings(itemWithSources ?? item, settings); const logoUrl = useMemo( () => (item ? getLogoImageUrlById({ api, item }) : null), [api, item], ); // Set default play options useEffect(() => { setSelectedOptions(() => ({ bitrate: defaultBitrate, mediaSource: defaultMediaSource ?? undefined, subtitleIndex: defaultSubtitleIndex ?? -1, audioIndex: defaultAudioIndex, })); }, [ defaultAudioIndex, defaultBitrate, defaultSubtitleIndex, defaultMediaSource, ]); const handlePlay = () => { if (!item || !selectedOptions) return; const queryParams = new URLSearchParams({ itemId: item.Id!, audioIndex: selectedOptions.audioIndex?.toString() ?? "", subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0", offline: isOffline ? "true" : "false", }); router.push(`/player/direct-player?${queryParams.toString()}`); }; // Modal state for option selectors type ModalType = "audio" | "subtitle" | "mediaSource" | "quality" | null; const [openModal, setOpenModal] = useState(null); const isModalOpen = openModal !== null; // State for first actor card ref (used for focus guide) // Using state instead of useRef to trigger re-renders when ref is set const [firstActorCardRef, setFirstActorCardRef] = useState( null, ); // State for last option button ref (used for upward focus guide from cast) const [lastOptionButtonRef, setLastOptionButtonRef] = useState( null, ); // Android TV BackHandler for closing modals useEffect(() => { if (Platform.OS === "android" && isModalOpen) { const backHandler = BackHandler.addEventListener( "hardwareBackPress", () => { setOpenModal(null); return true; }, ); return () => backHandler.remove(); } }, [isModalOpen]); // Get available audio tracks const audioTracks = useMemo(() => { const streams = selectedOptions?.mediaSource?.MediaStreams?.filter( (s) => s.Type === "Audio", ); return streams ?? []; }, [selectedOptions?.mediaSource]); // Get available subtitle tracks const subtitleTracks = useMemo(() => { const streams = selectedOptions?.mediaSource?.MediaStreams?.filter( (s) => s.Type === "Subtitle", ); return streams ?? []; }, [selectedOptions?.mediaSource]); // Get available media sources const mediaSources = useMemo(() => { return (itemWithSources ?? item)?.MediaSources ?? []; }, [item, itemWithSources]); // Audio options for selector const audioOptions = useMemo(() => { return audioTracks.map((track) => ({ label: track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`, value: track.Index!, selected: track.Index === selectedOptions?.audioIndex, })); }, [audioTracks, selectedOptions?.audioIndex]); // Media source options for selector const mediaSourceOptions = useMemo(() => { return mediaSources.map((source) => { const videoStream = source.MediaStreams?.find( (s) => s.Type === "Video", ); const displayName = videoStream?.DisplayTitle || source.Name || `Source ${source.Id}`; return { label: displayName, value: source, selected: source.Id === selectedOptions?.mediaSource?.Id, }; }); }, [mediaSources, selectedOptions?.mediaSource?.Id]); // Quality/bitrate options for selector const qualityOptions = useMemo(() => { return BITRATES.map((bitrate) => ({ label: bitrate.key, value: bitrate, selected: bitrate.value === selectedOptions?.bitrate?.value, })); }, [selectedOptions?.bitrate?.value]); // Handlers for option changes const handleAudioChange = useCallback((audioIndex: number) => { setSelectedOptions((prev) => prev ? { ...prev, audioIndex } : undefined, ); }, []); const handleSubtitleChange = useCallback((subtitleIndex: number) => { setSelectedOptions((prev) => prev ? { ...prev, subtitleIndex } : undefined, ); }, []); const handleMediaSourceChange = useCallback( (mediaSource: MediaSourceInfo) => { // When media source changes, reset audio/subtitle to defaults const defaultAudio = mediaSource.MediaStreams?.find( (s) => s.Type === "Audio" && s.IsDefault, ); const defaultSubtitle = mediaSource.MediaStreams?.find( (s) => s.Type === "Subtitle" && s.IsDefault, ); setSelectedOptions((prev) => prev ? { ...prev, mediaSource, audioIndex: defaultAudio?.Index ?? prev.audioIndex, subtitleIndex: defaultSubtitle?.Index ?? -1, } : undefined, ); }, [], ); const handleQualityChange = useCallback((bitrate: Bitrate) => { setSelectedOptions((prev) => (prev ? { ...prev, bitrate } : undefined)); }, []); // Handle server-side subtitle download - invalidate queries to refresh tracks const handleServerSubtitleDownloaded = useCallback(() => { if (item?.Id) { queryClient.invalidateQueries({ queryKey: ["item", item.Id] }); } }, [item?.Id, queryClient]); // Get display values for buttons const selectedAudioLabel = useMemo(() => { const track = audioTracks.find( (t) => t.Index === selectedOptions?.audioIndex, ); return track?.DisplayTitle || track?.Language || t("item_card.audio"); }, [audioTracks, selectedOptions?.audioIndex, t]); const selectedSubtitleLabel = useMemo(() => { if (selectedOptions?.subtitleIndex === -1) return t("item_card.subtitles.none"); const track = subtitleTracks.find( (t) => t.Index === selectedOptions?.subtitleIndex, ); return ( track?.DisplayTitle || track?.Language || t("item_card.subtitles.label") ); }, [subtitleTracks, selectedOptions?.subtitleIndex, t]); const selectedMediaSourceLabel = useMemo(() => { const source = selectedOptions?.mediaSource; if (!source) return t("item_card.video"); const videoStream = source.MediaStreams?.find((s) => s.Type === "Video"); return videoStream?.DisplayTitle || source.Name || t("item_card.video"); }, [selectedOptions?.mediaSource, t]); const selectedQualityLabel = useMemo(() => { return selectedOptions?.bitrate?.key || t("item_card.quality"); }, [selectedOptions?.bitrate?.key, t]); // Format year and duration const year = item?.ProductionYear; const duration = item?.RunTimeTicks ? runtimeTicksToMinutes(item.RunTimeTicks) : null; const hasProgress = (item?.UserData?.PlaybackPositionTicks ?? 0) > 0; const remainingTime = hasProgress ? runtimeTicksToMinutes( (item?.RunTimeTicks || 0) - (item?.UserData?.PlaybackPositionTicks || 0), ) : null; // Get director const director = item?.People?.find((p) => p.Type === "Director"); // Get cast (first 3 for text display) const cast = item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 3); // Get full cast for visual display (up to 10 actors) const fullCast = useMemo(() => { return ( item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 10) ?? [] ); }, [item?.People]); // Series/Season image URLs for episodes const seriesImageUrl = useMemo(() => { if (item?.Type !== "Episode" || !item.SeriesId) return null; return getPrimaryImageUrlById({ api, id: item.SeriesId, width: 300 }); }, [api, item?.Type, item?.SeriesId]); const seasonImageUrl = useMemo(() => { if (item?.Type !== "Episode") return null; const seasonId = item.SeasonId || item.ParentId; if (!seasonId) return null; return getPrimaryImageUrlById({ api, id: seasonId, width: 300 }); }, [api, item?.Type, item?.SeasonId, item?.ParentId]); // Determine which option button is the last one (for focus guide targeting) const lastOptionButton = useMemo(() => { const hasSubtitleOption = subtitleTracks.length > 0 || selectedOptions?.subtitleIndex !== undefined; const hasAudioOption = audioTracks.length > 0; const hasMediaSourceOption = mediaSources.length > 1; if (hasSubtitleOption) return "subtitle"; if (hasAudioOption) return "audio"; if (hasMediaSourceOption) return "mediaSource"; return "quality"; }, [ subtitleTracks.length, selectedOptions?.subtitleIndex, audioTracks.length, mediaSources.length, ]); if (!item || !selectedOptions) return null; return ( {/* Full-screen backdrop */} {/* Gradient overlays for readability */} {/* Main content area */} {/* Top section - Logo/Title + Metadata */} {/* Left side - Back button + Poster */} {/* Poster */} {/* Right side - Content */} {/* Logo or Title */} {logoUrl ? ( ) : ( {item.Name} )} {/* Episode info for TV shows */} {item.Type === "Episode" && ( {item.SeriesName} S{item.ParentIndexNumber} E{item.IndexNumber} ยท {item.Name} )} {/* Metadata badges row */} {year != null && ( {year} )} {duration && ( {duration} )} {item.OfficialRating && ( )} {item.CommunityRating != null && ( } /> )} {/* Genres */} {item.Genres && item.Genres.length > 0 && ( )} {/* Overview */} {item.Overview && ( {item.Overview} )} {/* Action buttons */} {hasProgress ? `${remainingTime} ${t("item_card.left")}` : t("common.play")} {/* Playback options */} {/* Quality selector */} setOpenModal("quality")} /> {/* Media source selector (only if multiple sources) */} {mediaSources.length > 1 && ( setOpenModal("mediaSource")} /> )} {/* Audio selector */} {audioTracks.length > 0 && ( setOpenModal("audio")} /> )} {/* Subtitle selector */} {(subtitleTracks.length > 0 || selectedOptions?.subtitleIndex !== undefined) && ( setOpenModal("subtitle")} /> )} {/* Focus guide to direct navigation from options to cast list */} {fullCast.length > 0 && firstActorCardRef && ( )} {/* Progress bar (if partially watched) */} {hasProgress && item.RunTimeTicks != null && ( )} {/* Additional info section */} {/* Cast & Crew */} {(director || (cast && cast.length > 0)) && ( {t("item_card.cast_and_crew")} {director && ( {t("item_card.director")} {director.Name} )} {/* Only show text cast if visual cast section won't be shown */} {cast && cast.length > 0 && !( (item.Type === "Movie" || item.Type === "Series" || item.Type === "Episode") && fullCast.length > 0 ) && ( {t("item_card.cast")} {cast.map((c) => c.Name).join(", ")} )} )} {/* Technical details */} {selectedOptions.mediaSource?.MediaStreams && selectedOptions.mediaSource.MediaStreams.length > 0 && ( {t("item_card.technical_details")} {/* Video info */} {(() => { const videoStream = selectedOptions.mediaSource?.MediaStreams?.find( (s) => s.Type === "Video", ); if (!videoStream) return null; return ( Video {videoStream.DisplayTitle || `${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`} ); })()} {/* Audio info */} {(() => { const audioStream = selectedOptions.mediaSource?.MediaStreams?.find( (s) => s.Type === "Audio", ); if (!audioStream) return null; return ( Audio {audioStream.DisplayTitle || `${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`} ); })()} )} {/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */} {(item.Type === "Movie" || item.Type === "Series" || item.Type === "Episode") && fullCast.length > 0 && ( {t("item_card.cast")} {/* Focus guide to direct upward navigation from cast back to options */} {lastOptionButtonRef && ( )} {fullCast.map((person, index) => ( { if (person.Id) { router.push(`/(auth)/persons/${person.Id}`); } }} /> ))} )} {/* From this Series - Episode only */} {item.Type === "Episode" && item.SeriesId && ( {t("item_card.from_this_series") || "From this Series"} {/* Series card */} { router.push(`/(auth)/series/${item.SeriesId}`); }} hasTVPreferredFocus={false} /> {/* Season card */} {(item.SeasonId || item.ParentId) && ( { router.push( `/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`, ); }} /> )} )} {/* Option selector modals */} setOpenModal(null)} /> setOpenModal(null)} /> setOpenModal(null)} /> {/* Unified Subtitle Sheet (tracks + download) */} {item && ( setOpenModal(null)} onServerSubtitleDownloaded={handleServerSubtitleDownloaded} /> )} ); }, ); // Alias for platform-resolved imports (tvOS auto-resolves .tv.tsx files) export const ItemContent = ItemContentTV;