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 { useRouter } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; 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 { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { import Animated, {
Easing, Easing,
interpolate,
runOnJS, runOnJS,
type SharedValue,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withTiming, withTiming,
@@ -34,12 +41,10 @@ import { PlayedStatus } from "./PlayedStatus";
interface AppleTVCarouselProps { interface AppleTVCarouselProps {
initialIndex?: number; initialIndex?: number;
onItemChange?: (index: number) => void; onItemChange?: (index: number) => void;
scrollOffset?: SharedValue<number>;
} }
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// Layout Constants // Layout Constants
const CAROUSEL_HEIGHT = screenHeight / 1.45;
const GRADIENT_HEIGHT_TOP = 150; const GRADIENT_HEIGHT_TOP = 150;
const GRADIENT_HEIGHT_BOTTOM = 150; const GRADIENT_HEIGHT_BOTTOM = 150;
const LOGO_HEIGHT = 80; const LOGO_HEIGHT = 80;
@@ -147,14 +152,21 @@ const DotIndicator = ({
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
initialIndex = 0, initialIndex = 0,
onItemChange, onItemChange,
scrollOffset,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const { isConnected, serverConnected } = useNetworkStatus(); const { isConnected, serverConnected } = useNetworkStatus();
const router = useRouter(); 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 [currentIndex, setCurrentIndex] = useState(initialIndex);
const translateX = useSharedValue(-currentIndex * screenWidth); const translateX = useSharedValue(-initialIndex * screenWidth);
const isQueryEnabled = const isQueryEnabled =
!!api && !!user?.Id && isConnected && serverConnected === true; !!api && !!user?.Id && isConnected && serverConnected === true;
@@ -281,7 +293,11 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
translateX.value = -newIndex * screenWidth; translateX.value = -newIndex * screenWidth;
return newIndex; return newIndex;
}); });
}, [hasItems, items, initialIndex, translateX]); }, [hasItems, items, initialIndex, screenWidth, translateX]);
useEffect(() => {
translateX.value = -currentIndex * screenWidth;
}, [currentIndex, screenWidth, translateX]);
useEffect(() => { useEffect(() => {
if (hasItems) { if (hasItems) {
@@ -301,7 +317,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
setCurrentIndex(index); setCurrentIndex(index);
onItemChange?.(index); onItemChange?.(index);
}, },
[hasItems, items, onItemChange, translateX], [hasItems, items, onItemChange, screenWidth, translateX],
); );
const navigateToItem = useCallback( 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 = () => { const renderDots = () => {
if (!hasItems || items.length <= 1) return null; if (!hasItems || items.length <= 1) return null;
@@ -381,7 +419,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
<View <View
style={{ style={{
width: screenWidth, width: screenWidth,
height: CAROUSEL_HEIGHT, height: carouselHeight,
backgroundColor: "#000", backgroundColor: "#000",
}} }}
> >
@@ -549,20 +587,30 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
key={item.Id} key={item.Id}
style={{ style={{
width: screenWidth, width: screenWidth,
height: CAROUSEL_HEIGHT, height: carouselHeight,
position: "relative", position: "relative",
}} }}
> >
{/* Background Backdrop */} {/* Background Backdrop */}
<ItemImage <Animated.View
item={item} style={[
variant='Backdrop' {
style={{ width: "100%",
width: "100%", height: "100%",
height: "100%", position: "absolute",
position: "absolute", },
}} headerAnimatedStyle,
/> ]}
>
<ItemImage
item={item}
variant='Backdrop'
style={{
width: "100%",
height: "100%",
}}
/>
</Animated.View>
{/* Dark Overlay */} {/* Dark Overlay */}
<View <View
@@ -731,7 +779,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
return ( return (
<View <View
style={{ style={{
height: CAROUSEL_HEIGHT, height: carouselHeight,
backgroundColor: "#000", backgroundColor: "#000",
overflow: "hidden", overflow: "hidden",
}} }}
@@ -749,7 +797,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
return ( return (
<View <View
style={{ style={{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1 height: carouselHeight, // Fixed height instead of flex: 1
backgroundColor: "#000", backgroundColor: "#000",
overflow: "hidden", overflow: "hidden",
}} }}
@@ -758,7 +806,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
<Animated.View <Animated.View
style={[ style={[
{ {
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1 height: carouselHeight, // Fixed height instead of flex: 1
flexDirection: "row", flexDirection: "row",
width: screenWidth * items.length, width: screenWidth * items.length,
}, },

View File

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

View File

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

View File

@@ -182,6 +182,7 @@
"VLC_4": "VLC 4 (Experimental + PiP)" "VLC_4": "VLC 4 (Experimental + PiP)"
}, },
"show_custom_menu_links": "Show Custom Menu Links", "show_custom_menu_links": "Show Custom Menu Links",
"show_large_home_carousel": "Show Large Home Carousel",
"hide_libraries": "Hide Libraries", "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.", "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", "disable_haptic_feedback": "Disable Haptic Feedback",

View File

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