mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Improves type safety throughout the codebase by eliminating unsafe `any` type assertions and replacing them with proper type definitions. Adds explicit type parameters and constraints to MMKV augmentations, component props, and router navigation calls. Updates function signatures to use `unknown` instead of `any` where appropriate, and properly types Icon glyphs, router Href parameters, and component prop spreads. Enhances maintainability and catches potential type errors at compile time rather than runtime.
910 lines
25 KiB
TypeScript
910 lines
25 KiB
TypeScript
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 { type Href, useRouter } from "expo-router";
|
|
import { useAtomValue } from "jotai";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Pressable,
|
|
TouchableOpacity,
|
|
useWindowDimensions,
|
|
View,
|
|
} from "react-native";
|
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
import Animated, {
|
|
Easing,
|
|
interpolate,
|
|
runOnJS,
|
|
type SharedValue,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withTiming,
|
|
} from "react-native-reanimated";
|
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
import { useSettings } from "@/utils/atoms/settings";
|
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
import { ItemImage } from "../common/ItemImage";
|
|
import { getItemNavigation } from "../common/TouchableItemRouter";
|
|
import type { SelectedOptions } from "../ItemContent";
|
|
import { PlayButton } from "../PlayButton";
|
|
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
|
|
|
interface AppleTVCarouselProps {
|
|
initialIndex?: number;
|
|
onItemChange?: (index: number) => void;
|
|
scrollOffset?: SharedValue<number>;
|
|
}
|
|
|
|
// 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 (
|
|
<Pressable
|
|
onPress={() => onPress(index)}
|
|
style={{
|
|
padding: DOT_PADDING, // Increase touch area
|
|
}}
|
|
>
|
|
<Animated.View
|
|
style={[
|
|
{
|
|
height: DOT_HEIGHT,
|
|
backgroundColor: isActive ? "white" : "rgba(255, 255, 255, 0.4)",
|
|
borderRadius: DOT_BORDER_RADIUS,
|
|
},
|
|
animatedStyle,
|
|
]}
|
|
/>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|
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<string>();
|
|
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,
|
|
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 Href);
|
|
},
|
|
[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 (
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: DOTS_BOTTOM_POSITION,
|
|
left: 0,
|
|
right: 0,
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
gap: DOT_GAP,
|
|
}}
|
|
>
|
|
{items.map((_, index) => (
|
|
<DotIndicator
|
|
key={index}
|
|
index={index}
|
|
currentIndex={currentIndex}
|
|
onPress={goToIndex}
|
|
/>
|
|
))}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const renderSkeletonLoader = () => {
|
|
return (
|
|
<View
|
|
style={{
|
|
width: screenWidth,
|
|
height: carouselHeight,
|
|
backgroundColor: "#000",
|
|
}}
|
|
>
|
|
{/* Background Skeleton */}
|
|
<View
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
backgroundColor: SKELETON_BACKGROUND_COLOR,
|
|
position: "absolute",
|
|
}}
|
|
/>
|
|
|
|
{/* Dark Overlay Skeleton */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
|
}}
|
|
/>
|
|
|
|
{/* Gradient Fade to Black Top Skeleton */}
|
|
<LinearGradient
|
|
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.8)", "transparent"]}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
height: GRADIENT_HEIGHT_TOP,
|
|
}}
|
|
/>
|
|
|
|
{/* Gradient Fade to Black Bottom Skeleton */}
|
|
<LinearGradient
|
|
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
height: GRADIENT_HEIGHT_BOTTOM,
|
|
}}
|
|
/>
|
|
|
|
{/* Logo Skeleton */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: LOGO_BOTTOM_POSITION,
|
|
left: 0,
|
|
right: 0,
|
|
paddingHorizontal: HORIZONTAL_PADDING,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
height: LOGO_HEIGHT,
|
|
width: LOGO_WIDTH_PERCENTAGE,
|
|
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
borderRadius: LOGO_SKELETON_BORDER_RADIUS,
|
|
}}
|
|
/>
|
|
</View>
|
|
|
|
{/* Type and Genres Skeleton */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: GENRES_BOTTOM_POSITION,
|
|
left: 0,
|
|
right: 0,
|
|
paddingHorizontal: HORIZONTAL_PADDING,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
height: TEXT_SKELETON_HEIGHT,
|
|
width: TEXT_SKELETON_WIDTH,
|
|
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
}}
|
|
/>
|
|
</View>
|
|
|
|
{/* Overview Skeleton */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: OVERVIEW_BOTTOM_POSITION,
|
|
left: 0,
|
|
right: 0,
|
|
paddingHorizontal: HORIZONTAL_PADDING,
|
|
alignItems: "center",
|
|
gap: 6,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
height: OVERVIEW_SKELETON_HEIGHT,
|
|
width: OVERVIEW_SKELETON_WIDTH,
|
|
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
}}
|
|
/>
|
|
<View
|
|
style={{
|
|
height: OVERVIEW_SKELETON_HEIGHT,
|
|
width: OVERVIEW_SKELETON_WIDTH * 0.7,
|
|
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
}}
|
|
/>
|
|
</View>
|
|
|
|
{/* Controls Skeleton */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: CONTROLS_BOTTOM_POSITION,
|
|
left: 0,
|
|
right: 0,
|
|
paddingHorizontal: HORIZONTAL_PADDING,
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
gap: CONTROLS_GAP,
|
|
}}
|
|
>
|
|
{/* Play Button Skeleton */}
|
|
<View
|
|
style={{
|
|
height: PLAY_BUTTON_SKELETON_HEIGHT,
|
|
flex: 1,
|
|
maxWidth: MAX_BUTTON_WIDTH,
|
|
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
borderRadius: PLAY_BUTTON_BORDER_RADIUS,
|
|
}}
|
|
/>
|
|
|
|
{/* Played Status Skeleton */}
|
|
<View
|
|
style={{
|
|
width: PLAYED_STATUS_SKELETON_SIZE,
|
|
height: PLAYED_STATUS_SKELETON_SIZE,
|
|
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
borderRadius: PLAYED_STATUS_BORDER_RADIUS,
|
|
}}
|
|
/>
|
|
</View>
|
|
|
|
{/* Dots Skeleton */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: DOTS_BOTTOM_POSITION,
|
|
left: 0,
|
|
right: 0,
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
gap: DOT_GAP,
|
|
}}
|
|
>
|
|
{[1, 2, 3].map((_, index) => (
|
|
<View
|
|
key={index}
|
|
style={{
|
|
width: index === 0 ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH,
|
|
height: DOT_HEIGHT,
|
|
backgroundColor:
|
|
index === 0
|
|
? SKELETON_ACTIVE_DOT_COLOR
|
|
: SKELETON_ELEMENT_COLOR,
|
|
borderRadius: DOT_BORDER_RADIUS,
|
|
}}
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const renderItem = (item: BaseItemDto, _index: number) => {
|
|
const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
|
|
|
|
return (
|
|
<View
|
|
key={item.Id}
|
|
style={{
|
|
width: screenWidth,
|
|
height: carouselHeight,
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{/* Background Backdrop */}
|
|
<Animated.View
|
|
style={[
|
|
{
|
|
width: "100%",
|
|
height: "100%",
|
|
position: "absolute",
|
|
},
|
|
headerAnimatedStyle,
|
|
]}
|
|
>
|
|
<ItemImage
|
|
item={item}
|
|
variant='Backdrop'
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
/>
|
|
</Animated.View>
|
|
|
|
{/* Dark Overlay */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
|
}}
|
|
/>
|
|
|
|
{/* Gradient Fade to Black at Top */}
|
|
<LinearGradient
|
|
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.2)", "transparent"]}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
height: GRADIENT_HEIGHT_TOP,
|
|
}}
|
|
/>
|
|
|
|
{/* Gradient Fade to Black at Bottom */}
|
|
<LinearGradient
|
|
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
height: GRADIENT_HEIGHT_BOTTOM,
|
|
}}
|
|
/>
|
|
|
|
{/* Logo Section */}
|
|
{itemLogoUrl && (
|
|
<TouchableOpacity
|
|
onPress={() => navigateToItem(item)}
|
|
style={{
|
|
position: "absolute",
|
|
bottom: LOGO_BOTTOM_POSITION,
|
|
left: 0,
|
|
right: 0,
|
|
paddingHorizontal: HORIZONTAL_PADDING,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Image
|
|
source={{
|
|
uri: itemLogoUrl,
|
|
}}
|
|
style={{
|
|
height: LOGO_HEIGHT,
|
|
width: LOGO_WIDTH_PERCENTAGE,
|
|
}}
|
|
contentFit='contain'
|
|
/>
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
{/* Type and Genres Section */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: GENRES_BOTTOM_POSITION,
|
|
left: 0,
|
|
right: 0,
|
|
paddingHorizontal: HORIZONTAL_PADDING,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
|
<Animated.Text
|
|
style={{
|
|
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
|
|
fontSize: GENRES_FONT_SIZE,
|
|
fontWeight: "500",
|
|
textAlign: "center",
|
|
textShadowColor: TEXT_SHADOW_COLOR,
|
|
textShadowOffset: { width: 0, height: 1 },
|
|
textShadowRadius: TEXT_SHADOW_RADIUS,
|
|
}}
|
|
>
|
|
{(() => {
|
|
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 "";
|
|
}
|
|
})()}
|
|
</Animated.Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Overview Section - for Episodes and Movies */}
|
|
{(item.Type === "Episode" || item.Type === "Movie") &&
|
|
item.Overview && (
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: OVERVIEW_BOTTOM_POSITION,
|
|
left: 0,
|
|
right: 0,
|
|
paddingHorizontal: HORIZONTAL_PADDING,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
|
<Animated.Text
|
|
numberOfLines={OVERVIEW_MAX_LINES}
|
|
style={{
|
|
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
|
|
fontSize: OVERVIEW_FONT_SIZE,
|
|
fontWeight: "400",
|
|
textAlign: "center",
|
|
maxWidth: OVERVIEW_MAX_WIDTH,
|
|
textShadowColor: TEXT_SHADOW_COLOR,
|
|
textShadowOffset: { width: 0, height: 1 },
|
|
textShadowRadius: TEXT_SHADOW_RADIUS,
|
|
}}
|
|
>
|
|
{item.Overview}
|
|
</Animated.Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
{/* Controls Section */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
bottom: CONTROLS_BOTTOM_POSITION,
|
|
left: 0,
|
|
right: 0,
|
|
paddingHorizontal: HORIZONTAL_PADDING,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
gap: CONTROLS_GAP,
|
|
}}
|
|
>
|
|
{/* Play Button */}
|
|
<View style={{ flex: 1, maxWidth: MAX_BUTTON_WIDTH }}>
|
|
{selectedOptions && (
|
|
<PlayButton
|
|
item={item}
|
|
selectedOptions={selectedOptions}
|
|
colors={currentItemColors}
|
|
/>
|
|
)}
|
|
</View>
|
|
|
|
{/* Mark as Played */}
|
|
<MarkAsPlayedLargeButton
|
|
isPlayed={item.UserData?.Played ?? false}
|
|
onToggle={togglePlayedStatus}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// Handle loading state
|
|
if (isLoading) {
|
|
return (
|
|
<View
|
|
style={{
|
|
height: carouselHeight,
|
|
backgroundColor: "#000",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{renderSkeletonLoader()}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Handle empty items
|
|
if (!hasItems) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
height: carouselHeight, // Fixed height instead of flex: 1
|
|
backgroundColor: "#000",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<GestureDetector gesture={panGesture}>
|
|
<Animated.View
|
|
style={[
|
|
{
|
|
height: carouselHeight, // Fixed height instead of flex: 1
|
|
flexDirection: "row",
|
|
width: screenWidth * items.length,
|
|
},
|
|
containerAnimatedStyle,
|
|
]}
|
|
>
|
|
{items.map((item, index) => renderItem(item, index))}
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
|
|
{/* Animated Dots Indicator */}
|
|
{renderDots()}
|
|
</View>
|
|
);
|
|
};
|