mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-20 16:26:24 +00:00
Merge branch 'develop' into sonarqube
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
|
||||
@@ -11,6 +11,18 @@ interface Props extends ViewProps {
|
||||
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isFavorite ? "heart" : "heart-outline"}
|
||||
onPress={toggleFavorite}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
|
||||
749
components/AppleTVCarousel.tsx
Normal file
749
components/AppleTVCarousel.tsx
Normal file
@@ -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 (
|
||||
<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,
|
||||
}) => {
|
||||
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 (
|
||||
<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: CAROUSEL_HEIGHT,
|
||||
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>
|
||||
|
||||
{/* 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: CAROUSEL_HEIGHT,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Background Backdrop */}
|
||||
<ItemImage
|
||||
item={item}
|
||||
variant='Backdrop'
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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 && (
|
||||
<View
|
||||
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'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Type and Genres Section */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: GENRES_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
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 "";
|
||||
}
|
||||
})()}
|
||||
</Animated.Text>
|
||||
</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 */}
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Handle loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: CAROUSEL_HEIGHT,
|
||||
backgroundColor: "#000",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{renderSkeletonLoader()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle empty items
|
||||
if (!hasItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
||||
backgroundColor: "#000",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
height: CAROUSEL_HEIGHT, // 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>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
CastContext,
|
||||
@@ -42,6 +42,22 @@ export function Chromecast({
|
||||
[Platform.OS],
|
||||
);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className='mr-4'
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<RoundButton
|
||||
|
||||
@@ -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<ItemContentProps> = 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);
|
||||
@@ -105,13 +105,27 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
if (!Platform.isTV) {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
item &&
|
||||
(Platform.OS === "ios" ? (
|
||||
<View className='flex flex-row items-center pl-2'>
|
||||
<Chromecast.Chromecast width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Chromecast.Chromecast
|
||||
background='blur'
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
<Chromecast.Chromecast width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV && (
|
||||
@@ -126,7 +140,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
)),
|
||||
});
|
||||
}
|
||||
}, [item, navigation, user]);
|
||||
@@ -253,6 +267,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
colors={itemColors}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -24,6 +24,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";
|
||||
@@ -40,6 +41,7 @@ interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
isOffline?: boolean;
|
||||
colors?: ThemeColors;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
@@ -106,6 +108,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
isOffline,
|
||||
colors,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
@@ -113,16 +116,19 @@ export const PlayButton: React.FC<Props> = ({
|
||||
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();
|
||||
@@ -316,7 +322,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => colorAtom,
|
||||
() => effectiveColors,
|
||||
(newColor) => {
|
||||
endColor.value = newColor;
|
||||
colorChangeProgress.value = 0;
|
||||
@@ -325,19 +331,19 @@ export const PlayButton: React.FC<Props> = ({
|
||||
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
|
||||
@@ -386,7 +392,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
className={"relative"}
|
||||
{...props}
|
||||
>
|
||||
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
|
||||
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
@@ -400,15 +406,15 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
<Animated.View
|
||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||
className='absolute w-full h-full top-0 left-0 rounded-xl'
|
||||
className='absolute w-full h-full top-0 left-0 rounded-full'
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorAtom.primary,
|
||||
borderColor: effectiveColors.primary,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
|
||||
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
|
||||
>
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
|
||||
@@ -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<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
colors?: ThemeColors;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
@@ -32,16 +34,20 @@ const MIN_PLAYBACK_WIDTH = 15;
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
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();
|
||||
@@ -100,7 +106,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => colorAtom,
|
||||
() => effectiveColors,
|
||||
(newColor) => {
|
||||
endColor.value = newColor;
|
||||
colorChangeProgress.value = 0;
|
||||
@@ -109,19 +115,19 @@ export const PlayButton: React.FC<Props> = ({
|
||||
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
|
||||
@@ -188,7 +194,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorAtom.primary,
|
||||
borderColor: effectiveColors.primary,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
@@ -14,6 +14,21 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||
const toggle = useMarkAsPlayed(items);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
color={allPlayed ? "purple" : "white"}
|
||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={async () => {
|
||||
await toggle(!allPlayed);
|
||||
}}
|
||||
size={props.size}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
|
||||
@@ -10,6 +10,7 @@ interface Props extends ViewProps {
|
||||
background?: boolean;
|
||||
size?: "default" | "large";
|
||||
fillColor?: "primary";
|
||||
color?: "white" | "purple";
|
||||
hapticFeedback?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
children,
|
||||
size = "default",
|
||||
fillColor,
|
||||
color = "white",
|
||||
hapticFeedback = true,
|
||||
...viewProps
|
||||
}) => {
|
||||
@@ -34,6 +36,25 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
onPress?.();
|
||||
};
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={color === "white" ? "white" : "#9334E9"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (fillColor)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -19,6 +19,18 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className='flex items-center justify-center w-9 h-9'
|
||||
{...touchableOpacityProps}
|
||||
>
|
||||
<Ionicons name='arrow-back' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (background === "transparent" && Platform.OS !== "android")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -89,22 +89,24 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
if (!popularItems) return null;
|
||||
|
||||
return (
|
||||
<View className='flex flex-col items-center mt-2' {...props}>
|
||||
<View className='flex flex-col items-center' {...props}>
|
||||
<Carousel
|
||||
ref={ref}
|
||||
autoPlay={false}
|
||||
loop={true}
|
||||
snapEnabled={true}
|
||||
vertical={false}
|
||||
mode='parallax'
|
||||
modeConfig={{
|
||||
parallaxScrollingScale: 0.86,
|
||||
parallaxScrollingOffset: 100,
|
||||
parallaxScrollingScale: 1,
|
||||
parallaxScrollingOffset: 0,
|
||||
}}
|
||||
width={width}
|
||||
height={204}
|
||||
height={500}
|
||||
data={popularItems}
|
||||
onProgressChange={progress}
|
||||
renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
|
||||
scrollAnimationDuration={1000}
|
||||
/>
|
||||
<Pagination.Basic
|
||||
progress={progress}
|
||||
@@ -160,6 +162,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
|
||||
const tap = Gesture.Tap()
|
||||
.maxDuration(2000)
|
||||
.shouldCancelWhenOutside(true)
|
||||
.onBegin(() => {
|
||||
opacity.value = withTiming(0.8, { duration: 100 });
|
||||
})
|
||||
@@ -174,25 +177,19 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={tap}>
|
||||
<Animated.View
|
||||
style={{
|
||||
opacity: opacity,
|
||||
}}
|
||||
className='px-4'
|
||||
>
|
||||
<View className='relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800'>
|
||||
<Animated.View style={{ opacity }}>
|
||||
<View className='relative flex justify-center overflow-hidden border border-neutral-800'>
|
||||
<Image
|
||||
source={{
|
||||
uri,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 200,
|
||||
borderRadius: 16,
|
||||
height: 500,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
/>
|
||||
<View className='absolute bottom-0 left-0 w-full h-24 p-4 flex items-center'>
|
||||
<View className='absolute bottom-0 left-0 w-full flex items-center'>
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUri,
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
@@ -58,6 +57,7 @@ import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { AppleTVCarousel } from "../AppleTVCarousel";
|
||||
|
||||
type ScrollingCollectionListSection = {
|
||||
type: "ScrollingCollectionList";
|
||||
@@ -133,8 +133,11 @@ export const HomeIndex = () => {
|
||||
const segments = useSegments() as string[];
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||
if (segments[2] === "(home)")
|
||||
scrollViewRef.current?.scrollTo({ y: -152, animated: true });
|
||||
if ((segments as string[])[2] === "(home)")
|
||||
scrollViewRef.current?.scrollTo({
|
||||
y: Platform.isTV ? -152 : -100,
|
||||
animated: true,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -200,9 +203,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,
|
||||
})
|
||||
@@ -244,8 +247,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",
|
||||
@@ -258,9 +262,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 || [],
|
||||
@@ -342,9 +346,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,
|
||||
});
|
||||
@@ -451,44 +455,60 @@ export const HomeIndex = () => {
|
||||
scrollToOverflowEnabled={true}
|
||||
ref={scrollViewRef}
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentInsetAdjustmentBehavior='never'
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||
<RefreshControl
|
||||
refreshing={loading}
|
||||
onRefresh={refetch}
|
||||
tintColor='white' // For iOS
|
||||
colors={["white"]} // For Android
|
||||
progressViewOffset={200} // This offsets the refresh indicator to appear over the carousel
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
style={{ marginTop: Platform.isTV ? 0 : -100 }}
|
||||
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
|
||||
>
|
||||
<View className='flex flex-col space-y-4'>
|
||||
<LargeMovieCarousel />
|
||||
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "ScrollingCollectionList") {
|
||||
return (
|
||||
<ScrollingCollectionList
|
||||
key={`${section.type}-${section.title || "untitled"}-${index}`}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<MediaListSection
|
||||
key={`${section.type}-${index}`}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
<AppleTVCarousel
|
||||
initialIndex={0}
|
||||
onItemChange={(index) => {
|
||||
console.log(`Now viewing carousel item ${index}`);
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col space-y-4'>
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "ScrollingCollectionList") {
|
||||
return (
|
||||
<ScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
<View className='h-24' />
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
254
components/settings/LibraryOptionsSheet.tsx
Normal file
254
components/settings/LibraryOptionsSheet.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface LibraryOptions {
|
||||
display: "row" | "list";
|
||||
imageStyle: "poster" | "cover";
|
||||
showTitles: boolean;
|
||||
showStats: boolean;
|
||||
}
|
||||
|
||||
interface Props extends ViewProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
settings: LibraryOptions;
|
||||
updateSettings: (options: Partial<LibraryOptions>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const OptionGroup: React.FC<{ title: string; children: React.ReactNode }> = ({
|
||||
title,
|
||||
children,
|
||||
}) => (
|
||||
<View className='mb-6'>
|
||||
<Text className='text-lg font-semibold mb-3 text-neutral-300'>{title}</Text>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className='bg-neutral-800 rounded-xl overflow-hidden'
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const OptionItem: React.FC<{
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
isLast?: boolean;
|
||||
}> = ({ label, selected, onPress, disabled: itemDisabled, isLast }) => (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
disabled={itemDisabled}
|
||||
className={`px-4 py-3 flex flex-row items-center justify-between ${
|
||||
itemDisabled ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<Text className='flex-1 text-white'>{label}</Text>
|
||||
{selected ? (
|
||||
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
|
||||
) : (
|
||||
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{!isLast && (
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='bg-neutral-700 mx-4'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const ToggleItem: React.FC<{
|
||||
label: string;
|
||||
value: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
isLast?: boolean;
|
||||
}> = ({ label, value, onToggle, disabled: itemDisabled, isLast }) => (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={onToggle}
|
||||
disabled={itemDisabled}
|
||||
className={`px-4 py-3 flex flex-row items-center justify-between ${
|
||||
itemDisabled ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<Text className='flex-1 text-white'>{label}</Text>
|
||||
<View
|
||||
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
||||
>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
|
||||
value ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{!isLast && (
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='bg-neutral-700 mx-4'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
/**
|
||||
* LibraryOptionsSheet Component
|
||||
*
|
||||
* This component creates a bottom sheet modal for managing library display options.
|
||||
*/
|
||||
export const LibraryOptionsSheet: React.FC<Props> = ({
|
||||
open,
|
||||
setOpen,
|
||||
settings,
|
||||
updateSettings,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const handlePresentModal = useCallback(() => {
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const handleDismissModal = useCallback(() => {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
handlePresentModal();
|
||||
} else {
|
||||
handleDismissModal();
|
||||
}
|
||||
}, [open, handlePresentModal, handleDismissModal]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (disabled) return null;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
enablePanDownToClose
|
||||
enableDismissOnClose
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View
|
||||
className='px-4 pb-8 pt-2'
|
||||
style={{
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
}}
|
||||
>
|
||||
<Text className='font-bold text-2xl mb-6'>
|
||||
{t("library.options.display")}
|
||||
</Text>
|
||||
|
||||
<OptionGroup title={t("library.options.display")}>
|
||||
<OptionItem
|
||||
label={t("library.options.row")}
|
||||
selected={settings.display === "row"}
|
||||
onPress={() => updateSettings({ display: "row" })}
|
||||
/>
|
||||
<OptionItem
|
||||
label={t("library.options.list")}
|
||||
selected={settings.display === "list"}
|
||||
onPress={() => updateSettings({ display: "list" })}
|
||||
isLast
|
||||
/>
|
||||
</OptionGroup>
|
||||
|
||||
<OptionGroup title={t("library.options.image_style")}>
|
||||
<OptionItem
|
||||
label={t("library.options.poster")}
|
||||
selected={settings.imageStyle === "poster"}
|
||||
onPress={() => updateSettings({ imageStyle: "poster" })}
|
||||
/>
|
||||
<OptionItem
|
||||
label={t("library.options.cover")}
|
||||
selected={settings.imageStyle === "cover"}
|
||||
onPress={() => updateSettings({ imageStyle: "cover" })}
|
||||
isLast
|
||||
/>
|
||||
</OptionGroup>
|
||||
|
||||
<OptionGroup title='Options'>
|
||||
<ToggleItem
|
||||
label={t("library.options.show_titles")}
|
||||
value={settings.showTitles}
|
||||
onToggle={() =>
|
||||
updateSettings({ showTitles: !settings.showTitles })
|
||||
}
|
||||
disabled={settings.imageStyle === "poster"}
|
||||
/>
|
||||
<ToggleItem
|
||||
label={t("library.options.show_stats")}
|
||||
value={settings.showStats}
|
||||
onToggle={() =>
|
||||
updateSettings({ showStats: !settings.showStats })
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
</OptionGroup>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect: "none",
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user