diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 1b2cbbac..7a57c4d8 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -15,7 +15,6 @@ import { ItemPeopleSections } from "@/components/item/ItemPeopleSections"; import { MediaSourceButton } from "@/components/MediaSourceButton"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; -// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null; import { PlayButton } from "@/components/PlayButton"; import { PlayedStatus } from "@/components/PlayedStatus"; import { SimilarItems } from "@/components/SimilarItems"; @@ -36,6 +35,9 @@ import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { PlayInRemoteSessionButton } from "./PlayInRemoteSession"; const Chromecast = !Platform.isTV ? require("./Chromecast") : null; +const ItemContentTV = Platform.isTV + ? require("./ItemContent.tv").ItemContentTV + : null; export type SelectedOptions = { bitrate: Bitrate; @@ -49,225 +51,238 @@ interface ItemContentProps { itemWithSources?: BaseItemDto | null; } -export const ItemContent: React.FC = React.memo( - ({ item, itemWithSources }) => { - const [api] = useAtom(apiAtom); - const isOffline = useOfflineMode(); - const { settings } = useSettings(); - const { orientation } = useOrientation(); - const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - const [user] = useAtom(userAtom); +// Mobile-specific implementation +const ItemContentMobile: React.FC = ({ + item, + itemWithSources, +}) => { + const [api] = useAtom(apiAtom); + const isOffline = useOfflineMode(); + const { settings } = useSettings(); + const { orientation } = useOrientation(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const [user] = useAtom(userAtom); - const itemColors = useImageColorsReturn({ item }); + const itemColors = useImageColorsReturn({ item }); - const [loadingLogo, setLoadingLogo] = useState(true); - const [headerHeight, setHeaderHeight] = useState(350); + const [loadingLogo, setLoadingLogo] = useState(true); + const [headerHeight, setHeaderHeight] = useState(350); - const [selectedOptions, setSelectedOptions] = useState< - SelectedOptions | undefined - >(undefined); + const [selectedOptions, setSelectedOptions] = useState< + SelectedOptions | undefined + >(undefined); - // Use itemWithSources for play settings since it has MediaSources data - const { - defaultAudioIndex, - defaultBitrate, - defaultMediaSource, - defaultSubtitleIndex, - } = useDefaultPlaySettings(itemWithSources ?? item, settings); + // Use itemWithSources for play settings since it has MediaSources data + const { + defaultAudioIndex, + defaultBitrate, + defaultMediaSource, + defaultSubtitleIndex, + } = useDefaultPlaySettings(itemWithSources ?? item, settings); - const logoUrl = useMemo( - () => (item ? getLogoImageUrlById({ api, item }) : null), - [api, item], - ); + const logoUrl = useMemo( + () => (item ? getLogoImageUrlById({ api, item }) : null), + [api, item], + ); - const onLogoLoad = React.useCallback(() => { - setLoadingLogo(false); - }, []); + const onLogoLoad = React.useCallback(() => { + setLoadingLogo(false); + }, []); - const loading = useMemo(() => { - return Boolean(logoUrl && loadingLogo); - }, [loadingLogo, logoUrl]); + const loading = useMemo(() => { + return Boolean(logoUrl && loadingLogo); + }, [loadingLogo, logoUrl]); - // Needs to automatically change the selected to the default values for default indexes. - useEffect(() => { - setSelectedOptions(() => ({ - bitrate: defaultBitrate, - mediaSource: defaultMediaSource ?? undefined, - subtitleIndex: defaultSubtitleIndex ?? -1, - audioIndex: defaultAudioIndex, - })); - }, [ - defaultAudioIndex, - defaultBitrate, - defaultSubtitleIndex, - defaultMediaSource, - ]); + // Needs to automatically change the selected to the default values for default indexes. + useEffect(() => { + setSelectedOptions(() => ({ + bitrate: defaultBitrate, + mediaSource: defaultMediaSource ?? undefined, + subtitleIndex: defaultSubtitleIndex ?? -1, + audioIndex: defaultAudioIndex, + })); + }, [ + defaultAudioIndex, + defaultBitrate, + defaultSubtitleIndex, + defaultMediaSource, + ]); - useEffect(() => { - if (!Platform.isTV && itemWithSources) { - navigation.setOptions({ - headerRight: () => - item && - (Platform.OS === "ios" ? ( - - - {item.Type !== "Program" && ( - - {!Platform.isTV && ( - + useEffect(() => { + if (!Platform.isTV && itemWithSources) { + navigation.setOptions({ + headerRight: () => + item && + (Platform.OS === "ios" ? ( + + + {item.Type !== "Program" && ( + + {!Platform.isTV && ( + + )} + {user?.Policy?.IsAdministrator && + !settings.hideRemoteSessionButton && ( + )} - {user?.Policy?.IsAdministrator && - !settings.hideRemoteSessionButton && ( - - )} - - - {settings.streamyStatsServerUrl && - !settings.hideWatchlistsTab && ( - - )} - - )} - - ) : ( - - - {item.Type !== "Program" && ( - - {!Platform.isTV && ( - + + + {settings.streamyStatsServerUrl && + !settings.hideWatchlistsTab && ( + )} - {user?.Policy?.IsAdministrator && - !settings.hideRemoteSessionButton && ( - - )} - - - - {settings.streamyStatsServerUrl && - !settings.hideWatchlistsTab && ( - - )} - - )} - - )), - }); - } - }, [ - item, - navigation, - user, - itemWithSources, - settings.hideRemoteSessionButton, - settings.streamyStatsServerUrl, - settings.hideWatchlistsTab, - ]); - - useEffect(() => { - if (item) { - if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) - setHeaderHeight(230); - else if (item.Type === "Movie") setHeaderHeight(500); - else setHeaderHeight(350); - } - }, [item, orientation]); - - if (!item || !selectedOptions) return null; - - return ( - - - + + )} - } - logo={ - logoUrl ? ( - - ) : ( - - ) - } - > - - - + ) : ( + + + {item.Type !== "Program" && ( + + {!Platform.isTV && ( + + )} + {user?.Policy?.IsAdministrator && + !settings.hideRemoteSessionButton && ( + + )} - - + + {settings.streamyStatsServerUrl && + !settings.hideWatchlistsTab && ( + + )} + + )} + + )), + }); + } + }, [ + item, + navigation, + user, + itemWithSources, + settings.hideRemoteSessionButton, + settings.streamyStatsServerUrl, + settings.hideWatchlistsTab, + ]); + + useEffect(() => { + if (item) { + if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) + setHeaderHeight(230); + else if (item.Type === "Movie") setHeaderHeight(500); + else setHeaderHeight(350); + } + }, [item, orientation]); + + if (!item || !selectedOptions) return null; + + return ( + + + + + } + logo={ + logoUrl ? ( + + ) : ( + + ) + } + > + + + + + + + + {!isOffline && ( + - - {!isOffline && ( - - )} - + )} - {item.Type === "Episode" && ( - + + {item.Type === "Episode" && ( + + )} + + {!isOffline && + selectedOptions.mediaSource?.MediaStreams && + selectedOptions.mediaSource.MediaStreams.length > 0 && ( + )} - {!isOffline && - selectedOptions.mediaSource?.MediaStreams && - selectedOptions.mediaSource.MediaStreams.length > 0 && ( - + + + {item.Type !== "Program" && ( + <> + {item.Type === "Episode" && !isOffline && ( + )} - + - {item.Type !== "Program" && ( - <> - {item.Type === "Episode" && !isOffline && ( - - )} + {!isOffline && } + + )} + + + + ); +}; - +// Memoize the mobile component +const MemoizedItemContentMobile = React.memo(ItemContentMobile); - {!isOffline && } - - )} - - - - ); - }, -); +// Exported component that renders TV or mobile version based on platform +export const ItemContent: React.FC = (props) => { + if (Platform.isTV && ItemContentTV) { + return ; + } + return ; +}; diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx new file mode 100644 index 00000000..8f56f26f --- /dev/null +++ b/components/ItemContent.tv.tsx @@ -0,0 +1,637 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useAtom } from "jotai"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Dimensions, + Easing, + Pressable, + ScrollView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Badge } from "@/components/Badge"; +import { type Bitrate } from "@/components/BitrateSelector"; +import { ItemImage } from "@/components/common/ItemImage"; +import { Text } from "@/components/common/Text"; +import { GenreTags } from "@/components/GenreTags"; +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 { 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; + itemWithSources?: BaseItemDto | null; +} + +// 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} + +); + +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 _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()}`); + }; + + // Format year and duration + const year = item.ProductionYear; + const duration = item.RunTimeTicks + ? runtimeTicksToMinutes(item.RunTimeTicks) + : null; + const hasProgress = + item.UserData?.PlaybackPositionTicks && + item.UserData.PlaybackPositionTicks > 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) + const cast = item.People?.filter((p) => p.Type === "Actor")?.slice(0, 3); + + if (!item || !selectedOptions) return null; + + return ( + + {/* Full-screen backdrop */} + + + {/* Gradient overlays for readability */} + + + + + {/* Main content area */} + + {/* Top section - Logo/Title + Metadata */} + + {/* Left side - 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")} + + + + {!isOffline && item.Type !== "Program" && ( + { + // Info/More options action + }} + variant='secondary' + > + + + {t("item_card.more_info")} + + + )} + + + {/* Progress bar (if partially watched) */} + {hasProgress && item.RunTimeTicks && ( + + + + + + )} + + + + {/* Additional info section */} + + {/* Cast & Crew */} + {(director || (cast && cast.length > 0)) && ( + + + {t("item_card.cast_and_crew")} + + + {director && ( + + + {t("item_card.director")} + + + {director.Name} + + + )} + {cast && cast.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`} + + + ); + })()} + + + )} + + + + ); + }, +);