import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi, getTvShowsApi, getUserLibraryApi, } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { LinearGradient } from "expo-linear-gradient"; import { useRouter } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Pressable, TouchableOpacity, useWindowDimensions, View, } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { Easing, interpolate, runOnJS, type SharedValue, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { ItemImage } from "../common/ItemImage"; import { getItemNavigation } from "../common/TouchableItemRouter"; import type { SelectedOptions } from "../ItemContent"; import { PlayButton } from "../PlayButton"; import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton"; interface AppleTVCarouselProps { initialIndex?: number; onItemChange?: (index: number) => void; scrollOffset?: SharedValue; } // Layout Constants const GRADIENT_HEIGHT_TOP = 150; const GRADIENT_HEIGHT_BOTTOM = 150; const LOGO_HEIGHT = 80; // Position Constants const LOGO_BOTTOM_POSITION = 260; const GENRES_BOTTOM_POSITION = 220; const OVERVIEW_BOTTOM_POSITION = 165; const CONTROLS_BOTTOM_POSITION = 80; const DOTS_BOTTOM_POSITION = 40; // Size Constants const DOT_HEIGHT = 6; const DOT_ACTIVE_WIDTH = 20; const DOT_INACTIVE_WIDTH = 12; const PLAY_BUTTON_SKELETON_HEIGHT = 50; const PLAYED_STATUS_SKELETON_SIZE = 40; const TEXT_SKELETON_HEIGHT = 20; const TEXT_SKELETON_WIDTH = 250; const OVERVIEW_SKELETON_HEIGHT = 16; const OVERVIEW_SKELETON_WIDTH = 400; const _EMPTY_STATE_ICON_SIZE = 64; // Spacing Constants const HORIZONTAL_PADDING = 40; const DOT_PADDING = 2; const DOT_GAP = 4; const CONTROLS_GAP = 10; const _TEXT_MARGIN_TOP = 16; // Border Radius Constants const DOT_BORDER_RADIUS = 3; const LOGO_SKELETON_BORDER_RADIUS = 8; const TEXT_SKELETON_BORDER_RADIUS = 4; const PLAY_BUTTON_BORDER_RADIUS = 25; const PLAYED_STATUS_BORDER_RADIUS = 20; // Animation Constants const DOT_ANIMATION_DURATION = 300; const CAROUSEL_TRANSITION_DURATION = 250; const PAN_ACTIVE_OFFSET = 10; const TRANSLATION_THRESHOLD = 0.2; const VELOCITY_THRESHOLD = 400; // Text Constants const GENRES_FONT_SIZE = 16; const OVERVIEW_FONT_SIZE = 14; const _EMPTY_STATE_FONT_SIZE = 18; const TEXT_SHADOW_RADIUS = 2; const MAX_GENRES_COUNT = 2; const MAX_BUTTON_WIDTH = 300; const OVERVIEW_MAX_LINES = 2; const OVERVIEW_MAX_WIDTH = "80%"; // Opacity Constants const OVERLAY_OPACITY = 0.3; const DOT_INACTIVE_OPACITY = 0.6; const TEXT_OPACITY = 0.9; // Color Constants const SKELETON_BACKGROUND_COLOR = "#1a1a1a"; const SKELETON_ELEMENT_COLOR = "#333"; const SKELETON_ACTIVE_DOT_COLOR = "#666"; const _EMPTY_STATE_COLOR = "#666"; const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)"; const LOGO_WIDTH_PERCENTAGE = "80%"; const DotIndicator = ({ index, currentIndex, onPress, }: { index: number; currentIndex: number; onPress: (index: number) => void; }) => { const isActive = index === currentIndex; const animatedStyle = useAnimatedStyle(() => ({ width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, { duration: DOT_ANIMATION_DURATION, easing: Easing.out(Easing.quad), }), opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, { duration: DOT_ANIMATION_DURATION, easing: Easing.out(Easing.quad), }), })); return ( onPress(index)} style={{ padding: DOT_PADDING, // Increase touch area }} > ); }; export const AppleTVCarousel: React.FC = ({ initialIndex = 0, onItemChange, scrollOffset, }) => { const { settings } = useSettings(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { isConnected, serverConnected } = useNetworkStatus(); const router = useRouter(); const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const isLandscape = screenWidth >= screenHeight; const carouselHeight = useMemo( () => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45), [isLandscape, screenHeight], ); const [currentIndex, setCurrentIndex] = useState(initialIndex); const translateX = useSharedValue(-initialIndex * screenWidth); const isQueryEnabled = !!api && !!user?.Id && isConnected && serverConnected === true; const { data: continueWatchingData, isLoading: continueWatchingLoading } = useQuery({ queryKey: ["appleTVCarousel", "continueWatching", user?.Id], queryFn: async () => { if (!api || !user?.Id) return []; const response = await getItemsApi(api).getResumeItems({ userId: user.Id, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], includeItemTypes: ["Movie", "Series", "Episode"], fields: ["Genres", "Overview"], limit: 2, }); return response.data.Items || []; }, enabled: isQueryEnabled, staleTime: 60 * 1000, }); const { data: nextUpData, isLoading: nextUpLoading } = useQuery({ queryKey: ["appleTVCarousel", "nextUp", user?.Id], queryFn: async () => { if (!api || !user?.Id) return []; const response = await getTvShowsApi(api).getNextUp({ userId: user.Id, fields: ["MediaSourceCount", "Genres", "Overview"], limit: 2, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableResumable: false, }); return response.data.Items || []; }, enabled: isQueryEnabled, staleTime: 60 * 1000, }); const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery( { queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id], queryFn: async () => { if (!api || !user?.Id) return []; const response = await getUserLibraryApi(api).getLatestMedia({ userId: user.Id, limit: 2, fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"], imageTypeLimit: 1, enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], }); return response.data || []; }, enabled: isQueryEnabled, staleTime: 60 * 1000, }, ); const items = useMemo(() => { const continueItems = continueWatchingData ?? []; const nextItems = nextUpData ?? []; const recentItems = recentlyAddedData ?? []; const allItems = [ ...continueItems.slice(0, 2), ...nextItems.slice(0, 2), ...recentItems.slice(0, 2), ]; // Deduplicate by item ID to prevent duplicate keys const seen = new Set(); return allItems.filter((item) => { if (item.Id && !seen.has(item.Id)) { seen.add(item.Id); return true; } return false; }); }, [continueWatchingData, nextUpData, recentlyAddedData]); const isLoading = continueWatchingLoading || nextUpLoading || recentlyAddedLoading; const hasItems = items.length > 0; // Only get play settings if we have valid items const currentItem = hasItems ? items[currentIndex] : null; // Extract colors for the current item only (for performance) const currentItemColors = useImageColorsReturn({ item: currentItem }); // Create a fallback empty item for useDefaultPlaySettings when no item is available const itemForPlaySettings = currentItem || { MediaSources: [] }; const { defaultAudioIndex, defaultBitrate, defaultMediaSource, defaultSubtitleIndex, } = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings); const [selectedOptions, setSelectedOptions] = useState< SelectedOptions | undefined >(undefined); useEffect(() => { // Only set options if we have valid current item if (currentItem) { setSelectedOptions({ bitrate: defaultBitrate, mediaSource: defaultMediaSource ?? undefined, subtitleIndex: defaultSubtitleIndex ?? -1, audioIndex: defaultAudioIndex, }); } else { setSelectedOptions(undefined); } }, [ defaultAudioIndex, defaultBitrate, defaultSubtitleIndex, defaultMediaSource, currentIndex, currentItem, ]); useEffect(() => { if (!hasItems) { setCurrentIndex(initialIndex); translateX.value = -initialIndex * screenWidth; return; } setCurrentIndex((prev) => { const newIndex = Math.min(prev, items.length - 1); translateX.value = -newIndex * screenWidth; return newIndex; }); }, [hasItems, items, initialIndex, screenWidth, translateX]); useEffect(() => { translateX.value = -currentIndex * screenWidth; }, [currentIndex, screenWidth, translateX]); useEffect(() => { if (hasItems) { onItemChange?.(currentIndex); } }, [hasItems, currentIndex, onItemChange]); const goToIndex = useCallback( (index: number) => { if (!hasItems || index < 0 || index >= items.length) return; translateX.value = withTiming(-index * screenWidth, { duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve }); setCurrentIndex(index); onItemChange?.(index); }, [hasItems, items, onItemChange, screenWidth, translateX], ); const navigateToItem = useCallback( (item: BaseItemDto) => { const navigation = getItemNavigation(item, "(home)"); router.push(navigation as any); }, [router], ); const panGesture = Gesture.Pan() .activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET]) .onUpdate((event) => { translateX.value = -currentIndex * screenWidth + event.translationX; }) .onEnd((event) => { const velocity = event.velocityX; const translation = event.translationX; let newIndex = currentIndex; // Improved thresholds for more responsive navigation if ( Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD || Math.abs(velocity) > VELOCITY_THRESHOLD ) { if (translation > 0 && currentIndex > 0) { newIndex = currentIndex - 1; } else if ( translation < 0 && items && currentIndex < items.length - 1 ) { newIndex = currentIndex + 1; } } runOnJS(goToIndex)(newIndex); }); const containerAnimatedStyle = useAnimatedStyle(() => { return { transform: [{ translateX: translateX.value }], }; }); const togglePlayedStatus = useMarkAsPlayed(items); const headerAnimatedStyle = useAnimatedStyle(() => { if (!scrollOffset) return {}; return { transform: [ { translateY: interpolate( scrollOffset.value, [-carouselHeight, 0, carouselHeight], [-carouselHeight / 2, 0, carouselHeight * 0.75], ), }, { scale: interpolate( scrollOffset.value, [-carouselHeight, 0, carouselHeight], [2, 1, 1], ), }, ], }; }); const renderDots = () => { if (!hasItems || items.length <= 1) return null; return ( {items.map((_, index) => ( ))} ); }; const renderSkeletonLoader = () => { return ( {/* Background Skeleton */} {/* Dark Overlay Skeleton */} {/* Gradient Fade to Black Top Skeleton */} {/* Gradient Fade to Black Bottom Skeleton */} {/* Logo Skeleton */} {/* Type and Genres Skeleton */} {/* Overview Skeleton */} {/* Controls Skeleton */} {/* Play Button Skeleton */} {/* Played Status Skeleton */} {/* Dots Skeleton */} {[1, 2, 3].map((_, index) => ( ))} ); }; const renderItem = (item: BaseItemDto, _index: number) => { const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null; return ( {/* Background Backdrop */} {/* Dark Overlay */} {/* Gradient Fade to Black at Top */} {/* Gradient Fade to Black at Bottom */} {/* Logo Section */} {itemLogoUrl && ( navigateToItem(item)} style={{ position: "absolute", bottom: LOGO_BOTTOM_POSITION, left: 0, right: 0, paddingHorizontal: HORIZONTAL_PADDING, alignItems: "center", }} > )} {/* Type and Genres Section */} navigateToItem(item)}> {(() => { let typeLabel = ""; if (item.Type === "Episode") { // For episodes, show season and episode number const season = item.ParentIndexNumber; const episode = item.IndexNumber; if (season && episode) { typeLabel = `S${season} • E${episode}`; } else { typeLabel = "Episode"; } } else { typeLabel = item.Type === "Series" ? "TV Show" : item.Type === "Movie" ? "Movie" : item.Type || ""; } const genres = item.Genres && item.Genres.length > 0 ? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ") : ""; if (typeLabel && genres) { return `${typeLabel} • ${genres}`; } else if (typeLabel) { return typeLabel; } else if (genres) { return genres; } else { return ""; } })()} {/* Overview Section - for Episodes and Movies */} {(item.Type === "Episode" || item.Type === "Movie") && item.Overview && ( navigateToItem(item)}> {item.Overview} )} {/* Controls Section */} {/* Play Button */} {selectedOptions && ( )} {/* Mark as Played */} ); }; // Handle loading state if (isLoading) { return ( {renderSkeletonLoader()} ); } // Handle empty items if (!hasItems) { return null; } return ( {items.map((item, index) => renderItem(item, index))} {/* Animated Dots Indicator */} {renderDots()} ); };