fix: improve carousel behavior and settings option (#1166)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Fredrik Burmester
2025-11-07 15:14:35 +01:00
committed by GitHub
parent 96d6220f5e
commit 0dadfd3d90
5 changed files with 110 additions and 33 deletions

View File

@@ -10,11 +10,18 @@ import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
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,
@@ -34,12 +41,10 @@ import { PlayedStatus } from "./PlayedStatus";
interface AppleTVCarouselProps {
initialIndex?: number;
onItemChange?: (index: number) => void;
scrollOffset?: SharedValue<number>;
}
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;
@@ -147,14 +152,21 @@ const DotIndicator = ({
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(-currentIndex * screenWidth);
const translateX = useSharedValue(-initialIndex * screenWidth);
const isQueryEnabled =
!!api && !!user?.Id && isConnected && serverConnected === true;
@@ -281,7 +293,11 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
translateX.value = -newIndex * screenWidth;
return newIndex;
});
}, [hasItems, items, initialIndex, translateX]);
}, [hasItems, items, initialIndex, screenWidth, translateX]);
useEffect(() => {
translateX.value = -currentIndex * screenWidth;
}, [currentIndex, screenWidth, translateX]);
useEffect(() => {
if (hasItems) {
@@ -301,7 +317,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
setCurrentIndex(index);
onItemChange?.(index);
},
[hasItems, items, onItemChange, translateX],
[hasItems, items, onItemChange, screenWidth, translateX],
);
const navigateToItem = useCallback(
@@ -348,6 +364,28 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
};
});
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;
@@ -381,7 +419,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
<View
style={{
width: screenWidth,
height: CAROUSEL_HEIGHT,
height: carouselHeight,
backgroundColor: "#000",
}}
>
@@ -549,20 +587,30 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
key={item.Id}
style={{
width: screenWidth,
height: CAROUSEL_HEIGHT,
height: carouselHeight,
position: "relative",
}}
>
{/* Background Backdrop */}
<ItemImage
item={item}
variant='Backdrop'
style={{
width: "100%",
height: "100%",
position: "absolute",
}}
/>
<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
@@ -731,7 +779,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
return (
<View
style={{
height: CAROUSEL_HEIGHT,
height: carouselHeight,
backgroundColor: "#000",
overflow: "hidden",
}}
@@ -749,7 +797,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
return (
<View
style={{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
height: carouselHeight, // Fixed height instead of flex: 1
backgroundColor: "#000",
overflow: "hidden",
}}
@@ -758,7 +806,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
<Animated.View
style={[
{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
height: carouselHeight, // Fixed height instead of flex: 1
flexDirection: "row",
width: screenWidth * items.length,
},

View File

@@ -21,10 +21,13 @@ import {
ActivityIndicator,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import Animated, {
useAnimatedRef,
useScrollViewOffset,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
@@ -64,14 +67,21 @@ export const HomeIndex = () => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? true;
const headerOverlayOffset = Platform.isTV
? 0
: showLargeHomeCarousel
? 60
: 0;
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const scrollViewRef = useRef<ScrollView>(null);
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(animatedScrollRef);
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
@@ -127,7 +137,7 @@ export const HomeIndex = () => {
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)")
scrollViewRef.current?.scrollTo({
animatedScrollRef.current?.scrollTo({
y: Platform.isTV ? -152 : -100,
animated: true,
});
@@ -456,29 +466,37 @@ export const HomeIndex = () => {
);
return (
<ScrollView
<Animated.ScrollView
scrollToOverflowEnabled={true}
ref={scrollViewRef}
ref={animatedScrollRef}
nestedScrollEnabled
contentInsetAdjustmentBehavior='never'
scrollEventThrottle={16}
refreshControl={
<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
progressViewOffset={showLargeHomeCarousel ? 200 : 0} // This offsets the refresh indicator to appear over the carousel
/>
}
style={{ marginTop: Platform.isTV ? 0 : -100 }}
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
style={{ marginTop: -headerOverlayOffset }}
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
>
<AppleTVCarousel initialIndex={0} />
{showLargeHomeCarousel && (
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
)}
<View
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
paddingTop: Platform.isTV
? 0
: showLargeHomeCarousel
? 0
: insets.top + 60,
}}
>
<View className='flex flex-col space-y-4'>
@@ -509,7 +527,7 @@ export const HomeIndex = () => {
</View>
</View>
<View className='h-24' />
</ScrollView>
</Animated.ScrollView>
);
};

View File

@@ -205,6 +205,14 @@ export const OtherSettings: React.FC = () => {
}
/>
</ListItem>
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
<Switch
value={settings.showLargeHomeCarousel}
onValueChange={(value) =>
updateSettings({ showLargeHomeCarousel: value })
}
/>
</ListItem>
<ListItem
onPress={() => router.push("/settings/hide-libraries/page")}
title={t("home.settings.other.hide_libraries")}

View File

@@ -182,6 +182,7 @@
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Show Custom Menu Links",
"show_large_home_carousel": "Show Large Home Carousel",
"hide_libraries": "Hide Libraries",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback",

View File

@@ -188,6 +188,7 @@ export type Settings = {
enableLeftSideBrightnessSwipe: boolean;
enableRightSideVolumeSwipe: boolean;
usePopularPlugin: boolean;
showLargeHomeCarousel: boolean;
};
export interface Lockable<T> {
@@ -256,6 +257,7 @@ export const defaultValues: Settings = {
enableLeftSideBrightnessSwipe: true,
enableRightSideVolumeSwipe: true,
usePopularPlugin: true,
showLargeHomeCarousel: true,
};
const loadSettings = (): Partial<Settings> => {