mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
apple hero changes
This commit is contained in:
parent
c9c4a80387
commit
1ca4e275de
10 changed files with 356 additions and 225 deletions
|
|
@ -477,7 +477,7 @@
|
|||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||
PRODUCT_NAME = Nuvio;
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
@ -508,8 +508,8 @@
|
|||
"-lc++",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuviohub.app;
|
||||
PRODUCT_NAME = Nuvio;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
|
|
|||
|
|
@ -1,101 +1,103 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Nuvio</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.8</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>nuvio</string>
|
||||
<string>com.nuvio.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>exp+nuvio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>23</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_http._tcp</string>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Allow $(PRODUCT_NAME) to access your local network</string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>RCTRootViewBackgroundColor</key>
|
||||
<integer>4278322180</integer>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>SplashScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleDefault</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Dark</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Nuvio</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.8</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>nuvio</string>
|
||||
<string>com.nuvio.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>exp+nuvio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>23</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_http._tcp</string>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Allow $(PRODUCT_NAME) to access your local network</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>This app does not require microphone access.</string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>RCTRootViewBackgroundColor</key>
|
||||
<integer>4278322180</integer>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>SplashScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleDefault</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Dark</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -28,7 +28,7 @@
|
|||
"@react-navigation/native-stack": "^7.3.10",
|
||||
"@react-navigation/stack": "^7.2.10",
|
||||
"@sentry/react-native": "~7.3.0",
|
||||
"@shopify/flash-list": "^2.1.0",
|
||||
"@shopify/flash-list": "^2.2.0",
|
||||
"@shopify/react-native-skia": "2.2.12",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react-native-video": "^5.0.20",
|
||||
|
|
@ -3616,9 +3616,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@shopify/flash-list": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.1.0.tgz",
|
||||
"integrity": "sha512-/EIQlptG456yM5o9qNmNsmaZEFEOGvG3WGyb6GUAxSLlcKUGlPUkPI2NLW5wQSDEY4xSRa5zocUI+9xwmsM4Kg==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.2.0.tgz",
|
||||
"integrity": "sha512-mL61IofcfBNRZ/qazIf+pghGULkcZUQ7EZNldH1JBbIjtDb25ADSiQrt62ZTnRz0H5+bPFEZUmN9+WChHzX8pw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": "*",
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
"@react-navigation/native-stack": "^7.3.10",
|
||||
"@react-navigation/stack": "^7.2.10",
|
||||
"@sentry/react-native": "~7.3.0",
|
||||
"@shopify/flash-list": "^2.1.0",
|
||||
"@shopify/flash-list": "^2.2.0",
|
||||
"@shopify/react-native-skia": "2.2.12",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react-native-video": "^5.0.20",
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const [logoHeights, setLogoHeights] = useState<Record<number, number>>({});
|
||||
const autoPlayTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastInteractionRef = useRef<number>(Date.now());
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||
|
||||
// Trailer state
|
||||
const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
|
||||
|
|
@ -192,9 +193,18 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const thumbnailOpacity = useSharedValue(1);
|
||||
const trailerOpacity = useSharedValue(0);
|
||||
const trailerMuted = settings?.trailerMuted ?? true;
|
||||
const heroOpacity = useSharedValue(0); // Start hidden for smooth fade-in
|
||||
|
||||
// Animated style for trailer container - 60% height with zoom
|
||||
const trailerContainerStyle = useAnimatedStyle(() => {
|
||||
// Fade out trailer during drag with smooth curve (inverse of next image fade)
|
||||
const dragFade = interpolate(
|
||||
dragProgress.value,
|
||||
[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.85, 1],
|
||||
[1, 0.95, 0.88, 0.78, 0.65, 0.5, 0.35, 0.22, 0.08, 0],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
|
@ -202,7 +212,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
right: 0,
|
||||
height: HERO_HEIGHT * 0.9, // 90% of hero height
|
||||
overflow: 'hidden',
|
||||
opacity: trailerOpacity.value,
|
||||
opacity: trailerOpacity.value * dragFade,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -279,6 +289,27 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
setLogoHeights({});
|
||||
}, [items.length]);
|
||||
|
||||
// Mark initial load as complete after a short delay
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setInitialLoadComplete(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Smooth fade-in when content loads
|
||||
useEffect(() => {
|
||||
if (currentItem && !loading) {
|
||||
heroOpacity.value = withDelay(
|
||||
100,
|
||||
withTiming(1, {
|
||||
duration: 500,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [currentItem, loading, heroOpacity]);
|
||||
|
||||
// Stop trailer when screen loses focus
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
|
|
@ -501,6 +532,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
dragProgress.value = 0;
|
||||
setNextIndex(currentIndex);
|
||||
|
||||
// Immediately hide trailer and show thumbnail when index changes
|
||||
trailerOpacity.value = 0;
|
||||
thumbnailOpacity.value = 1;
|
||||
setTrailerPlaying(false);
|
||||
|
||||
// Faster logo fade
|
||||
logoOpacity.value = 0;
|
||||
logoOpacity.value = withDelay(
|
||||
|
|
@ -510,7 +546,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
);
|
||||
}, [currentIndex]);
|
||||
}, [currentIndex, setTrailerPlaying, trailerOpacity, thumbnailOpacity]);
|
||||
|
||||
// Callback for updating interaction time
|
||||
const updateInteractionTime = useCallback(() => {
|
||||
|
|
@ -532,6 +568,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
setNextIndex(index);
|
||||
}, []);
|
||||
|
||||
// Callback to hide trailer when drag starts
|
||||
const hideTrailerOnDrag = useCallback(() => {
|
||||
setTrailerPlaying(false);
|
||||
}, [setTrailerPlaying]);
|
||||
|
||||
// Swipe gesture handler with live preview - only horizontal
|
||||
const panGesture = useMemo(
|
||||
() =>
|
||||
|
|
@ -541,6 +582,8 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
.onStart(() => {
|
||||
// Determine which direction and set preview
|
||||
runOnJS(updateInteractionTime)();
|
||||
// Immediately stop trailer playback when drag starts
|
||||
runOnJS(hideTrailerOnDrag)();
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const translationX = event.translationX;
|
||||
|
|
@ -599,7 +642,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
});
|
||||
}
|
||||
}),
|
||||
[goToPrevious, goToNext, updateInteractionTime, setPreviewIndex, currentIndex, items.length]
|
||||
[goToPrevious, goToNext, updateInteractionTime, setPreviewIndex, hideTrailerOnDrag, currentIndex, items.length]
|
||||
);
|
||||
|
||||
// Animated styles for next image only - smooth crossfade + slide during drag
|
||||
|
|
@ -648,6 +691,13 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
};
|
||||
});
|
||||
|
||||
// Animated style for hero container - smooth fade-in on load
|
||||
const heroContainerStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: heroOpacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const handleDotPress = useCallback((index: number) => {
|
||||
lastInteractionRef.current = Date.now();
|
||||
setCurrentIndex(index);
|
||||
|
|
@ -691,7 +741,8 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
return (
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={[styles.container, { height: HERO_HEIGHT, marginTop: -insets.top }]}
|
||||
entering={initialLoadComplete ? undefined : FadeIn.duration(600).delay(150)}
|
||||
style={[styles.container, heroContainerStyle, { height: HERO_HEIGHT, marginTop: -insets.top }]}
|
||||
>
|
||||
{/* Background Images with Crossfade */}
|
||||
<View style={styles.backgroundContainer}>
|
||||
|
|
|
|||
|
|
@ -176,6 +176,10 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
getItemType={getItemType}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
scrollEventThrottle={16}
|
||||
decelerationRate="fast"
|
||||
scrollEnabled={true}
|
||||
nestedScrollEnabled={true}
|
||||
contentContainerStyle={StyleSheet.flatten([
|
||||
styles.catalogList,
|
||||
{
|
||||
|
|
@ -186,7 +190,6 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
ItemSeparatorComponent={ItemSeparator}
|
||||
onEndReachedThreshold={0.7}
|
||||
onEndReached={() => {}}
|
||||
// FlashList v2 optimizations
|
||||
drawDistance={500}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@ import React, { useEffect } from 'react';
|
|||
import { View, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, Image } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import Reanimated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
import Reanimated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
Easing,
|
||||
|
|
@ -24,7 +23,6 @@ interface LoadingOverlayProps {
|
|||
onClose: () => void;
|
||||
width: number | string;
|
||||
height: number | string;
|
||||
useFastImage?: boolean; // Platform-specific: iOS uses FastImage, Android uses Image
|
||||
}
|
||||
|
||||
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
||||
|
|
@ -37,7 +35,6 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
|||
onClose,
|
||||
width,
|
||||
height,
|
||||
useFastImage = false,
|
||||
}) => {
|
||||
const logoOpacity = useSharedValue(0);
|
||||
const logoScale = useSharedValue(1);
|
||||
|
|
@ -103,19 +100,11 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
|||
opacity: backdropImageOpacityAnim
|
||||
}
|
||||
]}>
|
||||
{useFastImage ? (
|
||||
<FastImage
|
||||
source={{ uri: backdrop }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
source={{ uri: backdrop }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
source={{ uri: backdrop }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
<LinearGradient
|
||||
|
|
@ -145,13 +134,13 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
|||
},
|
||||
logoAnimatedStyle
|
||||
]}>
|
||||
<FastImage
|
||||
<Image
|
||||
source={{ uri: logo }}
|
||||
style={{
|
||||
width: 300,
|
||||
height: 180,
|
||||
}}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Reanimated.View>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -65,113 +65,194 @@ export const useMetadataAssets = (
|
|||
// For TMDB ID tracking
|
||||
const [foundTmdbId, setFoundTmdbId] = useState<string | null>(null);
|
||||
|
||||
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// CRITICAL: AbortController to cancel in-flight requests when component unmounts
|
||||
const abortControllerRef = useRef(new AbortController());
|
||||
|
||||
// Track pending requests to prevent duplicate concurrent API calls
|
||||
const pendingFetchRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
// Cancel any in-flight requests
|
||||
abortControllerRef.current.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
abortControllerRef.current = new AbortController();
|
||||
}, [id, type]);
|
||||
|
||||
// Force reset when preference changes
|
||||
useEffect(() => {
|
||||
// Reset all cached data when preference changes
|
||||
setBannerImage(null);
|
||||
setBannerSource(null);
|
||||
forcedBannerRefreshDone.current = false;
|
||||
if (isMountedRef.current) {
|
||||
setBannerImage(null);
|
||||
setBannerSource(null);
|
||||
forcedBannerRefreshDone.current = false;
|
||||
}
|
||||
}, [settings.logoSourcePreference]);
|
||||
|
||||
// Optimized banner fetching
|
||||
// Optimized banner fetching with race condition fixes
|
||||
const fetchBanner = useCallback(async () => {
|
||||
if (!metadata) return;
|
||||
if (!metadata || !isMountedRef.current) return;
|
||||
|
||||
setLoadingBanner(true);
|
||||
|
||||
|
||||
// If enrichment is disabled, use addon banner and don't fetch from external sources
|
||||
if (!settings.enrichMetadataWithTMDB) {
|
||||
const addonBanner = metadata?.banner || null;
|
||||
if (addonBanner && addonBanner !== bannerImage) {
|
||||
setBannerImage(addonBanner);
|
||||
setBannerSource('default');
|
||||
// Prevent concurrent fetch requests for the same metadata
|
||||
if (pendingFetchRef.current) {
|
||||
try {
|
||||
await pendingFetchRef.current;
|
||||
} catch (error) {
|
||||
// Previous request failed, allow new attempt
|
||||
}
|
||||
setLoadingBanner(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentPreference = settings.logoSourcePreference || 'tmdb';
|
||||
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
||||
const contentType = type === 'series' ? 'tv' : 'movie';
|
||||
|
||||
// Try to get a banner from the preferred source
|
||||
let finalBanner: string | null = null;
|
||||
let bannerSourceType: 'tmdb' | 'default' = 'default';
|
||||
|
||||
// TMDB path only
|
||||
if (currentPreference === 'tmdb') {
|
||||
let tmdbId = null;
|
||||
if (id.startsWith('tmdb:')) {
|
||||
tmdbId = id.split(':')[1];
|
||||
} else if (foundTmdbId) {
|
||||
tmdbId = foundTmdbId;
|
||||
} else if ((metadata as any).tmdbId) {
|
||||
tmdbId = (metadata as any).tmdbId;
|
||||
} else if (imdbId && settings.enrichMetadataWithTMDB) {
|
||||
try {
|
||||
const tmdbService = TMDBService.getInstance();
|
||||
const foundId = await tmdbService.findTMDBIdByIMDB(imdbId);
|
||||
if (foundId) {
|
||||
tmdbId = String(foundId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle error silently
|
||||
}
|
||||
// Create a promise to track this fetch operation
|
||||
const fetchPromise = (async () => {
|
||||
try {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setLoadingBanner(true);
|
||||
}
|
||||
|
||||
if (tmdbId) {
|
||||
try {
|
||||
const tmdbService = TMDBService.getInstance();
|
||||
const endpoint = contentType === 'tv' ? 'tv' : 'movie';
|
||||
|
||||
const details = endpoint === 'movie'
|
||||
? await tmdbService.getMovieDetails(tmdbId)
|
||||
: await tmdbService.getTVShowDetails(Number(tmdbId));
|
||||
|
||||
if (details?.backdrop_path) {
|
||||
finalBanner = tmdbService.getImageUrl(details.backdrop_path);
|
||||
bannerSourceType = 'tmdb';
|
||||
|
||||
// Preload the image
|
||||
if (finalBanner) {
|
||||
FastImage.preload([{ uri: finalBanner }]);
|
||||
// If enrichment is disabled, use addon banner and don't fetch from external sources
|
||||
if (!settings.enrichMetadataWithTMDB) {
|
||||
const addonBanner = metadata?.banner || null;
|
||||
if (isMountedRef.current && addonBanner && addonBanner !== bannerImage) {
|
||||
setBannerImage(addonBanner);
|
||||
setBannerSource('default');
|
||||
}
|
||||
if (isMountedRef.current) {
|
||||
setLoadingBanner(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentPreference = settings.logoSourcePreference || 'tmdb';
|
||||
const contentType = type === 'series' ? 'tv' : 'movie';
|
||||
|
||||
// Collect final state before updating to prevent intermediate null states
|
||||
let finalBanner: string | null = bannerImage; // Start with current to prevent flicker
|
||||
let bannerSourceType: 'tmdb' | 'default' = (bannerSource === 'tmdb' || bannerSource === 'default') ? bannerSource : 'default';
|
||||
|
||||
// TMDB path only
|
||||
if (currentPreference === 'tmdb') {
|
||||
let tmdbId = null;
|
||||
if (id.startsWith('tmdb:')) {
|
||||
tmdbId = id.split(':')[1];
|
||||
} else if (foundTmdbId) {
|
||||
tmdbId = foundTmdbId;
|
||||
} else if ((metadata as any).tmdbId) {
|
||||
tmdbId = (metadata as any).tmdbId;
|
||||
} else if (imdbId && settings.enrichMetadataWithTMDB) {
|
||||
try {
|
||||
const tmdbService = TMDBService.getInstance();
|
||||
const foundId = await tmdbService.findTMDBIdByIMDB(imdbId);
|
||||
if (foundId && isMountedRef.current) {
|
||||
tmdbId = String(foundId);
|
||||
}
|
||||
} catch (error) {
|
||||
// CRITICAL: Don't update state on error if unmounted
|
||||
if (!isMountedRef.current) return;
|
||||
logger.debug('[useMetadataAssets] TMDB ID lookup failed:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle error silently
|
||||
|
||||
if (tmdbId && isMountedRef.current) {
|
||||
try {
|
||||
const tmdbService = TMDBService.getInstance();
|
||||
const endpoint = contentType === 'tv' ? 'tv' : 'movie';
|
||||
|
||||
// Fetch details (AbortSignal will be used for future implementations)
|
||||
const details = endpoint === 'movie'
|
||||
? await tmdbService.getMovieDetails(tmdbId)
|
||||
: await tmdbService.getTVShowDetails(Number(tmdbId));
|
||||
|
||||
// Only update if request wasn't aborted and component is still mounted
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (details?.backdrop_path) {
|
||||
finalBanner = tmdbService.getImageUrl(details.backdrop_path);
|
||||
bannerSourceType = 'tmdb';
|
||||
|
||||
// Preload the image
|
||||
if (finalBanner) {
|
||||
FastImage.preload([{ uri: finalBanner }]);
|
||||
}
|
||||
} else {
|
||||
// TMDB has no backdrop, gracefully fall back
|
||||
finalBanner = metadata?.banner || bannerImage || null;
|
||||
bannerSourceType = 'default';
|
||||
}
|
||||
} catch (error) {
|
||||
// CRITICAL: Check if error is due to abort or actual network error
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
// Request was cancelled, don't update state
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update state if still mounted after error
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
logger.debug('[useMetadataAssets] TMDB details fetch failed:', error);
|
||||
// Keep current banner on error instead of setting to null
|
||||
finalBanner = bannerImage || metadata?.banner || null;
|
||||
bannerSourceType = 'default';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback to metadata banner only
|
||||
if (!finalBanner) {
|
||||
finalBanner = metadata?.banner || null;
|
||||
bannerSourceType = 'default';
|
||||
}
|
||||
|
||||
// CRITICAL: Batch all state updates into a single call to prevent race conditions
|
||||
// This ensures the native view hierarchy doesn't receive conflicting unmount/remount signals
|
||||
if (isMountedRef.current && (finalBanner !== bannerImage || bannerSourceType !== bannerSource)) {
|
||||
setBannerImage(finalBanner);
|
||||
setBannerSource(bannerSourceType);
|
||||
}
|
||||
|
||||
if (isMountedRef.current) {
|
||||
forcedBannerRefreshDone.current = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Outer catch for any unexpected errors
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
logger.error('[useMetadataAssets] Unexpected error in banner fetch:', error);
|
||||
// Use current banner on error, don't set to null
|
||||
const defaultBanner = bannerImage || metadata?.banner || null;
|
||||
if (defaultBanner !== bannerImage) {
|
||||
setBannerImage(defaultBanner);
|
||||
setBannerSource('default');
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoadingBanner(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pendingFetchRef.current = null;
|
||||
}
|
||||
|
||||
// Final fallback to metadata banner only
|
||||
if (!finalBanner) {
|
||||
finalBanner = metadata?.banner || null;
|
||||
bannerSourceType = 'default';
|
||||
}
|
||||
|
||||
// Update state if the banner changed
|
||||
if (finalBanner !== bannerImage || bannerSourceType !== bannerSource) {
|
||||
setBannerImage(finalBanner);
|
||||
setBannerSource(bannerSourceType);
|
||||
}
|
||||
|
||||
forcedBannerRefreshDone.current = true;
|
||||
} catch (error) {
|
||||
// Use default banner on error (only addon banner)
|
||||
const defaultBanner = metadata?.banner || null;
|
||||
if (defaultBanner !== bannerImage) {
|
||||
setBannerImage(defaultBanner);
|
||||
setBannerSource('default');
|
||||
}
|
||||
} finally {
|
||||
setLoadingBanner(false);
|
||||
}
|
||||
})();
|
||||
|
||||
pendingFetchRef.current = fetchPromise;
|
||||
return fetchPromise;
|
||||
}, [metadata, id, type, imdbId, settings.logoSourcePreference, settings.tmdbLanguagePreference, settings.enrichMetadataWithTMDB, foundTmdbId, bannerImage, bannerSource]);
|
||||
|
||||
// Fetch banner when needed
|
||||
useEffect(() => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const currentPreference = settings.logoSourcePreference || 'tmdb';
|
||||
|
||||
if (bannerSource !== currentPreference && !forcedBannerRefreshDone.current) {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
InteractionManager,
|
||||
AppState
|
||||
} from 'react-native';
|
||||
import { LegendList } from '@legendapp/list';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
|
|
@ -841,18 +841,18 @@ const HomeScreen = () => {
|
|||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
<LegendList
|
||||
<FlashList
|
||||
data={listData}
|
||||
renderItem={renderListItem}
|
||||
keyExtractor={keyExtractor}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={false}
|
||||
scrollEventThrottle={16}
|
||||
nestedScrollEnabled={true}
|
||||
ListHeaderComponent={memoizedHeader}
|
||||
ListFooterComponent={ListFooterComponent}
|
||||
onEndReached={handleLoadMoreCatalogs}
|
||||
onEndReachedThreshold={0.6}
|
||||
recycleItems={true}
|
||||
maintainVisibleContentPosition={Platform.OS !== 'ios'} // Disable on iOS to prevent shifting
|
||||
onScroll={handleScroll}
|
||||
/>
|
||||
{/* Toasts are rendered globally at root */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue