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 && (
-
- )}
-
-
- )
- }
- >
- {retryLoading ? (
-
- ) : (
- t("home.retry")
- )}
-
-
-
- );
- }
-
- 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,