mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-19 10:44:18 +01:00
fix: improve carousel behavior and settings option (#1166)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
96d6220f5e
commit
0dadfd3d90
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
Reference in New Issue
Block a user