mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-22 17:22:03 +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";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||||
PRODUCT_NAME = Nuvio;
|
PRODUCT_NAME = "Nuvio";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|
@ -508,8 +508,8 @@
|
||||||
"-lc++",
|
"-lc++",
|
||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuviohub.app;
|
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
|
||||||
PRODUCT_NAME = Nuvio;
|
PRODUCT_NAME = "Nuvio";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,103 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Nuvio</string>
|
<string>Nuvio</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.2.8</string>
|
<string>1.2.8</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>nuvio</string>
|
<string>nuvio</string>
|
||||||
<string>com.nuvio.app</string>
|
<string>com.nuvio.app</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>exp+nuvio</string>
|
<string>exp+nuvio</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>23</string>
|
<string>23</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSBonjourServices</key>
|
<key>NSBonjourServices</key>
|
||||||
<array>
|
<array>
|
||||||
<string>_http._tcp</string>
|
<string>_http._tcp</string>
|
||||||
<string>_googlecast._tcp</string>
|
<string>_googlecast._tcp</string>
|
||||||
<string>_CC1AD845._googlecast._tcp</string>
|
<string>_CC1AD845._googlecast._tcp</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Allow $(PRODUCT_NAME) to access your local network</string>
|
<string>Allow $(PRODUCT_NAME) to access your local network</string>
|
||||||
<key>RCTNewArchEnabled</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<true/>
|
<string>This app does not require microphone access.</string>
|
||||||
<key>RCTRootViewBackgroundColor</key>
|
<key>RCTNewArchEnabled</key>
|
||||||
<integer>4278322180</integer>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>RCTRootViewBackgroundColor</key>
|
||||||
<array>
|
<integer>4278322180</integer>
|
||||||
<string>audio</string>
|
<key>UIBackgroundModes</key>
|
||||||
</array>
|
<array>
|
||||||
<key>UIFileSharingEnabled</key>
|
<string>audio</string>
|
||||||
<true/>
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UIFileSharingEnabled</key>
|
||||||
<string>SplashScreen</string>
|
<true/>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<array>
|
<string>SplashScreen</string>
|
||||||
<string>arm64</string>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
</array>
|
<array>
|
||||||
<key>UIRequiresFullScreen</key>
|
<string>arm64</string>
|
||||||
<true/>
|
</array>
|
||||||
<key>UIStatusBarStyle</key>
|
<key>UIRequiresFullScreen</key>
|
||||||
<string>UIStatusBarStyleDefault</string>
|
<true/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UIStatusBarStyle</key>
|
||||||
<array>
|
<string>UIStatusBarStyleDefault</string>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<array>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
</array>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
<array>
|
</array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<array>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
</array>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<key>UIUserInterfaceStyle</key>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
<string>Dark</string>
|
</array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIUserInterfaceStyle</key>
|
||||||
<false/>
|
<string>Dark</string>
|
||||||
</dict>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict/>
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array/>
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -28,7 +28,7 @@
|
||||||
"@react-navigation/native-stack": "^7.3.10",
|
"@react-navigation/native-stack": "^7.3.10",
|
||||||
"@react-navigation/stack": "^7.2.10",
|
"@react-navigation/stack": "^7.2.10",
|
||||||
"@sentry/react-native": "~7.3.0",
|
"@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",
|
"@shopify/react-native-skia": "2.2.12",
|
||||||
"@types/lodash": "^4.17.16",
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/react-native-video": "^5.0.20",
|
"@types/react-native-video": "^5.0.20",
|
||||||
|
|
@ -3616,9 +3616,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@shopify/flash-list": {
|
"node_modules/@shopify/flash-list": {
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.2.0.tgz",
|
||||||
"integrity": "sha512-/EIQlptG456yM5o9qNmNsmaZEFEOGvG3WGyb6GUAxSLlcKUGlPUkPI2NLW5wQSDEY4xSRa5zocUI+9xwmsM4Kg==",
|
"integrity": "sha512-mL61IofcfBNRZ/qazIf+pghGULkcZUQ7EZNldH1JBbIjtDb25ADSiQrt62ZTnRz0H5+bPFEZUmN9+WChHzX8pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@babel/runtime": "*",
|
"@babel/runtime": "*",
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
"@react-navigation/native-stack": "^7.3.10",
|
"@react-navigation/native-stack": "^7.3.10",
|
||||||
"@react-navigation/stack": "^7.2.10",
|
"@react-navigation/stack": "^7.2.10",
|
||||||
"@sentry/react-native": "~7.3.0",
|
"@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",
|
"@shopify/react-native-skia": "2.2.12",
|
||||||
"@types/lodash": "^4.17.16",
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/react-native-video": "^5.0.20",
|
"@types/react-native-video": "^5.0.20",
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
const [logoHeights, setLogoHeights] = useState<Record<number, number>>({});
|
const [logoHeights, setLogoHeights] = useState<Record<number, number>>({});
|
||||||
const autoPlayTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const autoPlayTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastInteractionRef = useRef<number>(Date.now());
|
const lastInteractionRef = useRef<number>(Date.now());
|
||||||
|
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||||
|
|
||||||
// Trailer state
|
// Trailer state
|
||||||
const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
|
const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
|
||||||
|
|
@ -192,9 +193,18 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
const thumbnailOpacity = useSharedValue(1);
|
const thumbnailOpacity = useSharedValue(1);
|
||||||
const trailerOpacity = useSharedValue(0);
|
const trailerOpacity = useSharedValue(0);
|
||||||
const trailerMuted = settings?.trailerMuted ?? true;
|
const trailerMuted = settings?.trailerMuted ?? true;
|
||||||
|
const heroOpacity = useSharedValue(0); // Start hidden for smooth fade-in
|
||||||
|
|
||||||
// Animated style for trailer container - 60% height with zoom
|
// Animated style for trailer container - 60% height with zoom
|
||||||
const trailerContainerStyle = useAnimatedStyle(() => {
|
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 {
|
return {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|
@ -202,7 +212,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
right: 0,
|
right: 0,
|
||||||
height: HERO_HEIGHT * 0.9, // 90% of hero height
|
height: HERO_HEIGHT * 0.9, // 90% of hero height
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
opacity: trailerOpacity.value,
|
opacity: trailerOpacity.value * dragFade,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -279,6 +289,27 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
setLogoHeights({});
|
setLogoHeights({});
|
||||||
}, [items.length]);
|
}, [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
|
// Stop trailer when screen loses focus
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFocused) {
|
if (!isFocused) {
|
||||||
|
|
@ -501,6 +532,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
dragProgress.value = 0;
|
dragProgress.value = 0;
|
||||||
setNextIndex(currentIndex);
|
setNextIndex(currentIndex);
|
||||||
|
|
||||||
|
// Immediately hide trailer and show thumbnail when index changes
|
||||||
|
trailerOpacity.value = 0;
|
||||||
|
thumbnailOpacity.value = 1;
|
||||||
|
setTrailerPlaying(false);
|
||||||
|
|
||||||
// Faster logo fade
|
// Faster logo fade
|
||||||
logoOpacity.value = 0;
|
logoOpacity.value = 0;
|
||||||
logoOpacity.value = withDelay(
|
logoOpacity.value = withDelay(
|
||||||
|
|
@ -510,7 +546,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
easing: Easing.out(Easing.cubic),
|
easing: Easing.out(Easing.cubic),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}, [currentIndex]);
|
}, [currentIndex, setTrailerPlaying, trailerOpacity, thumbnailOpacity]);
|
||||||
|
|
||||||
// Callback for updating interaction time
|
// Callback for updating interaction time
|
||||||
const updateInteractionTime = useCallback(() => {
|
const updateInteractionTime = useCallback(() => {
|
||||||
|
|
@ -532,6 +568,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
setNextIndex(index);
|
setNextIndex(index);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Callback to hide trailer when drag starts
|
||||||
|
const hideTrailerOnDrag = useCallback(() => {
|
||||||
|
setTrailerPlaying(false);
|
||||||
|
}, [setTrailerPlaying]);
|
||||||
|
|
||||||
// Swipe gesture handler with live preview - only horizontal
|
// Swipe gesture handler with live preview - only horizontal
|
||||||
const panGesture = useMemo(
|
const panGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -541,6 +582,8 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
// Determine which direction and set preview
|
// Determine which direction and set preview
|
||||||
runOnJS(updateInteractionTime)();
|
runOnJS(updateInteractionTime)();
|
||||||
|
// Immediately stop trailer playback when drag starts
|
||||||
|
runOnJS(hideTrailerOnDrag)();
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
const translationX = event.translationX;
|
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
|
// 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) => {
|
const handleDotPress = useCallback((index: number) => {
|
||||||
lastInteractionRef.current = Date.now();
|
lastInteractionRef.current = Date.now();
|
||||||
setCurrentIndex(index);
|
setCurrentIndex(index);
|
||||||
|
|
@ -691,7 +741,8 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={panGesture}>
|
<GestureDetector gesture={panGesture}>
|
||||||
<Animated.View
|
<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 */}
|
{/* Background Images with Crossfade */}
|
||||||
<View style={styles.backgroundContainer}>
|
<View style={styles.backgroundContainer}>
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,10 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
||||||
getItemType={getItemType}
|
getItemType={getItemType}
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
decelerationRate="fast"
|
||||||
|
scrollEnabled={true}
|
||||||
|
nestedScrollEnabled={true}
|
||||||
contentContainerStyle={StyleSheet.flatten([
|
contentContainerStyle={StyleSheet.flatten([
|
||||||
styles.catalogList,
|
styles.catalogList,
|
||||||
{
|
{
|
||||||
|
|
@ -186,7 +190,6 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
||||||
ItemSeparatorComponent={ItemSeparator}
|
ItemSeparatorComponent={ItemSeparator}
|
||||||
onEndReachedThreshold={0.7}
|
onEndReachedThreshold={0.7}
|
||||||
onEndReached={() => {}}
|
onEndReached={() => {}}
|
||||||
// FlashList v2 optimizations
|
|
||||||
drawDistance={500}
|
drawDistance={500}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import React, { useEffect } from 'react';
|
||||||
import { View, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, Image } from 'react-native';
|
import { View, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, Image } from 'react-native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import FastImage from '@d11/react-native-fast-image';
|
|
||||||
import Reanimated, {
|
import Reanimated, {
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
|
|
@ -24,7 +23,6 @@ interface LoadingOverlayProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
width: number | string;
|
width: number | string;
|
||||||
height: number | string;
|
height: number | string;
|
||||||
useFastImage?: boolean; // Platform-specific: iOS uses FastImage, Android uses Image
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
||||||
|
|
@ -37,7 +35,6 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
||||||
onClose,
|
onClose,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
useFastImage = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const logoOpacity = useSharedValue(0);
|
const logoOpacity = useSharedValue(0);
|
||||||
const logoScale = useSharedValue(1);
|
const logoScale = useSharedValue(1);
|
||||||
|
|
@ -103,19 +100,11 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
||||||
opacity: backdropImageOpacityAnim
|
opacity: backdropImageOpacityAnim
|
||||||
}
|
}
|
||||||
]}>
|
]}>
|
||||||
{useFastImage ? (
|
<Image
|
||||||
<FastImage
|
source={{ uri: backdrop }}
|
||||||
source={{ uri: backdrop }}
|
style={StyleSheet.absoluteFillObject}
|
||||||
style={StyleSheet.absoluteFillObject}
|
resizeMode="cover"
|
||||||
resizeMode={FastImage.resizeMode.cover}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Image
|
|
||||||
source={{ uri: backdrop }}
|
|
||||||
style={StyleSheet.absoluteFillObject}
|
|
||||||
resizeMode="cover"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
|
|
@ -145,13 +134,13 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
||||||
},
|
},
|
||||||
logoAnimatedStyle
|
logoAnimatedStyle
|
||||||
]}>
|
]}>
|
||||||
<FastImage
|
<Image
|
||||||
source={{ uri: logo }}
|
source={{ uri: logo }}
|
||||||
style={{
|
style={{
|
||||||
width: 300,
|
width: 300,
|
||||||
height: 180,
|
height: 180,
|
||||||
}}
|
}}
|
||||||
resizeMode={FastImage.resizeMode.contain}
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
</Reanimated.View>
|
</Reanimated.View>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -65,113 +65,194 @@ export const useMetadataAssets = (
|
||||||
// For TMDB ID tracking
|
// For TMDB ID tracking
|
||||||
const [foundTmdbId, setFoundTmdbId] = useState<string | null>(null);
|
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
|
// Force reset when preference changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset all cached data when preference changes
|
// Reset all cached data when preference changes
|
||||||
setBannerImage(null);
|
if (isMountedRef.current) {
|
||||||
setBannerSource(null);
|
setBannerImage(null);
|
||||||
forcedBannerRefreshDone.current = false;
|
setBannerSource(null);
|
||||||
|
forcedBannerRefreshDone.current = false;
|
||||||
|
}
|
||||||
}, [settings.logoSourcePreference]);
|
}, [settings.logoSourcePreference]);
|
||||||
|
|
||||||
// Optimized banner fetching
|
// Optimized banner fetching with race condition fixes
|
||||||
const fetchBanner = useCallback(async () => {
|
const fetchBanner = useCallback(async () => {
|
||||||
if (!metadata) return;
|
if (!metadata || !isMountedRef.current) return;
|
||||||
|
|
||||||
setLoadingBanner(true);
|
// Prevent concurrent fetch requests for the same metadata
|
||||||
|
if (pendingFetchRef.current) {
|
||||||
|
try {
|
||||||
// If enrichment is disabled, use addon banner and don't fetch from external sources
|
await pendingFetchRef.current;
|
||||||
if (!settings.enrichMetadataWithTMDB) {
|
} catch (error) {
|
||||||
const addonBanner = metadata?.banner || null;
|
// Previous request failed, allow new attempt
|
||||||
if (addonBanner && addonBanner !== bannerImage) {
|
|
||||||
setBannerImage(addonBanner);
|
|
||||||
setBannerSource('default');
|
|
||||||
}
|
}
|
||||||
setLoadingBanner(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Create a promise to track this fetch operation
|
||||||
const currentPreference = settings.logoSourcePreference || 'tmdb';
|
const fetchPromise = (async () => {
|
||||||
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
try {
|
||||||
const contentType = type === 'series' ? 'tv' : 'movie';
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
// Try to get a banner from the preferred source
|
if (isMountedRef.current) {
|
||||||
let finalBanner: string | null = null;
|
setLoadingBanner(true);
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tmdbId) {
|
// If enrichment is disabled, use addon banner and don't fetch from external sources
|
||||||
try {
|
if (!settings.enrichMetadataWithTMDB) {
|
||||||
const tmdbService = TMDBService.getInstance();
|
const addonBanner = metadata?.banner || null;
|
||||||
const endpoint = contentType === 'tv' ? 'tv' : 'movie';
|
if (isMountedRef.current && addonBanner && addonBanner !== bannerImage) {
|
||||||
|
setBannerImage(addonBanner);
|
||||||
|
setBannerSource('default');
|
||||||
|
}
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setLoadingBanner(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const details = endpoint === 'movie'
|
try {
|
||||||
? await tmdbService.getMovieDetails(tmdbId)
|
const currentPreference = settings.logoSourcePreference || 'tmdb';
|
||||||
: await tmdbService.getTVShowDetails(Number(tmdbId));
|
const contentType = type === 'series' ? 'tv' : 'movie';
|
||||||
|
|
||||||
if (details?.backdrop_path) {
|
// Collect final state before updating to prevent intermediate null states
|
||||||
finalBanner = tmdbService.getImageUrl(details.backdrop_path);
|
let finalBanner: string | null = bannerImage; // Start with current to prevent flicker
|
||||||
bannerSourceType = 'tmdb';
|
let bannerSourceType: 'tmdb' | 'default' = (bannerSource === 'tmdb' || bannerSource === 'default') ? bannerSource : 'default';
|
||||||
|
|
||||||
// Preload the image
|
// TMDB path only
|
||||||
if (finalBanner) {
|
if (currentPreference === 'tmdb') {
|
||||||
FastImage.preload([{ uri: finalBanner }]);
|
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
|
pendingFetchRef.current = fetchPromise;
|
||||||
if (!finalBanner) {
|
return fetchPromise;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, [metadata, id, type, imdbId, settings.logoSourcePreference, settings.tmdbLanguagePreference, settings.enrichMetadataWithTMDB, foundTmdbId, bannerImage, bannerSource]);
|
}, [metadata, id, type, imdbId, settings.logoSourcePreference, settings.tmdbLanguagePreference, settings.enrichMetadataWithTMDB, foundTmdbId, bannerImage, bannerSource]);
|
||||||
|
|
||||||
// Fetch banner when needed
|
// Fetch banner when needed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
const currentPreference = settings.logoSourcePreference || 'tmdb';
|
const currentPreference = settings.logoSourcePreference || 'tmdb';
|
||||||
|
|
||||||
if (bannerSource !== currentPreference && !forcedBannerRefreshDone.current) {
|
if (bannerSource !== currentPreference && !forcedBannerRefreshDone.current) {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import {
|
||||||
InteractionManager,
|
InteractionManager,
|
||||||
AppState
|
AppState
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { LegendList } from '@legendapp/list';
|
import { FlashList } from '@shopify/flash-list';
|
||||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||||
import { NavigationProp } from '@react-navigation/native';
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
|
@ -841,18 +841,18 @@ const HomeScreen = () => {
|
||||||
backgroundColor="transparent"
|
backgroundColor="transparent"
|
||||||
translucent
|
translucent
|
||||||
/>
|
/>
|
||||||
<LegendList
|
<FlashList
|
||||||
data={listData}
|
data={listData}
|
||||||
renderItem={renderListItem}
|
renderItem={renderListItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
contentContainerStyle={contentContainerStyle}
|
contentContainerStyle={contentContainerStyle}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
nestedScrollEnabled={true}
|
||||||
ListHeaderComponent={memoizedHeader}
|
ListHeaderComponent={memoizedHeader}
|
||||||
ListFooterComponent={ListFooterComponent}
|
ListFooterComponent={ListFooterComponent}
|
||||||
onEndReached={handleLoadMoreCatalogs}
|
onEndReached={handleLoadMoreCatalogs}
|
||||||
onEndReachedThreshold={0.6}
|
onEndReachedThreshold={0.6}
|
||||||
recycleItems={true}
|
|
||||||
maintainVisibleContentPosition={Platform.OS !== 'ios'} // Disable on iOS to prevent shifting
|
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
/>
|
/>
|
||||||
{/* Toasts are rendered globally at root */}
|
{/* Toasts are rendered globally at root */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue