From 1f454c0f12fe8fb2286c8f8b5801cb18b7e055ba Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 24 Jan 2026 23:43:40 +0100 Subject: [PATCH] feat(tv): add Apple TV+ style hero carousel to home page --- app/(auth)/(tabs)/(home)/index.tsx | 9 - app/(auth)/(tabs)/(home)/settings.tv.tsx | 5 + components/ContinueWatchingPoster.tv.tsx | 20 +- .../apple-tv-carousel/AppleTVCarousel.tsx | 909 ------------------ .../MarkAsPlayedLargeButton.tsx | 51 - components/home/Home.tv.tsx | 192 ++-- components/home/HomeWithCarousel.tsx | 631 ------------ .../InfiniteScrollingCollectionList.tv.tsx | 9 +- .../StreamystatsPromotedWatchlists.tv.tsx | 7 +- .../home/StreamystatsRecommendations.tv.tsx | 7 +- components/home/TVHeroCarousel.tsx | 569 +++++++++++ components/posters/MoviePoster.tv.tsx | 4 +- components/posters/SeriesPoster.tv.tsx | 6 +- components/settings/AppearanceSettings.tsx | 8 - components/settings/OtherSettings.tsx | 8 - translations/en.json | 3 +- utils/atoms/settings.ts | 4 +- 17 files changed, 738 insertions(+), 1704 deletions(-) delete mode 100644 components/apple-tv-carousel/AppleTVCarousel.tsx delete mode 100644 components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx delete mode 100644 components/home/HomeWithCarousel.tsx create mode 100644 components/home/TVHeroCarousel.tsx diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index ad951c36..9586d465 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -1,15 +1,6 @@ -import { useSettings } from "@/utils/atoms/settings"; import { Home } from "../../../../components/home/Home"; -import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel"; const Index = () => { - const { settings } = useSettings(); - const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false; - - if (showLargeHomeCarousel) { - return ; - } - return ; }; diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index e311c582..1463713d 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -358,6 +358,11 @@ export default function SettingsTV() { value={settings.showHomeBackdrop} onToggle={(value) => updateSettings({ showHomeBackdrop: value })} /> + updateSettings({ showTVHeroCarousel: value })} + /> {/* User Section */} = ({ return; } if (item.Type === "Episode" && useEpisodePoster) { - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; } if (item.Type === "Episode") { if (item.ParentBackdropItemId && item.ParentThumbImageTag) { - return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ParentThumbImageTag}`; + return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`; } - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; } if (item.Type === "Movie") { if (item.ImageTags?.Thumb) { - return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`; + return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`; } - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; } if (item.Type === "Program") { if (item.ImageTags?.Thumb) { - return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`; + return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`; } - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; } if (item.ImageTags?.Thumb) { - return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`; + return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`; } - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`; + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; }, [api, item, useEpisodePoster]); if (!url) { diff --git a/components/apple-tv-carousel/AppleTVCarousel.tsx b/components/apple-tv-carousel/AppleTVCarousel.tsx deleted file mode 100644 index 82c0f7b4..00000000 --- a/components/apple-tv-carousel/AppleTVCarousel.tsx +++ /dev/null @@ -1,909 +0,0 @@ -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 { 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 useRouter from "@/hooks/useAppRouter"; -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()} - - ); -}; diff --git a/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx b/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx deleted file mode 100644 index ea9bd98d..00000000 --- a/components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Button, Host } from "@expo/ui/swift-ui"; -import { Ionicons } from "@expo/vector-icons"; -import { Platform, View } from "react-native"; -import { RoundButton } from "../RoundButton"; - -interface MarkAsPlayedLargeButtonProps { - isPlayed: boolean; - onToggle: (isPlayed: boolean) => void; -} - -export const MarkAsPlayedLargeButton: React.FC< - MarkAsPlayedLargeButtonProps -> = ({ isPlayed, onToggle }) => { - if (Platform.OS === "ios") - return ( - - - - ); - - return ( - - onToggle(isPlayed)} - /> - - ); -}; diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 1b182dd4..772ea094 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -30,6 +30,7 @@ import { Text } from "@/components/common/Text"; import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv"; import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv"; import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv"; +import { TVHeroCarousel } from "@/components/home/TVHeroCarousel"; import { Loader } from "@/components/Loader"; import { TVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; @@ -41,8 +42,8 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; const HORIZONTAL_PADDING = 60; const TOP_PADDING = 100; -// Reduced gap since sections have internal padding for scale animations -const SECTION_GAP = 10; +// Generous gap between sections for Apple TV+ aesthetic +const SECTION_GAP = 24; type InfiniteScrollingCollectionListSection = { type: "InfiniteScrollingCollectionList"; @@ -204,6 +205,57 @@ export const Home = () => { refetchInterval: 60 * 1000, }); + // Fetch hero items (Continue Watching + Next Up combined) + const { data: heroItems } = useQuery({ + queryKey: ["home", "heroItems", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) return []; + + const [resumeResponse, nextUpResponse] = await Promise.all([ + getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + fields: ["Overview"], + startIndex: 0, + limit: 10, + }), + getTvShowsApi(api).getNextUp({ + userId: user.Id, + startIndex: 0, + limit: 10, + fields: ["Overview"], + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }), + ]); + + const resumeItems = resumeResponse.data.Items || []; + const nextUpItems = nextUpResponse.data.Items || []; + + // Combine, sort by recent activity, and dedupe + const combined = [...resumeItems, ...nextUpItems]; + const sorted = combined.sort((a, b) => { + const dateA = a.UserData?.LastPlayedDate || a.DateCreated || ""; + const dateB = b.UserData?.LastPlayedDate || b.DateCreated || ""; + return new Date(dateB).getTime() - new Date(dateA).getTime(); + }); + + const seen = new Set(); + const deduped: BaseItemDto[] = []; + for (const item of sorted) { + if (!item.Id || seen.has(item.Id)) continue; + seen.add(item.Id); + deduped.push(item); + } + + return deduped.slice(0, 8); + }, + enabled: !!api && !!user?.Id, + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + }); + const userViews = useMemo( () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), [data, settings?.hiddenLibraries], @@ -608,87 +660,106 @@ export const Home = () => { ); + // Determine if hero should be shown (separate setting from backdrop) + const showHero = + heroItems && heroItems.length > 0 && settings.showTVHeroCarousel; + return ( - {/* Dynamic backdrop with crossfade */} - - {/* Layer 0 */} - - {layer0Url && ( - - )} - - {/* Layer 1 */} - - {layer1Url && ( - - )} - - {/* Gradient overlays for readability */} - - + > + {/* Layer 0 */} + + {layer0Url && ( + + )} + + {/* Layer 1 */} + + {layer1Url && ( + + )} + + {/* Gradient overlays for readability */} + + + )} - - {sections.map((section, index) => { + {/* Hero Carousel - Apple TV+ style featured content */} + {showHero && ( + + )} + + + {/* Skip first section (Continue Watching) when hero is shown since hero displays that content */} + {sections.slice(showHero ? 1 : 0).map((section, index) => { // Render Streamystats sections after Recently Added sections // For default sections: place after Recently Added, before Suggested Movies (if present) // For custom sections: place at the very end const hasSuggestedMovies = !settings?.streamyStatsMovieRecommendations && !settings?.home?.sections; + // Adjust index calculation to account for sliced array when hero is shown + const displayedSectionsLength = + sections.length - (showHero ? 1 : 0); const streamystatsIndex = - sections.length - 1 - (hasSuggestedMovies ? 1 : 0); + displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0); const hasStreamystatsContent = settings.streamyStatsMovieRecommendations || settings.streamyStatsSeriesRecommendations || @@ -727,7 +798,8 @@ export const Home = () => { if (section.type === "InfiniteScrollingCollectionList") { const isHighPriority = section.priority === 1; - const isFirstSection = index === 0; + // First section only gets preferred focus if hero is not shown + const isFirstSection = index === 0 && !showHero; return ( ; - orientation?: "horizontal" | "vertical"; - pageSize?: number; -}; - -type MediaListSectionType = { - type: "MediaListSection"; - queryKey: (string | undefined)[]; - queryFn: QueryFunction; -}; - -type Section = InfiniteScrollingCollectionListSection | MediaListSectionType; - -export const HomeWithCarousel = () => { - const router = useRouter(); - const { t } = useTranslation(); - const api = useAtomValue(apiAtom); - const user = useAtomValue(userAtom); - const insets = useSafeAreaInsets(); - const [_loading, setLoading] = useState(false); - const { settings, refreshStreamyfinPluginSettings } = useSettings(); - const headerOverlayOffset = Platform.isTV ? 0 : 60; - const navigation = useNavigation(); - const animatedScrollRef = useAnimatedRef(); - const scrollOffset = useScrollViewOffset(animatedScrollRef); - const { downloadedItems, cleanCacheDirectory } = useDownload(); - const prevIsConnected = useRef(false); - const { - isConnected, - serverConnected, - loading: retryLoading, - retryCheck, - } = useNetworkStatus(); - const invalidateCache = useInvalidatePlaybackProgressCache(); - const [scrollY, setScrollY] = useState(0); - - useEffect(() => { - if (isConnected && !prevIsConnected.current) { - invalidateCache(); - } - prevIsConnected.current = isConnected; - }, [isConnected, invalidateCache]); - - const hasDownloads = useMemo(() => { - if (Platform.isTV) return false; - return downloadedItems.length > 0; - }, [downloadedItems]); - - useEffect(() => { - if (Platform.isTV) { - navigation.setOptions({ - headerLeft: () => null, - }); - return; - } - navigation.setOptions({ - headerLeft: () => ( - { - router.push("/(auth)/downloads"); - }} - className='ml-1.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), - }); - }, [navigation, router, hasDownloads]); - - useEffect(() => { - cleanCacheDirectory().catch((_e) => - console.error("Something went wrong cleaning cache directory"), - ); - }, []); - - const segments = useSegments(); - useEffect(() => { - const unsubscribe = eventBus.on("scrollToTop", () => { - if ((segments as string[])[2] === "(home)") - animatedScrollRef.current?.scrollTo({ - y: Platform.isTV ? -152 : -100, - animated: true, - }); - }); - - return () => { - unsubscribe(); - }; - }, [segments]); - - const { - data, - isError: e1, - isLoading: l1, - } = useQuery({ - queryKey: ["home", "userViews", user?.Id], - queryFn: async () => { - if (!api || !user?.Id) { - return null; - } - - const response = await getUserViewsApi(api).getUserViews({ - userId: user.Id, - }); - - return response.data.Items || null; - }, - enabled: !!api && !!user?.Id, - staleTime: 60 * 1000, - }); - - const userViews = useMemo( - () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), - [data, settings?.hiddenLibraries], - ); - - const collections = useMemo(() => { - const allow = ["movies", "tvshows"]; - return ( - userViews?.filter( - (c) => c.CollectionType && allow.includes(c.CollectionType), - ) || [] - ); - }, [userViews]); - - const _refetch = async () => { - setLoading(true); - await refreshStreamyfinPluginSettings(); - await invalidateCache(); - setLoading(false); - }; - - const createCollectionConfig = useCallback( - ( - title: string, - queryKey: string[], - includeItemTypes: BaseItemKind[], - parentId: string | undefined, - pageSize: number = 10, - ): InfiniteScrollingCollectionListSection => ({ - title, - queryKey, - queryFn: async ({ pageParam = 0 }) => { - if (!api) return []; - // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side - const allData = - ( - await getUserLibraryApi(api).getLatestMedia({ - userId: user?.Id, - limit: 100, // Fetch a larger set for pagination - fields: ["PrimaryImageAspectRatio", "Path", "Genres"], - imageTypeLimit: 1, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - includeItemTypes, - parentId, - }) - ).data || []; - - // Simulate pagination by slicing - return allData.slice(pageParam, pageParam + pageSize); - }, - type: "InfiniteScrollingCollectionList", - pageSize, - }), - [api, user?.Id], - ); - - const defaultSections = useMemo(() => { - if (!api || !user?.Id) return []; - - const latestMediaViews = collections.map((c) => { - const includeItemTypes: BaseItemKind[] = - c.CollectionType === "tvshows" || c.CollectionType === "movies" - ? [] - : ["Movie"]; - const title = t("home.recently_added_in", { libraryName: c.Name }); - const queryKey: string[] = [ - "home", - `recentlyAddedIn${c.CollectionType}`, - user.Id!, - c.Id!, - ]; - return createCollectionConfig( - title || "", - queryKey, - includeItemTypes, - c.Id, - 10, - ); - }); - - // Helper to sort items by most recent activity - const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => { - return items.sort((a, b) => { - const dateA = a.UserData?.LastPlayedDate || a.DateCreated || ""; - const dateB = b.UserData?.LastPlayedDate || b.DateCreated || ""; - return new Date(dateB).getTime() - new Date(dateA).getTime(); - }); - }; - - // Helper to deduplicate items by ID - const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => { - const seen = new Set(); - return items.filter((item) => { - if (!item.Id || seen.has(item.Id)) return false; - seen.add(item.Id); - return true; - }); - }; - - // Build the first sections based on merge setting - const firstSections: Section[] = settings.mergeNextUpAndContinueWatching - ? [ - { - title: t("home.continue_and_next_up"), - queryKey: ["home", "continueAndNextUp"], - queryFn: async ({ pageParam = 0 }) => { - // Fetch both in parallel - const [resumeResponse, nextUpResponse] = await Promise.all([ - getItemsApi(api).getResumeItems({ - userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - includeItemTypes: ["Movie", "Series", "Episode"], - fields: ["Genres"], - startIndex: 0, - limit: 20, - }), - getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount", "Genres"], - startIndex: 0, - limit: 20, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - enableResumable: false, - }), - ]); - - const resumeItems = resumeResponse.data.Items || []; - const nextUpItems = nextUpResponse.data.Items || []; - - // Combine, sort by recent activity, deduplicate - const combined = [...resumeItems, ...nextUpItems]; - const sorted = sortByRecentActivity(combined); - const deduplicated = deduplicateById(sorted); - - // Paginate client-side - return deduplicated.slice(pageParam, pageParam + 10); - }, - type: "InfiniteScrollingCollectionList", - orientation: "horizontal", - pageSize: 10, - }, - ] - : [ - { - title: t("home.continue_watching"), - queryKey: ["home", "resumeItems"], - queryFn: async ({ pageParam = 0 }) => - ( - await getItemsApi(api).getResumeItems({ - userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - includeItemTypes: ["Movie", "Series", "Episode"], - fields: ["Genres"], - startIndex: pageParam, - limit: 10, - }) - ).data.Items || [], - type: "InfiniteScrollingCollectionList", - orientation: "horizontal", - pageSize: 10, - }, - { - title: t("home.next_up"), - queryKey: ["home", "nextUp-all"], - queryFn: async ({ pageParam = 0 }) => - ( - await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount", "Genres"], - startIndex: pageParam, - limit: 10, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - enableResumable: false, - }) - ).data.Items || [], - type: "InfiniteScrollingCollectionList", - orientation: "horizontal", - pageSize: 10, - }, - ]; - - const ss: Section[] = [ - ...firstSections, - ...latestMediaViews, - // Only show Jellyfin suggested movies if StreamyStats recommendations are disabled - ...(!settings?.streamyStatsMovieRecommendations - ? [ - { - title: t("home.suggested_movies"), - queryKey: ["home", "suggestedMovies", user?.Id], - queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => - ( - await getSuggestionsApi(api).getSuggestions({ - userId: user?.Id, - startIndex: pageParam, - limit: 10, - mediaType: ["Video"], - type: ["Movie"], - }) - ).data.Items || [], - type: "InfiniteScrollingCollectionList" as const, - orientation: "vertical" as const, - pageSize: 10, - }, - ] - : []), - ]; - return ss; - }, [ - api, - user?.Id, - collections, - t, - createCollectionConfig, - settings?.streamyStatsMovieRecommendations, - settings.mergeNextUpAndContinueWatching, - ]); - - const customSections = useMemo(() => { - if (!api || !user?.Id || !settings?.home?.sections) return []; - const ss: Section[] = []; - settings.home.sections.forEach((section, index) => { - const id = section.title || `section-${index}`; - const pageSize = 10; - ss.push({ - title: t(`${id}`), - queryKey: ["home", "custom", String(index), section.title ?? null], - queryFn: async ({ pageParam = 0 }) => { - if (section.items) { - const response = await getItemsApi(api).getItems({ - userId: user?.Id, - startIndex: pageParam, - limit: section.items?.limit || pageSize, - recursive: true, - includeItemTypes: section.items?.includeItemTypes, - sortBy: section.items?.sortBy, - sortOrder: section.items?.sortOrder, - filters: section.items?.filters, - parentId: section.items?.parentId, - }); - return response.data.Items || []; - } - if (section.nextUp) { - const response = await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount", "Genres"], - startIndex: pageParam, - limit: section.nextUp?.limit || pageSize, - enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], - enableResumable: section.nextUp?.enableResumable, - enableRewatching: section.nextUp?.enableRewatching, - }); - return response.data.Items || []; - } - if (section.latest) { - // getLatestMedia doesn't support startIndex, so we fetch all and slice client-side - const allData = - ( - await getUserLibraryApi(api).getLatestMedia({ - userId: user?.Id, - includeItemTypes: section.latest?.includeItemTypes, - limit: section.latest?.limit || 100, // Fetch larger set - isPlayed: section.latest?.isPlayed, - groupItems: section.latest?.groupItems, - }) - ).data || []; - - // Simulate pagination by slicing - return allData.slice(pageParam, pageParam + pageSize); - } - if (section.custom) { - const response = await api.get( - section.custom.endpoint, - { - params: { - ...(section.custom.query || {}), - userId: user?.Id, - startIndex: pageParam, - limit: pageSize, - }, - headers: section.custom.headers || {}, - }, - ); - return response.data.Items || []; - } - return []; - }, - type: "InfiniteScrollingCollectionList", - orientation: section?.orientation || "vertical", - pageSize, - }); - }); - return ss; - }, [api, user?.Id, settings?.home?.sections, t]); - - const sections = settings?.home?.sections ? customSections : defaultSections; - - if (!isConnected || serverConnected !== true) { - let title = ""; - let subtitle = ""; - - if (!isConnected) { - title = t("home.no_internet"); - subtitle = t("home.no_internet_message"); - } else if (serverConnected === null) { - title = t("home.checking_server_connection"); - subtitle = t("home.checking_server_connection_message"); - } else if (!serverConnected) { - title = t("home.server_unreachable"); - subtitle = t("home.server_unreachable_message"); - } - return ( - - {title} - {subtitle} - - - {!Platform.isTV && ( - - )} - - - - - ); - } - - if (e1) - return ( - - {t("home.oops")} - - {t("home.error_message")} - - - ); - - if (l1) - return ( - - - - ); - - return ( - { - setScrollY(event.nativeEvent.contentOffset.y); - }} - > - - - - {sections.map((section, index) => { - // Render Streamystats sections after Continue Watching and Next Up - // When merged, they appear after index 0; otherwise after index 1 - const streamystatsIndex = settings.mergeNextUpAndContinueWatching - ? 0 - : 1; - const hasStreamystatsContent = - settings.streamyStatsMovieRecommendations || - settings.streamyStatsSeriesRecommendations || - settings.streamyStatsPromotedWatchlists; - const streamystatsSections = - index === streamystatsIndex && hasStreamystatsContent ? ( - <> - {settings.streamyStatsMovieRecommendations && ( - - )} - {settings.streamyStatsSeriesRecommendations && ( - - )} - {settings.streamyStatsPromotedWatchlists && ( - - )} - - ) : null; - - if (section.type === "InfiniteScrollingCollectionList") { - return ( - - - {streamystatsSections} - - ); - } - if (section.type === "MediaListSection") { - return ( - - - {streamystatsSections} - - ); - } - return null; - })} - - - - - ); -}; diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index 9fdbd0db..ef7bcae7 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -28,7 +28,7 @@ import ContinueWatchingPoster, { } from "../ContinueWatchingPoster.tv"; import SeriesPoster from "../posters/SeriesPoster.tv"; -const ITEM_GAP = 16; +const ITEM_GAP = 24; // Extra padding to accommodate scale animation (1.05x) and glow shadow const SCALE_PADDING = 20; @@ -365,11 +365,12 @@ export const InfiniteScrollingCollectionList: React.FC = ({ {/* Section Header */} {title} diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx index 59f9c640..1bb168a8 100644 --- a/components/home/StreamystatsPromotedWatchlists.tv.tsx +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -155,11 +155,12 @@ const WatchlistSection: React.FC = ({ {watchlist.name} diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx index 79293394..ec58f0d6 100644 --- a/components/home/StreamystatsRecommendations.tv.tsx +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -218,11 +218,12 @@ export const StreamystatsRecommendations: React.FC = ({ {title} diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx new file mode 100644 index 00000000..f0e34c75 --- /dev/null +++ b/components/home/TVHeroCarousel.tsx @@ -0,0 +1,569 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useAtomValue } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Animated, + Dimensions, + Easing, + FlatList, + Pressable, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ProgressBar } from "@/components/common/ProgressBar"; +import { Text } from "@/components/common/Text"; +import { getItemNavigation } from "@/components/common/TouchableItemRouter"; +import { TVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; +import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; +import { runtimeTicksToMinutes } from "@/utils/time"; + +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); +const HERO_HEIGHT = SCREEN_HEIGHT * 0.62; +const CARD_WIDTH = 280; +const CARD_GAP = 24; +const CARD_PADDING = 60; + +interface TVHeroCarouselProps { + items: BaseItemDto[]; + onItemFocus?: (item: BaseItemDto) => void; +} + +interface HeroCardProps { + item: BaseItemDto; + isFirst: boolean; + onFocus: (item: BaseItemDto) => void; + onPress: (item: BaseItemDto) => void; +} + +const HeroCard: React.FC = React.memo( + ({ item, isFirst, onFocus, onPress }) => { + const api = useAtomValue(apiAtom); + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const posterUrl = useMemo(() => { + if (!api) return null; + // Try thumb first, then primary + if (item.ImageTags?.Thumb) { + return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`; + } + if (item.ImageTags?.Primary) { + return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`; + } + // For episodes, use series thumb + if (item.Type === "Episode" && item.ParentThumbImageTag) { + return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`; + } + return null; + }, [api, item]); + + const animateTo = useCallback( + (value: number) => + Animated.timing(scale, { + toValue: value, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(), + [scale], + ); + + const handleFocus = useCallback(() => { + setFocused(true); + animateTo(1.08); + onFocus(item); + }, [animateTo, onFocus, item]); + + const handleBlur = useCallback(() => { + setFocused(false); + animateTo(1); + }, [animateTo]); + + const handlePress = useCallback(() => { + onPress(item); + }, [onPress, item]); + + return ( + + + {posterUrl ? ( + + ) : ( + + + + )} + + + + ); + }, +); + +// Debounce delay to prevent rapid backdrop changes when navigating fast +const BACKDROP_DEBOUNCE_MS = 300; + +export const TVHeroCarousel: React.FC = ({ + items, + onItemFocus, +}) => { + const api = useAtomValue(apiAtom); + const insets = useSafeAreaInsets(); + const router = useRouter(); + + // Active item for featured display (debounced) + const [activeItem, setActiveItem] = useState( + items[0] || null, + ); + const debounceTimerRef = useRef | null>(null); + + // Cleanup debounce timer on unmount + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + // Crossfade animation state + const [activeLayer, setActiveLayer] = useState<0 | 1>(0); + const [layer0Url, setLayer0Url] = useState(null); + const [layer1Url, setLayer1Url] = useState(null); + const layer0Opacity = useRef(new Animated.Value(0)).current; + const layer1Opacity = useRef(new Animated.Value(0)).current; + + // Get backdrop URL for active item + const backdropUrl = useMemo(() => { + if (!activeItem) return null; + return getBackdropUrl({ + api, + item: activeItem, + quality: 90, + width: 1920, + }); + }, [api, activeItem]); + + // Get logo URL for active item + const logoUrl = useMemo(() => { + if (!activeItem) return null; + return getLogoImageUrlById({ api, item: activeItem }); + }, [api, activeItem]); + + // Crossfade effect for backdrop + useEffect(() => { + if (!backdropUrl) return; + + let isCancelled = false; + + const performCrossfade = async () => { + try { + await Image.prefetch(backdropUrl); + } catch { + // Continue even if prefetch fails + } + + if (isCancelled) return; + + const incomingLayer = activeLayer === 0 ? 1 : 0; + const incomingOpacity = + incomingLayer === 0 ? layer0Opacity : layer1Opacity; + const outgoingOpacity = + incomingLayer === 0 ? layer1Opacity : layer0Opacity; + + if (incomingLayer === 0) { + setLayer0Url(backdropUrl); + } else { + setLayer1Url(backdropUrl); + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (isCancelled) return; + + Animated.parallel([ + Animated.timing(incomingOpacity, { + toValue: 1, + duration: 500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(outgoingOpacity, { + toValue: 0, + duration: 500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + ]).start(() => { + if (!isCancelled) { + setActiveLayer(incomingLayer); + } + }); + }; + + performCrossfade(); + + return () => { + isCancelled = true; + }; + }, [backdropUrl]); + + // Handle card focus with debounce + const handleCardFocus = useCallback( + (item: BaseItemDto) => { + // Clear any pending debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + // Set new timer to update active item after debounce delay + debounceTimerRef.current = setTimeout(() => { + setActiveItem(item); + onItemFocus?.(item); + }, BACKDROP_DEBOUNCE_MS); + }, + [onItemFocus], + ); + + // Handle card press - navigate to item + const handleCardPress = useCallback( + (item: BaseItemDto) => { + const navigation = getItemNavigation(item, "(home)"); + router.push(navigation as any); + }, + [router], + ); + + // Get metadata for active item + const year = activeItem?.ProductionYear; + const duration = activeItem?.RunTimeTicks + ? runtimeTicksToMinutes(activeItem.RunTimeTicks) + : null; + const hasProgress = (activeItem?.UserData?.PlaybackPositionTicks ?? 0) > 0; + const playedPercent = activeItem?.UserData?.PlayedPercentage ?? 0; + + // Get display title + const displayTitle = useMemo(() => { + if (!activeItem) return ""; + if (activeItem.Type === "Episode") { + return activeItem.SeriesName || activeItem.Name || ""; + } + return activeItem.Name || ""; + }, [activeItem]); + + // Get subtitle for episodes + const episodeSubtitle = useMemo(() => { + if (!activeItem || activeItem.Type !== "Episode") return null; + return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`; + }, [activeItem]); + + // Memoize hero items to prevent re-renders + const heroItems = useMemo(() => items.slice(0, 8), [items]); + + // Memoize renderItem for FlatList + const renderHeroCard = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => ( + + ), + [handleCardFocus, handleCardPress], + ); + + // Memoize keyExtractor + const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []); + + if (items.length === 0) return null; + + return ( + + {/* Backdrop layers with crossfade */} + + {/* Layer 0 */} + + {layer0Url && ( + + )} + + {/* Layer 1 */} + + {layer1Url && ( + + )} + + + {/* Gradient overlays */} + + + + + {/* Content overlay */} + + {/* Logo or Title */} + {logoUrl ? ( + + ) : ( + + {displayTitle} + + )} + + {/* Episode subtitle */} + {episodeSubtitle && ( + + {episodeSubtitle} + + )} + + {/* Description */} + {activeItem?.Overview && ( + + {activeItem.Overview} + + )} + + {/* Metadata badges */} + + {year && ( + + {year} + + )} + {duration && ( + + {duration} + + )} + {activeItem?.OfficialRating && ( + + + {activeItem.OfficialRating} + + + )} + {hasProgress && ( + + + + + + {Math.round(playedPercent)}% + + + )} + + + {/* Thumbnail carousel */} + + + + ); +}; diff --git a/components/posters/MoviePoster.tv.tsx b/components/posters/MoviePoster.tv.tsx index 1719df96..46f25523 100644 --- a/components/posters/MoviePoster.tv.tsx +++ b/components/posters/MoviePoster.tv.tsx @@ -7,7 +7,7 @@ import { WatchedIndicator } from "@/components/WatchedIndicator"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -export const TV_POSTER_WIDTH = 210; +export const TV_POSTER_WIDTH = 260; type MoviePosterProps = { item: BaseItemDto; @@ -24,7 +24,7 @@ const MoviePoster: React.FC = ({ return getPrimaryImageUrl({ api, item, - width: 420, // 2x for quality on large screens + width: 520, // 2x for quality on large screens }); }, [api, item]); diff --git a/components/posters/SeriesPoster.tv.tsx b/components/posters/SeriesPoster.tv.tsx index 21b41ff6..49c43cef 100644 --- a/components/posters/SeriesPoster.tv.tsx +++ b/components/posters/SeriesPoster.tv.tsx @@ -6,7 +6,7 @@ import { View } from "react-native"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -export const TV_POSTER_WIDTH = 210; +export const TV_POSTER_WIDTH = 260; type SeriesPosterProps = { item: BaseItemDto; @@ -18,12 +18,12 @@ const SeriesPoster: React.FC = ({ item }) => { const url = useMemo(() => { if (item.Type === "Episode") { - return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=630&quality=80&tag=${item.SeriesPrimaryImageTag}`; + return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=780&quality=80&tag=${item.SeriesPrimaryImageTag}`; } return getPrimaryImageUrl({ api, item, - width: 420, // 2x for quality on large screens + width: 520, // 2x for quality on large screens }); }, [api, item]); diff --git a/components/settings/AppearanceSettings.tsx b/components/settings/AppearanceSettings.tsx index f9074213..84409617 100644 --- a/components/settings/AppearanceSettings.tsx +++ b/components/settings/AppearanceSettings.tsx @@ -42,14 +42,6 @@ export const AppearanceSettings: React.FC = () => { } /> - - - updateSettings({ showLargeHomeCarousel: value }) - } - /> - diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index fcca2498..7abf10fb 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -158,14 +158,6 @@ export const OtherSettings: React.FC = () => { } /> - - - updateSettings({ showLargeHomeCarousel: value }) - } - /> - router.push("/settings/hide-libraries/page")} title={t("home.settings.other.hide_libraries")} diff --git a/translations/en.json b/translations/en.json index 2a629267..855d1cf6 100644 --- a/translations/en.json +++ b/translations/en.json @@ -125,7 +125,8 @@ "title": "Appearance", "merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "hide_remote_session_button": "Hide Remote Session Button", - "show_home_backdrop": "Dynamic Home Backdrop" + "show_home_backdrop": "Dynamic Home Backdrop", + "show_hero_carousel": "Hero Carousel" }, "network": { "title": "Network", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 21ac3577..3038199b 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -198,10 +198,10 @@ export type Settings = { hideVolumeSlider: boolean; hideBrightnessSlider: boolean; usePopularPlugin: boolean; - showLargeHomeCarousel: boolean; mergeNextUpAndContinueWatching: boolean; // TV-specific settings showHomeBackdrop: boolean; + showTVHeroCarousel: boolean; // Appearance hideRemoteSessionButton: boolean; hideWatchlistsTab: boolean; @@ -287,10 +287,10 @@ export const defaultValues: Settings = { hideVolumeSlider: false, hideBrightnessSlider: false, usePopularPlugin: true, - showLargeHomeCarousel: false, mergeNextUpAndContinueWatching: false, // TV-specific settings showHomeBackdrop: true, + showTVHeroCarousel: true, // Appearance hideRemoteSessionButton: false, hideWatchlistsTab: false,