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; +};