diff --git a/components/AppleTVCarousel.tsx b/components/AppleTVCarousel.tsx
new file mode 100644
index 00000000..2f5b2d50
--- /dev/null
+++ b/components/AppleTVCarousel.tsx
@@ -0,0 +1,749 @@
+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 { Dimensions, Pressable, View } from "react-native";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import Animated, {
+ Easing,
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
+import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
+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 type { SelectedOptions } from "./ItemContent";
+import { PlayButton } from "./PlayButton";
+import { PlayedStatus } from "./PlayedStatus";
+
+interface AppleTVCarouselProps {
+ initialIndex?: number;
+ onItemChange?: (index: number) => void;
+}
+
+const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
+
+// Layout Constants
+const CAROUSEL_HEIGHT = screenHeight / 1.45;
+const GRADIENT_HEIGHT_TOP = 150;
+const GRADIENT_HEIGHT_BOTTOM = 150;
+const LOGO_HEIGHT = 80;
+
+// Position Constants
+const LOGO_BOTTOM_POSITION = 210;
+const GENRES_BOTTOM_POSITION = 170;
+const CONTROLS_BOTTOM_POSITION = 100;
+const DOTS_BOTTOM_POSITION = 60;
+
+// 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 _EMPTY_STATE_ICON_SIZE = 64;
+
+// Spacing Constants
+const HORIZONTAL_PADDING = 40;
+const DOT_PADDING = 2;
+const DOT_GAP = 4;
+const CONTROLS_GAP = 20;
+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 _EMPTY_STATE_FONT_SIZE = 18;
+const TEXT_SHADOW_RADIUS = 2;
+const MAX_GENRES_COUNT = 2;
+const MAX_BUTTON_WIDTH = 300;
+
+// Opacity Constants
+const OVERLAY_OPACITY = 0.4;
+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,
+}) => {
+ const { settings } = useSettings();
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const { isConnected, serverConnected } = useNetworkStatus();
+ const [currentIndex, setCurrentIndex] = useState(initialIndex);
+ const translateX = useSharedValue(-currentIndex * 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"],
+ 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"],
+ 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"],
+ 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 ?? [];
+
+ return [
+ ...continueItems.slice(0, 2),
+ ...nextItems.slice(0, 2),
+ ...recentItems.slice(0, 2),
+ ];
+ }, [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,
+ 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, 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, translateX],
+ );
+
+ 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 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 */}
+
+
+
+
+ {/* 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 && (
+
+
+
+ )}
+
+ {/* Type and Genres Section */}
+
+
+ {(() => {
+ const 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 "";
+ }
+ })()}
+
+
+
+ {/* 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/ItemContent.tsx b/components/ItemContent.tsx
index 4fc41301..070b02e2 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -22,7 +22,7 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
-import { useImageColors } from "@/hooks/useImageColors";
+import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -61,7 +61,7 @@ export const ItemContent: React.FC = React.memo(
const [user] = useAtom(userAtom);
const { t } = useTranslation();
- useImageColors({ item });
+ const itemColors = useImageColorsReturn({ item });
const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350);
@@ -267,6 +267,7 @@ export const ItemContent: React.FC = React.memo(
selectedOptions={selectedOptions}
item={item}
isOffline={isOffline}
+ colors={itemColors}
/>
diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx
index 3f4ca141..6ac1956e 100644
--- a/components/PlayButton.tsx
+++ b/components/PlayButton.tsx
@@ -23,6 +23,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
+import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
@@ -39,6 +40,7 @@ interface Props extends React.ComponentProps {
item: BaseItemDto;
selectedOptions: SelectedOptions;
isOffline?: boolean;
+ colors?: ThemeColors;
}
const ANIMATION_DURATION = 500;
@@ -48,6 +50,7 @@ export const PlayButton: React.FC = ({
item,
selectedOptions,
isOffline,
+ colors,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
@@ -55,16 +58,19 @@ export const PlayButton: React.FC = ({
const mediaStatus = useMediaStatus();
const { t } = useTranslation();
- const [colorAtom] = useAtom(itemThemeColorAtom);
+ const [globalColorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
+ // Use colors prop if provided, otherwise fallback to global atom
+ const effectiveColors = colors || globalColorAtom;
+
const router = useRouter();
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
- const endColor = useSharedValue(colorAtom);
- const startColor = useSharedValue(colorAtom);
+ const endColor = useSharedValue(effectiveColors);
+ const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings, updateSettings } = useSettings();
@@ -297,7 +303,7 @@ export const PlayButton: React.FC = ({
);
useAnimatedReaction(
- () => colorAtom,
+ () => effectiveColors,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -306,19 +312,19 @@ export const PlayButton: React.FC = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
- [colorAtom],
+ [effectiveColors],
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
- startColor.value = colorAtom;
+ startColor.value = effectiveColors;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
- }, [colorAtom, item]);
+ }, [effectiveColors, item]);
/**
* ANIMATED STYLES
@@ -367,7 +373,7 @@ export const PlayButton: React.FC = ({
className={"relative"}
{...props}
>
-
+
= ({
diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx
index b4fa45a9..8e3b9811 100644
--- a/components/PlayButton.tv.tsx
+++ b/components/PlayButton.tv.tsx
@@ -15,6 +15,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
+import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
@@ -24,6 +25,7 @@ import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps {
item: BaseItemDto;
selectedOptions: SelectedOptions;
+ colors?: ThemeColors;
}
const ANIMATION_DURATION = 500;
@@ -32,16 +34,20 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC = ({
item,
selectedOptions,
+ colors,
...props
}: Props) => {
- const [colorAtom] = useAtom(itemThemeColorAtom);
+ const [globalColorAtom] = useAtom(itemThemeColorAtom);
+
+ // Use colors prop if provided, otherwise fallback to global atom
+ const effectiveColors = colors || globalColorAtom;
const router = useRouter();
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
- const endColor = useSharedValue(colorAtom);
- const startColor = useSharedValue(colorAtom);
+ const endColor = useSharedValue(effectiveColors);
+ const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings } = useSettings();
@@ -101,7 +107,7 @@ export const PlayButton: React.FC = ({
);
useAnimatedReaction(
- () => colorAtom,
+ () => effectiveColors,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -110,19 +116,19 @@ export const PlayButton: React.FC = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
- [colorAtom],
+ [effectiveColors],
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
- startColor.value = colorAtom;
+ startColor.value = effectiveColors;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
- }, [colorAtom, item]);
+ }, [effectiveColors, item]);
/**
* ANIMATED STYLES
@@ -189,7 +195,7 @@ export const PlayButton: React.FC = ({
= ({ ...props }) => {
if (!popularItems) return null;
return (
-
+
}
+ scrollAnimationDuration={1000}
/>
= ({ item }) => {
const tap = Gesture.Tap()
.maxDuration(2000)
+ .shouldCancelWhenOutside(true)
.onBegin(() => {
opacity.value = withTiming(0.8, { duration: 100 });
})
@@ -173,25 +176,19 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
-
-
+
+
-
+
{
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)")
- scrollViewRef.current?.scrollTo({ y: -152, animated: true });
+ scrollViewRef.current?.scrollTo({
+ y: Platform.isTV ? -152 : -100,
+ animated: true,
+ });
});
return () => {
@@ -192,9 +195,9 @@ export const HomeIndex = () => {
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
- fields: ["PrimaryImageAspectRatio", "Path"],
+ fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
imageTypeLimit: 1,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes,
parentId,
})
@@ -236,8 +239,9 @@ export const HomeIndex = () => {
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
+ fields: ["Genres"],
})
).data.Items || [],
type: "ScrollingCollectionList",
@@ -250,9 +254,9 @@ export const HomeIndex = () => {
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
- fields: ["MediaSourceCount"],
+ fields: ["MediaSourceCount", "Genres"],
limit: 20,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
})
).data.Items || [],
@@ -334,9 +338,9 @@ export const HomeIndex = () => {
if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
- fields: ["MediaSourceCount"],
+ fields: ["MediaSourceCount", "Genres"],
limit: section.nextUp?.limit || 25,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
+ enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
@@ -443,44 +447,60 @@ export const HomeIndex = () => {
scrollToOverflowEnabled={true}
ref={scrollViewRef}
nestedScrollEnabled
- contentInsetAdjustmentBehavior='automatic'
+ contentInsetAdjustmentBehavior='never'
refreshControl={
-
+
}
- contentContainerStyle={{
- paddingLeft: insets.left,
- paddingRight: insets.right,
- paddingBottom: 16,
- }}
+ style={{ marginTop: Platform.isTV ? 0 : -100 }}
+ contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
>
-
-
-
- {sections.map((section, index) => {
- if (section.type === "ScrollingCollectionList") {
- return (
-
- );
- }
- if (section.type === "MediaListSection") {
- return (
-
- );
- }
- return null;
- })}
+ {
+ console.log(`Now viewing carousel item ${index}`);
+ }}
+ />
+
+
+ {sections.map((section, index) => {
+ if (section.type === "ScrollingCollectionList") {
+ return (
+
+ );
+ }
+ if (section.type === "MediaListSection") {
+ return (
+
+ );
+ }
+ return null;
+ })}
+
+
);
};
diff --git a/hooks/useImageColorsReturn.ts b/hooks/useImageColorsReturn.ts
new file mode 100644
index 00000000..b9c53e61
--- /dev/null
+++ b/hooks/useImageColorsReturn.ts
@@ -0,0 +1,131 @@
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import { useAtomValue } from "jotai";
+import { useEffect, useMemo, useState } from "react";
+import { Platform } from "react-native";
+import { getColors, ImageColorsResult } from "react-native-image-colors";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import {
+ adjustToNearBlack,
+ calculateTextColor,
+ isCloseToBlack,
+} from "@/utils/atoms/primaryColor";
+import { getItemImage } from "@/utils/getItemImage";
+import { storage } from "@/utils/mmkv";
+
+export interface ThemeColors {
+ primary: string;
+ text: string;
+}
+
+const DEFAULT_COLORS: ThemeColors = {
+ primary: "#FFFFFF",
+ text: "#000000",
+};
+
+/**
+ * Custom hook to extract and return image colors for a given item.
+ * Returns colors as state instead of updating global atom.
+ *
+ * @param item - The BaseItemDto object representing the item.
+ * @param disabled - A boolean flag to disable color extraction.
+ * @returns ThemeColors object with primary and text colors
+ */
+export const useImageColorsReturn = ({
+ item,
+ url,
+ disabled,
+}: {
+ item?: BaseItemDto | null;
+ url?: string | null;
+ disabled?: boolean;
+}): ThemeColors => {
+ const api = useAtomValue(apiAtom);
+ const [colors, setColors] = useState(DEFAULT_COLORS);
+
+ const isTv = Platform.isTV;
+
+ const source = useMemo(() => {
+ if (!api) return;
+ if (url) return { uri: url };
+ if (item)
+ return getItemImage({
+ item,
+ api,
+ variant: "Primary",
+ quality: 80,
+ width: 300,
+ });
+ return null;
+ }, [api, item, url]);
+
+ useEffect(() => {
+ // Reset to default colors when item changes
+ if (!item && !url) {
+ setColors(DEFAULT_COLORS);
+ return;
+ }
+
+ if (isTv) return;
+ if (disabled) return;
+ if (source?.uri) {
+ const _primary = storage.getString(`${source.uri}-primary`);
+ const _text = storage.getString(`${source.uri}-text`);
+
+ if (_primary && _text) {
+ setColors({
+ primary: _primary,
+ text: _text,
+ });
+ return;
+ }
+
+ // Extract colors from the image
+ getColors(source.uri, {
+ fallback: "#fff",
+ cache: false,
+ })
+ .then((colors: ImageColorsResult) => {
+ let primary = "#fff";
+ let text = "#000";
+ let backup = "#fff";
+
+ // Select the appropriate color based on the platform
+ if (colors.platform === "android") {
+ primary = colors.dominant;
+ backup = colors.vibrant;
+ } else if (colors.platform === "ios") {
+ primary = colors.detail;
+ backup = colors.primary;
+ }
+
+ // Adjust the primary color if it's too close to black
+ if (primary && isCloseToBlack(primary)) {
+ if (backup && !isCloseToBlack(backup)) primary = backup;
+ primary = adjustToNearBlack(primary);
+ }
+
+ // Calculate the text color based on the primary color
+ if (primary) text = calculateTextColor(primary);
+
+ const newColors = {
+ primary,
+ text,
+ };
+
+ setColors(newColors);
+
+ // Cache the colors in storage
+ if (source.uri && primary) {
+ storage.set(`${source.uri}-primary`, primary);
+ storage.set(`${source.uri}-text`, text);
+ }
+ })
+ .catch((error: any) => {
+ console.error("Error getting colors", error);
+ setColors(DEFAULT_COLORS);
+ });
+ }
+ }, [isTv, source?.uri, disabled, item, url]);
+
+ return colors;
+};