import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto, MediaSourceInfo, MediaStream, } from "@jellyfin/sdk/lib/generated-client/models"; import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { BlurView } from "expo-blur"; import { Image } from "expo-image"; import { useAtom } from "jotai"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import { Dimensions, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import { ItemImage } from "@/components/common/ItemImage"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { GenreTags } from "@/components/GenreTags"; import { TVEpisodeCard } from "@/components/series/TVEpisodeCard"; import { TVBackdrop, TVButton, TVCastCrewText, TVCastSection, TVFavoriteButton, TVMetadataBadges, TVOptionButton, TVProgressBar, TVRefreshButton, TVSeriesNavigation, TVTechnicalDetails, } from "@/components/tv"; import type { Track } from "@/components/video-player/controls/types"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; 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; } // Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports) export const ItemContentTV: React.FC = React.memo( ({ item, itemWithSources }) => { const typography = useScaledTVTypography(); 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 }); // State for first episode card ref (used for focus guide) const [_firstEpisodeRef, setFirstEpisodeRef] = useState(null); // Fetch season episodes for episodes const { data: seasonEpisodes = [] } = useQuery({ queryKey: ["episodes", item?.SeasonId], queryFn: async () => { if (!api || !user?.Id || !item?.SeriesId || !item?.SeasonId) return []; const res = await getTvShowsApi(api).getEpisodes({ seriesId: item.SeriesId, userId: user.Id, seasonId: item.SeasonId, enableUserData: true, fields: ["MediaSources", "Overview"], }); return res.data.Items || []; }, enabled: !!api && !!user?.Id && !!item?.SeriesId && !!item?.SeasonId && item?.Type === "Episode", }); 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()}`); }; // TV Option Modal hook for quality, audio, media source selectors const { showOptions } = useTVOptionModal(); // TV Subtitle Modal hook const { showSubtitleModal } = useTVSubtitleModal(); // State for first actor card ref (used for focus guide) const [_firstActorCardRef, setFirstActorCardRef] = useState( null, ); // State for last option button ref (used for upward focus guide from cast) const [_lastOptionButtonRef, setLastOptionButtonRef] = useState(null); // 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 (raw MediaStream[] for label lookup) const subtitleStreams = useMemo(() => { const streams = selectedOptions?.mediaSource?.MediaStreams?.filter( (s) => s.Type === "Subtitle", ); return streams ?? []; }, [selectedOptions?.mediaSource]); // Store handleSubtitleChange in a ref for stable callback reference const handleSubtitleChangeRef = useRef<((index: number) => void) | null>( null, ); // Convert MediaStream[] to Track[] for the modal (with setTrack callbacks) const subtitleTracksForModal = useMemo((): Track[] => { return subtitleStreams.map((stream) => ({ name: stream.DisplayTitle || `${stream.Language || "Unknown"} (${stream.Codec})`, index: stream.Index ?? -1, setTrack: () => { handleSubtitleChangeRef.current?.(stream.Index ?? -1); }, })); }, [subtitleStreams]); // Get available media sources const mediaSources = useMemo(() => { return (itemWithSources ?? item)?.MediaSources ?? []; }, [item, itemWithSources]); // Audio options for selector const audioOptions: TVOptionItem[] = 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: TVOptionItem[] = 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: TVOptionItem[] = 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, ); }, []); // Keep the ref updated with the latest callback handleSubtitleChangeRef.current = handleSubtitleChange; const handleMediaSourceChange = useCallback( (mediaSource: MediaSourceInfo) => { 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]); // Refresh subtitle tracks by fetching fresh item data from Jellyfin const refreshSubtitleTracks = useCallback(async (): Promise => { if (!api || !item?.Id) return []; try { // Fetch fresh item data with media sources const response = await getUserLibraryApi(api).getItem({ itemId: item.Id, }); const freshItem = response.data; const mediaSourceId = selectedOptions?.mediaSource?.Id; // Find the matching media source const mediaSource = mediaSourceId ? freshItem.MediaSources?.find( (s: MediaSourceInfo) => s.Id === mediaSourceId, ) : freshItem.MediaSources?.[0]; // Get subtitle streams from the fresh data const streams = mediaSource?.MediaStreams?.filter( (s: MediaStream) => s.Type === "Subtitle", ) ?? []; // Convert to Track[] with setTrack callbacks return streams.map((stream) => ({ name: stream.DisplayTitle || `${stream.Language || "Unknown"} (${stream.Codec})`, index: stream.Index ?? -1, setTrack: () => { handleSubtitleChangeRef.current?.(stream.Index ?? -1); }, })); } catch (error) { console.error("Failed to refresh subtitle tracks:", error); return []; } }, [api, item?.Id, selectedOptions?.mediaSource?.Id]); // 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 = subtitleStreams.find( (t) => t.Index === selectedOptions?.subtitleIndex, ); return ( track?.DisplayTitle || track?.Language || t("item_card.subtitles.label") ); }, [subtitleStreams, 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]); // Whether to show visual cast section const showVisualCast = (item?.Type === "Movie" || item?.Type === "Series" || item?.Type === "Episode") && fullCast.length > 0; // 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]); // Episode thumbnail URL - episode's own primary image (16:9 for episodes) const episodeThumbnailUrl = useMemo(() => { if (item?.Type !== "Episode" || !api) return null; return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; }, [api, item]); // Series thumb URL - used when showSeriesPosterOnEpisode setting is enabled const seriesThumbUrl = useMemo(() => { if (item?.Type !== "Episode" || !item.SeriesId || !api) return null; return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`; }, [api, item]); // Determine which option button is the last one (for focus guide targeting) const lastOptionButton = useMemo(() => { const hasSubtitleOption = subtitleStreams.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"; }, [ subtitleStreams.length, selectedOptions?.subtitleIndex, audioTracks.length, mediaSources.length, ]); // Navigation handlers const handleActorPress = useCallback( (personId: string) => { router.push(`/(auth)/persons/${personId}`); }, [router], ); const handleSeriesPress = useCallback(() => { if (item?.SeriesId) { router.push(`/(auth)/series/${item.SeriesId}`); } }, [router, item?.SeriesId]); const handleSeasonPress = useCallback(() => { if (item?.SeriesId && item?.ParentIndexNumber) { router.push( `/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`, ); } }, [router, item?.SeriesId, item?.ParentIndexNumber]); const handleEpisodePress = useCallback( (episode: BaseItemDto) => { const navigation = getItemNavigation(episode, "(home)"); router.push(navigation as any); }, [router], ); if (!item || !selectedOptions) return null; return ( {/* Full-screen backdrop */} {/* Main content area */} {/* Top section - Logo/Title + Metadata */} {/* Left 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 */} {/* 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 */} showOptions({ title: t("item_card.quality"), options: qualityOptions, onSelect: handleQualityChange, }) } /> {/* Media source selector (only if multiple sources) */} {mediaSources.length > 1 && ( showOptions({ title: t("item_card.video"), options: mediaSourceOptions, onSelect: handleMediaSourceChange, }) } /> )} {/* Audio selector */} {audioTracks.length > 0 && ( showOptions({ title: t("item_card.audio"), options: audioOptions, onSelect: handleAudioChange, }) } /> )} {/* Subtitle selector */} {(subtitleStreams.length > 0 || selectedOptions?.subtitleIndex !== undefined) && ( showSubtitleModal({ item, mediaSourceId: selectedOptions?.mediaSource?.Id, subtitleTracks: subtitleTracksForModal, currentSubtitleIndex: selectedOptions?.subtitleIndex ?? -1, onDisableSubtitles: () => handleSubtitleChange(-1), onServerSubtitleDownloaded: handleServerSubtitleDownloaded, refreshSubtitleTracks, }) } /> )} {/* Progress bar (if partially watched) */} {hasProgress && item.RunTimeTicks != null && ( )} {/* Right side - Poster */} {item.Type === "Episode" ? ( ) : ( )} {/* Additional info section */} {/* Season Episodes - Episode only */} {item.Type === "Episode" && seasonEpisodes.length > 1 && ( {t("item_card.more_from_this_season")} {seasonEpisodes.map((episode, index) => ( handleEpisodePress(episode)} disabled={episode.Id === item.Id} refSetter={index === 0 ? setFirstEpisodeRef : undefined} /> ))} )} {/* From this Series - Episode only */} {/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */} {showVisualCast && ( )} {/* Cast & Crew (text version - director, etc.) */} {/* Technical details */} {selectedOptions.mediaSource?.MediaStreams && selectedOptions.mediaSource.MediaStreams.length > 0 && ( )} ); }, ); // Alias for platform-resolved imports (tvOS auto-resolves .tv.tsx files) export const ItemContent = ItemContentTV;