Merge branch 'main' of https://github.com/qarqun/NuvioStreaming
This commit is contained in:
commit
f895428e3d
31 changed files with 3695 additions and 1218 deletions
1
TrailerService
Submodule
1
TrailerService
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 2cb2c6d1a3ca60416160bdb28be800fe249207fc
|
||||
|
|
@ -4,6 +4,7 @@ module.exports = function (api) {
|
|||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
'react-native-worklets/plugin',
|
||||
'react-native-boost/plugin',
|
||||
],
|
||||
env: {
|
||||
production: {
|
||||
|
|
|
|||
1
enginefs
Submodule
1
enginefs
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 3a70b36f873307cd83fb3178bb891f73cf73aa87
|
||||
|
|
@ -460,8 +460,8 @@
|
|||
"-lc++",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuviohub.app;
|
||||
PRODUCT_NAME = Nuvio;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
@ -492,8 +492,8 @@
|
|||
"-lc++",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuviohub.app;
|
||||
PRODUCT_NAME = Nuvio;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@
|
|||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
buildConfiguration = "Debug"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
|
|
|||
|
|
@ -1,101 +1,98 @@
|
|||
<?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.5</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>20</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>
|
||||
</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>
|
||||
<false/>
|
||||
<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.5</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>20</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>
|
||||
</array>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>RCTRootViewBackgroundColor</key>
|
||||
<integer>4278322180</integer>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>SplashScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<false/>
|
||||
<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,10 +1,5 @@
|
|||
<?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>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
<dict/>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
<?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>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
<dict/>
|
||||
</plist>
|
||||
|
|
|
|||
25
package-lock.json
generated
25
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.0.2",
|
||||
"@shopify/flash-list": "^2.1.0",
|
||||
"@supabase/supabase-js": "^2.54.0",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react-native-video": "^5.0.20",
|
||||
|
|
@ -2813,6 +2813,19 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@legendapp/list": {
|
||||
"version": "2.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@legendapp/list/-/list-2.0.13.tgz",
|
||||
"integrity": "sha512-OL9rvxRDDqiQ07+QhldcRqCX5+VihtXbbZaoey0TVWJqQN5XPh9b9Buefax3/HjNRzCaYTx1lCoeW5dz20j+cA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@lottiefiles/dotlottie-react": {
|
||||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.6.5.tgz",
|
||||
|
|
@ -3589,13 +3602,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@shopify/flash-list": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.0.2.tgz",
|
||||
"integrity": "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.1.0.tgz",
|
||||
"integrity": "sha512-/EIQlptG456yM5o9qNmNsmaZEFEOGvG3WGyb6GUAxSLlcKUGlPUkPI2NLW5wQSDEY4xSRa5zocUI+9xwmsM4Kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": "*",
|
||||
"react": "*",
|
||||
|
|
@ -12949,6 +12959,7 @@
|
|||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@legendapp/list": "^2.0.13",
|
||||
"@lottiefiles/dotlottie-react": "^0.6.5",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
|
|
@ -28,7 +29,7 @@
|
|||
"@react-navigation/native-stack": "^7.3.10",
|
||||
"@react-navigation/stack": "^7.2.10",
|
||||
"@sentry/react-native": "~7.3.0",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@shopify/flash-list": "^2.1.0",
|
||||
"@supabase/supabase-js": "^2.54.0",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react-native-video": "^5.0.20",
|
||||
|
|
@ -67,6 +68,7 @@
|
|||
"posthog-react-native": "^4.4.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-boost": "^0.6.2",
|
||||
"react-native-bottom-tabs": "^0.12.2",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
|
|
@ -80,7 +82,7 @@
|
|||
"react-native-svg": "15.12.1",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-video": "^6.12.0",
|
||||
"react-native-video": "^6.17.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-wheel-color-picker": "^1.3.1",
|
||||
"react-native-worklets": "^0.6.1",
|
||||
|
|
|
|||
1467
patches/react-native-video+6.17.0.patch
Normal file
1467
patches/react-native-video+6.17.0.patch
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions } from 'react-native';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { LegendList } from '@legendapp/list';
|
||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
|
@ -98,7 +98,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FlashList
|
||||
<LegendList
|
||||
data={catalog.items}
|
||||
renderItem={renderContentItem}
|
||||
keyExtractor={keyExtractor}
|
||||
|
|
@ -108,8 +108,8 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
ItemSeparatorComponent={ItemSeparator}
|
||||
onEndReachedThreshold={0.7}
|
||||
onEndReached={() => {}}
|
||||
scrollEventThrottle={64}
|
||||
removeClippedSubviews={true}
|
||||
recycleItems={true}
|
||||
maintainVisibleContentPosition
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
|
||||
import { View, Text, StyleSheet, Dimensions, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, FlatList, StyleProp, Platform, Image } from 'react-native';
|
||||
import Animated, { FadeIn, FadeOut, Easing, useSharedValue, withTiming, useAnimatedStyle, useAnimatedScrollHandler, useAnimatedReaction, runOnJS } from 'react-native-reanimated';
|
||||
import React, { useMemo, useState, useEffect, useCallback, memo, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, Dimensions, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, ScrollView, StyleProp, Platform, Image } from 'react-native';
|
||||
import Animated, { FadeIn, FadeOut, Easing, useSharedValue, withTiming, useAnimatedStyle, useAnimatedScrollHandler, useAnimatedReaction, runOnJS, SharedValue } from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
|
|
@ -47,6 +47,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
const data = useMemo(() => (items && items.length ? items.slice(0, 10) : []), [items]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [failedLogoIds, setFailedLogoIds] = useState<Set<string>>(new Set());
|
||||
const scrollViewRef = useRef<any>(null);
|
||||
|
||||
// Note: do not early-return before hooks. Loading UI is returned later.
|
||||
|
||||
|
|
@ -55,10 +56,50 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
// Optimized: update background as soon as scroll starts, without waiting for momentum end
|
||||
const scrollX = useSharedValue(0);
|
||||
const interval = CARD_WIDTH + 16;
|
||||
|
||||
// Comprehensive reset when component mounts/remounts to prevent glitching
|
||||
useEffect(() => {
|
||||
scrollX.value = 0;
|
||||
setActiveIndex(0);
|
||||
|
||||
// Scroll to position 0 after a brief delay to ensure ScrollView is ready
|
||||
const timer = setTimeout(() => {
|
||||
scrollViewRef.current?.scrollTo({ x: 0, y: 0, animated: false });
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Reset scroll when data becomes available
|
||||
useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
scrollX.value = 0;
|
||||
setActiveIndex(0);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
scrollViewRef.current?.scrollTo({ x: 0, y: 0, animated: false });
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [data.length]);
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll: (event) => {
|
||||
scrollX.value = event.contentOffset.x;
|
||||
},
|
||||
onBeginDrag: () => {
|
||||
// Smooth scroll start - could add haptic feedback here
|
||||
},
|
||||
onEndDrag: () => {
|
||||
// Smooth scroll end
|
||||
},
|
||||
onMomentumBegin: () => {
|
||||
// Momentum scroll start
|
||||
},
|
||||
onMomentumEnd: () => {
|
||||
// Momentum scroll end
|
||||
},
|
||||
});
|
||||
|
||||
// Derive the index reactively and only set state when it changes
|
||||
|
|
@ -78,17 +119,6 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
|
||||
const contentPadding = useMemo(() => ({ paddingHorizontal: (width - CARD_WIDTH) / 2 }), []);
|
||||
|
||||
const keyExtractor = useCallback((item: StreamingContent) => item.id, []);
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
(_: unknown, index: number) => {
|
||||
const length = CARD_WIDTH + 16;
|
||||
const offset = length * index;
|
||||
return { length, offset, index };
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleNavigateToMetadata = useCallback((id: string, type: any) => {
|
||||
navigation.navigate('Metadata', { id, type });
|
||||
}, [navigation]);
|
||||
|
|
@ -97,20 +127,31 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
navigation.navigate('Streams', { id, type });
|
||||
}, [navigation]);
|
||||
|
||||
// Container animation based on scroll - must be before early returns
|
||||
const containerAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const progress = Math.abs(translateX) / (data.length * (CARD_WIDTH + 16));
|
||||
|
||||
// Very subtle scale animation for the entire container
|
||||
const scale = 1 - progress * 0.01;
|
||||
const clampedScale = Math.max(0.99, Math.min(1, scale));
|
||||
|
||||
return {
|
||||
transform: [{ scale: clampedScale }],
|
||||
};
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.container, { paddingVertical: 12 }] as StyleProp<ViewStyle>}>
|
||||
<View style={{ height: CARD_HEIGHT }}>
|
||||
<FlatList
|
||||
data={[1, 2, 3] as any}
|
||||
keyExtractor={(i) => String(i)}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={CARD_WIDTH + 16}
|
||||
decelerationRate="fast"
|
||||
contentContainerStyle={{ paddingHorizontal: (width - CARD_WIDTH) / 2 }}
|
||||
renderItem={() => (
|
||||
<View style={{ width: CARD_WIDTH + 16 }}>
|
||||
>
|
||||
{[1, 2, 3].map((_, index) => (
|
||||
<View key={index} style={{ width: CARD_WIDTH + 16 }}>
|
||||
<View style={[
|
||||
styles.card,
|
||||
{
|
||||
|
|
@ -137,8 +178,8 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
|
@ -222,7 +263,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
|
||||
return (
|
||||
<Animated.View entering={FadeIn.duration(350).easing(Easing.out(Easing.cubic))}>
|
||||
<View style={styles.container as ViewStyle}>
|
||||
<Animated.View style={[styles.container as ViewStyle, containerAnimatedStyle]}>
|
||||
{settings.enableHomeHeroBackground && data.length > 0 && (
|
||||
<View style={{ height: 0, width: 0, overflow: 'hidden' }}>
|
||||
{data[activeIndex + 1] && (
|
||||
|
|
@ -264,37 +305,35 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
<Animated.FlatList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
<Animated.ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={CARD_WIDTH + 16}
|
||||
decelerationRate="fast"
|
||||
contentContainerStyle={contentPadding}
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={32}
|
||||
scrollEventThrottle={8}
|
||||
disableIntervalMomentum
|
||||
initialNumToRender={2}
|
||||
windowSize={3}
|
||||
maxToRenderPerBatch={2}
|
||||
updateCellsBatchingPeriod={50}
|
||||
removeClippedSubviews
|
||||
getItemLayout={getItemLayout}
|
||||
renderItem={({ item }) => (
|
||||
<View style={{ width: CARD_WIDTH + 16 }}>
|
||||
<CarouselCard
|
||||
item={item}
|
||||
colors={currentTheme.colors}
|
||||
logoFailed={failedLogoIds.has(item.id)}
|
||||
onLogoError={() => setFailedLogoIds((prev) => new Set(prev).add(item.id))}
|
||||
onPressInfo={() => handleNavigateToMetadata(item.id, item.type)}
|
||||
onPressPlay={() => handleNavigateToStreams(item.id, item.type)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
pagingEnabled={false}
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
>
|
||||
{data.map((item, index) => (
|
||||
<CarouselCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
colors={currentTheme.colors}
|
||||
logoFailed={failedLogoIds.has(item.id)}
|
||||
onLogoError={() => setFailedLogoIds((prev) => new Set(prev).add(item.id))}
|
||||
onPressInfo={() => handleNavigateToMetadata(item.id, item.type)}
|
||||
onPressPlay={() => handleNavigateToStreams(item.id, item.type)}
|
||||
scrollX={scrollX}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Animated.ScrollView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
|
@ -306,61 +345,208 @@ interface CarouselCardProps {
|
|||
onLogoError: () => void;
|
||||
onPressPlay: () => void;
|
||||
onPressInfo: () => void;
|
||||
scrollX: SharedValue<number>;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFailed, onLogoError, onPressPlay, onPressInfo }) => {
|
||||
const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFailed, onLogoError, onPressPlay, onPressInfo, scrollX, index }) => {
|
||||
const [bannerLoaded, setBannerLoaded] = useState(false);
|
||||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||
|
||||
const bannerOpacity = useSharedValue(0);
|
||||
const logoOpacity = useSharedValue(0);
|
||||
const genresOpacity = useSharedValue(0);
|
||||
const actionsOpacity = useSharedValue(0);
|
||||
|
||||
// Reset animations when component mounts/remounts to prevent glitching
|
||||
useEffect(() => {
|
||||
bannerOpacity.value = 0;
|
||||
logoOpacity.value = 0;
|
||||
genresOpacity.value = 0;
|
||||
actionsOpacity.value = 0;
|
||||
// Force re-render states to ensure clean state
|
||||
setBannerLoaded(false);
|
||||
setLogoLoaded(false);
|
||||
}, [item.id]);
|
||||
|
||||
const inputRange = [
|
||||
(index - 1) * (CARD_WIDTH + 16),
|
||||
index * (CARD_WIDTH + 16),
|
||||
(index + 1) * (CARD_WIDTH + 16),
|
||||
];
|
||||
|
||||
const bannerAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: bannerOpacity.value,
|
||||
}));
|
||||
|
||||
const logoAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: logoOpacity.value,
|
||||
}));
|
||||
|
||||
const genresAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * (CARD_WIDTH + 16);
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
const maxDistance = (CARD_WIDTH + 16) * 0.5; // Smaller threshold for smoother transition
|
||||
|
||||
// Hide genres when scrolling (not centered)
|
||||
const progress = Math.min(distance / maxDistance, 1);
|
||||
const opacity = 1 - progress; // Linear fade out
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
|
||||
return {
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
|
||||
const actionsAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * (CARD_WIDTH + 16);
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
const maxDistance = (CARD_WIDTH + 16) * 0.5; // Smaller threshold for smoother transition
|
||||
|
||||
// Hide actions when scrolling (not centered)
|
||||
const progress = Math.min(distance / maxDistance, 1);
|
||||
const opacity = 1 - progress; // Linear fade out
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
|
||||
return {
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
|
||||
// Scroll-based animations
|
||||
const cardAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * (CARD_WIDTH + 16);
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
const maxDistance = CARD_WIDTH + 16;
|
||||
|
||||
// Scale animation based on distance from center
|
||||
const scale = 1 - (distance / maxDistance) * 0.1;
|
||||
const clampedScale = Math.max(0.9, Math.min(1, scale));
|
||||
|
||||
// Opacity animation for cards that are far from center
|
||||
const opacity = 1 - (distance / maxDistance) * 0.3;
|
||||
const clampedOpacity = Math.max(0.7, Math.min(1, opacity));
|
||||
|
||||
return {
|
||||
transform: [{ scale: clampedScale }],
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
|
||||
const bannerParallaxStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * (CARD_WIDTH + 16);
|
||||
const distance = translateX - cardOffset;
|
||||
|
||||
// Reduced parallax effect to prevent displacement
|
||||
const parallaxOffset = distance * 0.05;
|
||||
|
||||
return {
|
||||
transform: [{ translateX: parallaxOffset }],
|
||||
};
|
||||
});
|
||||
|
||||
const infoParallaxStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * (CARD_WIDTH + 16);
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
const maxDistance = CARD_WIDTH + 16;
|
||||
|
||||
// Hide info section when scrolling (not centered)
|
||||
const progress = distance / maxDistance;
|
||||
const opacity = 1 - progress * 2; // Fade out faster when scrolling
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
|
||||
// Minimal parallax for info section to prevent displacement
|
||||
const parallaxOffset = -(translateX - cardOffset) * 0.02;
|
||||
|
||||
return {
|
||||
transform: [{ translateY: parallaxOffset }],
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bannerLoaded) {
|
||||
bannerOpacity.value = withTiming(1, {
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.ease)
|
||||
});
|
||||
}
|
||||
}, [bannerLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (logoLoaded) {
|
||||
logoOpacity.value = withTiming(1, {
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.ease)
|
||||
});
|
||||
}
|
||||
}, [logoLoaded]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={onPressInfo}
|
||||
<Animated.View
|
||||
style={{ width: CARD_WIDTH + 16 }}
|
||||
entering={FadeIn.duration(400).delay(index * 100).easing(Easing.out(Easing.cubic))}
|
||||
>
|
||||
<View style={[
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: colors.elevation1,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.18)',
|
||||
}
|
||||
] as StyleProp<ViewStyle>}>
|
||||
<View style={styles.bannerContainer as ViewStyle}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.banner || item.poster,
|
||||
priority: FastImage.priority.normal,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={styles.banner as any}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.2)", "rgba(0,0,0,0.6)"]}
|
||||
locations={[0.4, 0.7, 1]}
|
||||
style={styles.bannerGradient as ViewStyle}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.info as ViewStyle}>
|
||||
{item.logo && !logoFailed ? (
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.logo,
|
||||
priority: FastImage.priority.high,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={styles.logo as any}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
onError={onLogoError}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={onPressInfo}
|
||||
style={{ width: CARD_WIDTH, height: CARD_HEIGHT }}
|
||||
>
|
||||
<Animated.View style={[
|
||||
styles.card,
|
||||
cardAnimatedStyle,
|
||||
{
|
||||
backgroundColor: colors.elevation1,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.18)',
|
||||
}
|
||||
] as StyleProp<ViewStyle>}>
|
||||
<View style={styles.bannerContainer as ViewStyle}>
|
||||
{!bannerLoaded && (
|
||||
<View style={styles.skeletonBannerFull as ViewStyle} />
|
||||
)}
|
||||
<Animated.View style={[bannerAnimatedStyle, bannerParallaxStyle, { flex: 1 }]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.banner || item.poster,
|
||||
priority: FastImage.priority.normal,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={styles.banner as any}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
onLoad={() => setBannerLoaded(true)}
|
||||
/>
|
||||
</Animated.View>
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.2)", "rgba(0,0,0,0.6)"]}
|
||||
locations={[0.4, 0.7, 1]}
|
||||
style={styles.bannerGradient as ViewStyle}
|
||||
/>
|
||||
) : (
|
||||
<Text style={[styles.title as TextStyle, { color: colors.highEmphasis, textAlign: 'center' }]} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
)}
|
||||
{item.genres && (
|
||||
<Text style={[styles.genres as TextStyle, { color: colors.mediumEmphasis, textAlign: 'center' }]} numberOfLines={1}>
|
||||
</View>
|
||||
</Animated.View>
|
||||
{/* Static genres positioned absolutely over the card */}
|
||||
{item.genres && (
|
||||
<View style={styles.genresOverlay as ViewStyle} pointerEvents="none">
|
||||
<Animated.Text
|
||||
entering={FadeIn.duration(400).delay(100)}
|
||||
style={[styles.genres as TextStyle, { color: colors.mediumEmphasis, textAlign: 'center' }, genresAnimatedStyle]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.genres.slice(0, 3).join(' • ')}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.actions as ViewStyle}>
|
||||
</Animated.Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Static action buttons positioned absolutely over the card */}
|
||||
<View style={styles.actionsOverlay as ViewStyle} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(500).delay(200)}
|
||||
style={[styles.actions as ViewStyle, actionsAnimatedStyle]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.playButton as ViewStyle, { backgroundColor: colors.white }]}
|
||||
onPress={onPressPlay}
|
||||
|
|
@ -377,10 +563,38 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
<MaterialIcons name="info-outline" size={18} color={colors.white} />
|
||||
<Text style={[styles.secondaryText as TextStyle, { color: colors.white }]}>Info</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{/* Static logo positioned absolutely over the card */}
|
||||
{item.logo && !logoFailed && (
|
||||
<View style={styles.logoOverlay as ViewStyle} pointerEvents="none">
|
||||
<Animated.View style={logoAnimatedStyle}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.logo,
|
||||
priority: FastImage.priority.high,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={styles.logo as any}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
onLoad={() => setLogoLoaded(true)}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
{/* Static title when no logo */}
|
||||
{!item.logo || logoFailed ? (
|
||||
<View style={styles.titleOverlay as ViewStyle} pointerEvents="none">
|
||||
<Animated.View entering={FadeIn.duration(300)}>
|
||||
<Text style={[styles.title as TextStyle, { color: colors.highEmphasis, textAlign: 'center' }]} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -542,6 +756,46 @@ const styles = StyleSheet.create({
|
|||
marginLeft: 6,
|
||||
fontSize: 14,
|
||||
},
|
||||
logoOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 80, // Position above genres and actions
|
||||
},
|
||||
titleOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 90, // Position above genres and actions
|
||||
},
|
||||
genresOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 65, // Position above actions
|
||||
},
|
||||
actionsOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 12, // Position at bottom
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(HeroCarousel);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
TouchableOpacity,
|
||||
Platform,
|
||||
Dimensions,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { BlurView as ExpoBlurView } from 'expo-blur';
|
||||
import { MaterialIcons, Feather } from '@expo/vector-icons';
|
||||
|
|
@ -24,7 +25,6 @@ if (Platform.OS === 'ios') {
|
|||
liquidGlassAvailable = false;
|
||||
}
|
||||
}
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
interpolate,
|
||||
|
|
@ -49,6 +49,7 @@ interface FloatingHeaderProps {
|
|||
headerElementsOpacity: SharedValue<number>;
|
||||
safeAreaTop: number;
|
||||
setLogoLoadError: (error: boolean) => void;
|
||||
stableLogoUri?: string | null;
|
||||
}
|
||||
|
||||
const FloatingHeader: React.FC<FloatingHeaderProps> = ({
|
||||
|
|
@ -62,6 +63,7 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
|
|||
headerElementsOpacity,
|
||||
safeAreaTop,
|
||||
setLogoLoadError,
|
||||
stableLogoUri,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const [isHeaderInteractive, setIsHeaderInteractive] = React.useState(false);
|
||||
|
|
@ -111,13 +113,13 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
|
|||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerTitleContainer}>
|
||||
{metadata.logo && !logoLoadError ? (
|
||||
<FastImage
|
||||
source={{ uri: metadata.logo }}
|
||||
{(stableLogoUri || metadata.logo) && !logoLoadError ? (
|
||||
<Image
|
||||
source={{ uri: stableLogoUri || metadata.logo }}
|
||||
style={styles.floatingHeaderLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
resizeMode="contain"
|
||||
onError={() => {
|
||||
logger.warn(`[FloatingHeader] Logo failed to load: ${metadata.logo}`);
|
||||
logger.warn(`[FloatingHeader] Logo failed to load: ${stableLogoUri || metadata.logo}`);
|
||||
setLogoLoadError(true);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -155,13 +157,13 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
|
|||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerTitleContainer}>
|
||||
{metadata.logo && !logoLoadError ? (
|
||||
<FastImage
|
||||
source={{ uri: metadata.logo }}
|
||||
{(stableLogoUri || metadata.logo) && !logoLoadError ? (
|
||||
<Image
|
||||
source={{ uri: stableLogoUri || metadata.logo }}
|
||||
style={styles.floatingHeaderLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
resizeMode="contain"
|
||||
onError={() => {
|
||||
logger.warn(`[FloatingHeader] Logo failed to load: ${metadata.logo}`);
|
||||
logger.warn(`[FloatingHeader] Logo failed to load: ${stableLogoUri || metadata.logo}`);
|
||||
setLogoLoadError(true);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -202,10 +204,10 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
|
|||
|
||||
<View style={styles.headerTitleContainer}>
|
||||
{metadata.logo && !logoLoadError ? (
|
||||
<FastImage
|
||||
<Image
|
||||
source={{ uri: metadata.logo }}
|
||||
style={styles.floatingHeaderLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
resizeMode="contain"
|
||||
onError={() => {
|
||||
logger.warn(`[FloatingHeader] Logo failed to load: ${metadata.logo}`);
|
||||
setLogoLoadError(true);
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ interface HeroSectionProps {
|
|||
traktSynced?: boolean;
|
||||
traktProgress?: number;
|
||||
} | null;
|
||||
onStableLogoUriChange?: (logoUri: string | null) => void;
|
||||
type: 'movie' | 'series';
|
||||
getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null;
|
||||
handleShowStreams: () => void;
|
||||
|
|
@ -777,6 +778,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
buttonsTranslateY,
|
||||
watchProgressOpacity,
|
||||
watchProgress,
|
||||
onStableLogoUriChange,
|
||||
type,
|
||||
getEpisodeDetails,
|
||||
handleShowStreams,
|
||||
|
|
@ -832,7 +834,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
const titleCardTranslateY = useSharedValue(0);
|
||||
const genreOpacity = useSharedValue(1);
|
||||
|
||||
// Performance optimization: Cache theme colors
|
||||
// Ultra-optimized theme colors with stable references
|
||||
const themeColors = useMemo(() => ({
|
||||
black: currentTheme.colors.black,
|
||||
darkBackground: currentTheme.colors.darkBackground,
|
||||
|
|
@ -840,6 +842,15 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
text: currentTheme.colors.text
|
||||
}), [currentTheme.colors.black, currentTheme.colors.darkBackground, currentTheme.colors.highEmphasis, currentTheme.colors.text]);
|
||||
|
||||
// Pre-calculated style objects for better performance
|
||||
const staticStyles = useMemo(() => ({
|
||||
heroWrapper: styles.heroWrapper,
|
||||
heroSection: styles.heroSection,
|
||||
absoluteFill: styles.absoluteFill,
|
||||
thumbnailContainer: styles.thumbnailContainer,
|
||||
thumbnailImage: styles.thumbnailImage,
|
||||
}), []);
|
||||
|
||||
// Handle trailer preload completion
|
||||
const handleTrailerPreloaded = useCallback(() => {
|
||||
setTrailerPreloaded(true);
|
||||
|
|
@ -957,12 +968,14 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
|
||||
if (metadata?.logo && metadata.logo !== stableLogoUri) {
|
||||
setStableLogoUri(metadata.logo);
|
||||
onStableLogoUriChange?.(metadata.logo);
|
||||
setLogoHasLoadedSuccessfully(false); // Reset for new logo
|
||||
logoLoadOpacity.value = 0; // reset fade for new logo
|
||||
setShouldShowTextFallback(false);
|
||||
} else if (!metadata?.logo && stableLogoUri) {
|
||||
// Clear logo if metadata no longer has one
|
||||
setStableLogoUri(null);
|
||||
onStableLogoUriChange?.(null);
|
||||
setLogoHasLoadedSuccessfully(false);
|
||||
// Start a short grace period before showing text fallback
|
||||
setShouldShowTextFallback(false);
|
||||
|
|
@ -1153,15 +1166,31 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
opacity: watchProgressOpacity.value,
|
||||
}), []);
|
||||
|
||||
// Enhanced backdrop with smooth loading animation
|
||||
// Ultra-optimized backdrop with cached calculations and minimal worklet overhead
|
||||
const backdropImageStyle = useAnimatedStyle(() => {
|
||||
'worklet';
|
||||
const scale = 1 + (scrollY.value * 0.0001); // Micro scale effect
|
||||
const scrollYValue = scrollY.value;
|
||||
|
||||
// Pre-calculated constants for better performance
|
||||
const DEFAULT_ZOOM = 1.1;
|
||||
const SCROLL_UP_MULTIPLIER = 0.002;
|
||||
const SCROLL_DOWN_MULTIPLIER = 0.0001;
|
||||
const MAX_SCALE = 1.4;
|
||||
const PARALLAX_FACTOR = 0.3;
|
||||
|
||||
// Optimized scale calculation with minimal branching
|
||||
const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER;
|
||||
const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER;
|
||||
const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE);
|
||||
|
||||
// Single parallax calculation
|
||||
const parallaxOffset = scrollYValue * PARALLAX_FACTOR;
|
||||
|
||||
return {
|
||||
opacity: imageOpacity.value * imageLoadOpacity.value,
|
||||
transform: [
|
||||
{ scale: Math.min(scale, SCALE_FACTOR) } // Cap scale
|
||||
{ scale },
|
||||
{ translateY: parallaxOffset }
|
||||
],
|
||||
};
|
||||
}, []);
|
||||
|
|
@ -1189,6 +1218,34 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
opacity: genreOpacity.value
|
||||
}), []);
|
||||
|
||||
// Ultra-optimized trailer parallax with cached calculations
|
||||
const trailerParallaxStyle = useAnimatedStyle(() => {
|
||||
'worklet';
|
||||
const scrollYValue = scrollY.value;
|
||||
|
||||
// Pre-calculated constants for better performance
|
||||
const DEFAULT_ZOOM = 1.0;
|
||||
const SCROLL_UP_MULTIPLIER = 0.0015;
|
||||
const SCROLL_DOWN_MULTIPLIER = 0.0001;
|
||||
const MAX_SCALE = 1.25;
|
||||
const PARALLAX_FACTOR = 0.2;
|
||||
|
||||
// Optimized scale calculation with minimal branching
|
||||
const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER;
|
||||
const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER;
|
||||
const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE);
|
||||
|
||||
// Single parallax calculation
|
||||
const parallaxOffset = scrollYValue * PARALLAX_FACTOR;
|
||||
|
||||
return {
|
||||
transform: [
|
||||
{ scale },
|
||||
{ translateY: parallaxOffset }
|
||||
],
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Optimized genre rendering with lazy loading and memory management
|
||||
const genreElements = useMemo(() => {
|
||||
if (!shouldLoadSecondaryData || !metadata?.genres?.length) return null;
|
||||
|
|
@ -1336,27 +1393,31 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
}
|
||||
}, [isFocused, setTrailerPlaying]);
|
||||
|
||||
// Pause/resume trailer based on scroll with hysteresis and guard
|
||||
// Ultra-optimized scroll-based pause/resume with cached calculations
|
||||
useDerivedValue(() => {
|
||||
'worklet';
|
||||
try {
|
||||
if (!scrollGuardEnabledSV.value || isFocusedSV.value === 0) return;
|
||||
const pauseThreshold = heroHeight.value * 0.7; // pause when beyond 70%
|
||||
const resumeThreshold = heroHeight.value * 0.4; // resume when back within 40%
|
||||
|
||||
|
||||
// Pre-calculate thresholds for better performance
|
||||
const pauseThreshold = heroHeight.value * 0.7;
|
||||
const resumeThreshold = heroHeight.value * 0.4;
|
||||
const y = scrollY.value;
|
||||
const isPlaying = isPlayingSV.value === 1;
|
||||
const isPausedByScroll = pausedByScrollSV.value === 1;
|
||||
|
||||
if (y > pauseThreshold && isPlayingSV.value === 1 && pausedByScrollSV.value === 0) {
|
||||
// Optimized pause/resume logic with minimal branching
|
||||
if (y > pauseThreshold && isPlaying && !isPausedByScroll) {
|
||||
pausedByScrollSV.value = 1;
|
||||
runOnJS(setTrailerPlaying)(false);
|
||||
isPlayingSV.value = 0;
|
||||
} else if (y < resumeThreshold && pausedByScrollSV.value === 1) {
|
||||
} else if (y < resumeThreshold && isPausedByScroll) {
|
||||
pausedByScrollSV.value = 0;
|
||||
runOnJS(setTrailerPlaying)(true);
|
||||
isPlayingSV.value = 1;
|
||||
}
|
||||
} catch (e) {
|
||||
// no-op
|
||||
// Silent error handling for performance
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1408,20 +1469,21 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.heroSection, heroAnimatedStyle]}>
|
||||
{/* Optimized Background */}
|
||||
<View style={[styles.absoluteFill, { backgroundColor: themeColors.black }]} />
|
||||
<View style={staticStyles.heroWrapper}>
|
||||
<Animated.View style={[staticStyles.heroSection, heroAnimatedStyle]}>
|
||||
{/* Optimized Background */}
|
||||
<View style={[staticStyles.absoluteFill, { backgroundColor: themeColors.black }]} />
|
||||
|
||||
{/* Shimmer loading effect removed */}
|
||||
|
||||
{/* Background thumbnail image - always rendered when available */}
|
||||
{/* Background thumbnail image - always rendered when available with parallax */}
|
||||
{shouldLoadSecondaryData && imageSource && !loadingBanner && (
|
||||
<Animated.View style={[styles.absoluteFill, {
|
||||
<Animated.View style={[staticStyles.thumbnailContainer, {
|
||||
opacity: thumbnailOpacity
|
||||
}]}>
|
||||
<Animated.Image
|
||||
source={{ uri: imageSource }}
|
||||
style={[styles.absoluteFill, backdropImageStyle]}
|
||||
style={[staticStyles.thumbnailImage, backdropImageStyle]}
|
||||
resizeMode="cover"
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
|
|
@ -1431,13 +1493,13 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
|
||||
{/* Hidden preload trailer player - loads in background */}
|
||||
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && !trailerPreloaded && (
|
||||
<View style={[styles.absoluteFill, { opacity: 0, pointerEvents: 'none' }]}>
|
||||
<View style={[staticStyles.absoluteFill, { opacity: 0, pointerEvents: 'none' }]}>
|
||||
<TrailerPlayer
|
||||
key={`preload-${trailerUrl}`}
|
||||
trailerUrl={trailerUrl}
|
||||
autoPlay={false}
|
||||
muted={true}
|
||||
style={styles.absoluteFill}
|
||||
style={staticStyles.absoluteFill}
|
||||
hideLoadingSpinner={true}
|
||||
onLoad={handleTrailerPreloaded}
|
||||
onError={handleTrailerError}
|
||||
|
|
@ -1445,18 +1507,18 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
</View>
|
||||
)}
|
||||
|
||||
{/* Visible trailer player - rendered on top with fade transition */}
|
||||
{/* Visible trailer player - rendered on top with fade transition and parallax */}
|
||||
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && trailerPreloaded && (
|
||||
<Animated.View style={[styles.absoluteFill, {
|
||||
<Animated.View style={[staticStyles.absoluteFill, {
|
||||
opacity: trailerOpacity
|
||||
}]}>
|
||||
}, trailerParallaxStyle]}>
|
||||
<TrailerPlayer
|
||||
key={`visible-${trailerUrl}`}
|
||||
ref={trailerVideoRef}
|
||||
trailerUrl={trailerUrl}
|
||||
autoPlay={globalTrailerPlaying}
|
||||
muted={trailerMuted}
|
||||
style={styles.absoluteFill}
|
||||
style={staticStyles.absoluteFill}
|
||||
hideLoadingSpinner={true}
|
||||
hideControls={true}
|
||||
onFullscreenToggle={handleFullscreenToggle}
|
||||
|
|
@ -1641,16 +1703,23 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
/>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
// Ultra-optimized styles
|
||||
const styles = StyleSheet.create({
|
||||
heroWrapper: {
|
||||
width: '100%',
|
||||
marginTop: -150, // Extend wrapper 150px above to accommodate thumbnail overflow
|
||||
paddingTop: 150, // Add padding to maintain proper positioning
|
||||
overflow: 'hidden', // This will clip the thumbnail overflow when scrolling
|
||||
},
|
||||
heroSection: {
|
||||
width: '100%',
|
||||
backgroundColor: '#000',
|
||||
overflow: 'hidden',
|
||||
overflow: 'visible', // Allow thumbnail to extend within the wrapper
|
||||
},
|
||||
|
||||
absoluteFill: {
|
||||
|
|
@ -1660,6 +1729,20 @@ const styles = StyleSheet.create({
|
|||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
thumbnailContainer: {
|
||||
position: 'absolute',
|
||||
top: 0, // Now positioned at the top of the wrapper (which extends 150px above)
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
thumbnailImage: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
backButtonContainer: {
|
||||
position: 'absolute',
|
||||
top: Platform.OS === 'android' ? 40 : 50,
|
||||
|
|
|
|||
467
src/components/metadata/TrailerModal.tsx
Normal file
467
src/components/metadata/TrailerModal.tsx
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
import React, { useState, useEffect, useCallback, memo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTrailer } from '../../contexts/TrailerContext';
|
||||
import { logger } from '../../utils/logger';
|
||||
import TrailerService from '../../services/trailerService';
|
||||
import Video, { VideoRef, OnLoadData, OnProgressData } from 'react-native-video';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
||||
// Helper function to format trailer type
|
||||
const formatTrailerType = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'Trailer':
|
||||
return 'Official Trailer';
|
||||
case 'Teaser':
|
||||
return 'Teaser';
|
||||
case 'Clip':
|
||||
return 'Clip';
|
||||
case 'Featurette':
|
||||
return 'Featurette';
|
||||
case 'Behind the Scenes':
|
||||
return 'Behind the Scenes';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
interface TrailerVideo {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
site: string;
|
||||
size: number;
|
||||
type: string;
|
||||
official: boolean;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
interface TrailerModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
trailer: TrailerVideo | null;
|
||||
contentTitle: string;
|
||||
}
|
||||
|
||||
const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
||||
visible,
|
||||
onClose,
|
||||
trailer,
|
||||
contentTitle
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { pauseTrailer, resumeTrailer } = useTrailer();
|
||||
const videoRef = React.useRef<VideoRef>(null);
|
||||
const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
// Load trailer when modal opens or trailer changes
|
||||
useEffect(() => {
|
||||
if (visible && trailer) {
|
||||
loadTrailer();
|
||||
} else {
|
||||
// Reset state when modal closes
|
||||
setTrailerUrl(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setIsPlaying(false);
|
||||
setRetryCount(0);
|
||||
}
|
||||
}, [visible, trailer]);
|
||||
|
||||
const loadTrailer = useCallback(async () => {
|
||||
if (!trailer) return;
|
||||
|
||||
// Pause hero section trailer when modal opens
|
||||
try {
|
||||
pauseTrailer();
|
||||
logger.info('TrailerModal', 'Paused hero section trailer');
|
||||
} catch (error) {
|
||||
logger.warn('TrailerModal', 'Error pausing hero trailer:', error);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setTrailerUrl(null);
|
||||
setRetryCount(0); // Reset retry count when starting fresh load
|
||||
|
||||
try {
|
||||
const youtubeUrl = `https://www.youtube.com/watch?v=${trailer.key}`;
|
||||
|
||||
logger.info('TrailerModal', `Loading trailer: ${trailer.name} (${youtubeUrl})`);
|
||||
|
||||
// Use the direct YouTube URL method - much more efficient!
|
||||
const directUrl = await TrailerService.getTrailerFromYouTubeUrl(
|
||||
youtubeUrl,
|
||||
`${contentTitle} - ${trailer.name}`,
|
||||
new Date(trailer.published_at).getFullYear().toString()
|
||||
);
|
||||
|
||||
if (directUrl) {
|
||||
setTrailerUrl(directUrl);
|
||||
setIsPlaying(true);
|
||||
logger.info('TrailerModal', `Successfully loaded direct trailer URL for: ${trailer.name}`);
|
||||
} else {
|
||||
throw new Error('No streaming URL available');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load trailer';
|
||||
setError(errorMessage);
|
||||
setLoading(false);
|
||||
logger.error('TrailerModal', 'Error loading trailer:', err);
|
||||
|
||||
Alert.alert(
|
||||
'Trailer Unavailable',
|
||||
'This trailer could not be loaded at this time. Please try again later.',
|
||||
[{ text: 'OK', style: 'default' }]
|
||||
);
|
||||
}
|
||||
}, [trailer, contentTitle, pauseTrailer]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
|
||||
// Resume hero section trailer when modal closes
|
||||
try {
|
||||
resumeTrailer();
|
||||
logger.info('TrailerModal', 'Resumed hero section trailer');
|
||||
} catch (error) {
|
||||
logger.warn('TrailerModal', 'Error resuming hero trailer:', error);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [onClose, resumeTrailer]);
|
||||
|
||||
const handleTrailerError = useCallback(() => {
|
||||
setError('Failed to play trailer');
|
||||
setIsPlaying(false);
|
||||
}, []);
|
||||
|
||||
// Handle video playback errors with retry logic
|
||||
const handleVideoError = useCallback((error: any) => {
|
||||
logger.error('TrailerModal', 'Video error:', error);
|
||||
|
||||
// Check if this is a permission/network error that might benefit from retry
|
||||
const errorCode = error?.error?.code;
|
||||
const isRetryableError = errorCode === -1102 || errorCode === -1009 || errorCode === -1005;
|
||||
|
||||
if (isRetryableError && retryCount < 2) {
|
||||
// Silent retry - increment count and try again
|
||||
logger.info('TrailerModal', `Retrying video load (attempt ${retryCount + 1}/2)`);
|
||||
setRetryCount(prev => prev + 1);
|
||||
|
||||
// Small delay before retry to avoid rapid-fire attempts
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
// Force video to reload by changing the source briefly
|
||||
setTrailerUrl(null);
|
||||
setTimeout(() => {
|
||||
if (trailerUrl) {
|
||||
setTrailerUrl(trailerUrl);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// After 2 retries or for non-retryable errors, show the error
|
||||
logger.error('TrailerModal', 'Video error after retries or non-retryable:', error);
|
||||
setError('Unable to play trailer. Please try again.');
|
||||
setLoading(false);
|
||||
}, [retryCount, trailerUrl]);
|
||||
|
||||
const handleTrailerEnd = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
}, []);
|
||||
|
||||
if (!visible || !trailer) return null;
|
||||
|
||||
const modalHeight = isTablet ? height * 0.8 : height * 0.7;
|
||||
const modalWidth = isTablet ? width * 0.8 : width * 0.95;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={handleClose}
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={[styles.modal, {
|
||||
width: modalWidth,
|
||||
maxHeight: modalHeight,
|
||||
backgroundColor: currentTheme.colors.background
|
||||
}]}>
|
||||
{/* Enhanced Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<View style={styles.headerTextContainer}>
|
||||
<Text
|
||||
style={[styles.title, { color: currentTheme.colors.highEmphasis }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{trailer.name}
|
||||
</Text>
|
||||
<View style={styles.headerMeta}>
|
||||
<Text style={[styles.meta, { color: currentTheme.colors.textMuted }]}>
|
||||
{formatTrailerType(trailer.type)} • {new Date(trailer.published_at).getFullYear()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handleClose}
|
||||
style={[styles.closeButton, { backgroundColor: 'rgba(255,255,255,0.1)' }]}
|
||||
hitSlop={{ top: 10, left: 10, right: 10, bottom: 10 }}
|
||||
>
|
||||
<Text style={[styles.closeButtonText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Close
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Player Container */}
|
||||
<View style={styles.playerContainer}>
|
||||
{loading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>
|
||||
Loading trailer...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={[styles.errorText, { color: currentTheme.colors.textMuted }]}>
|
||||
{error}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={loadTrailer}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>Try Again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Render the Video as soon as we have a URL; keep spinner overlay until onLoad */}
|
||||
{trailerUrl && !error && (
|
||||
<View style={styles.playerWrapper}>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={{ uri: trailerUrl }}
|
||||
style={styles.player}
|
||||
controls={true}
|
||||
paused={!isPlaying}
|
||||
resizeMode="contain"
|
||||
volume={1.0}
|
||||
rate={1.0}
|
||||
playInBackground={false}
|
||||
playWhenInactive={false}
|
||||
ignoreSilentSwitch="ignore"
|
||||
onLoad={(data: OnLoadData) => {
|
||||
logger.info('TrailerModal', 'Trailer loaded successfully', data);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setIsPlaying(true);
|
||||
}}
|
||||
onError={handleVideoError}
|
||||
onEnd={() => {
|
||||
logger.info('TrailerModal', 'Trailer ended');
|
||||
handleTrailerEnd();
|
||||
}}
|
||||
onProgress={(data: OnProgressData) => {
|
||||
// Handle progress if needed
|
||||
}}
|
||||
onLoadStart={() => {
|
||||
logger.info('TrailerModal', 'Video load started');
|
||||
setLoading(true);
|
||||
}}
|
||||
onReadyForDisplay={() => {
|
||||
logger.info('TrailerModal', 'Video ready for display');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Enhanced Footer */}
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerContent}>
|
||||
<Text style={[styles.footerText, { color: currentTheme.colors.textMuted }]}>
|
||||
{contentTitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.92)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modal: {
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
elevation: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
|
||||
// Enhanced Header Styles
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 18,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
},
|
||||
headerTextContainer: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
lineHeight: 20,
|
||||
color: '#fff',
|
||||
},
|
||||
headerMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
meta: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
fontWeight: '500',
|
||||
},
|
||||
closeButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
playerContainer: {
|
||||
aspectRatio: 16 / 9,
|
||||
backgroundColor: '#000',
|
||||
position: 'relative',
|
||||
},
|
||||
loadingContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#000',
|
||||
gap: 16,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
opacity: 0.8,
|
||||
},
|
||||
errorContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#000',
|
||||
padding: 20,
|
||||
gap: 16,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
opacity: 0.8,
|
||||
},
|
||||
retryButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 20,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
playerWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
player: {
|
||||
flex: 1,
|
||||
},
|
||||
// Enhanced Footer Styles
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
footerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
opacity: 0.8,
|
||||
},
|
||||
footerMeta: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
footerMetaText: {
|
||||
fontSize: 11,
|
||||
opacity: 0.6,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
export default TrailerModal;
|
||||
860
src/components/metadata/TrailersSection.tsx
Normal file
860
src/components/metadata/TrailersSection.tsx
Normal file
|
|
@ -0,0 +1,860 @@
|
|||
import React, { useState, useEffect, useCallback, memo, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Alert,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
import { useTrailer } from '../../contexts/TrailerContext';
|
||||
import { logger } from '../../utils/logger';
|
||||
import TrailerService from '../../services/trailerService';
|
||||
import TrailerModal from './TrailerModal';
|
||||
import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
||||
interface TrailerVideo {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
site: string;
|
||||
size: number;
|
||||
type: string;
|
||||
official: boolean;
|
||||
published_at: string;
|
||||
seasonNumber: number | null;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
interface TrailersSectionProps {
|
||||
tmdbId: number | null;
|
||||
type: 'movie' | 'tv';
|
||||
contentId: string;
|
||||
contentTitle: string;
|
||||
}
|
||||
|
||||
interface CategorizedTrailers {
|
||||
[key: string]: TrailerVideo[];
|
||||
}
|
||||
|
||||
const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||
tmdbId,
|
||||
type,
|
||||
contentId,
|
||||
contentTitle
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const { pauseTrailer } = useTrailer();
|
||||
const [trailers, setTrailers] = useState<CategorizedTrailers>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTrailer, setSelectedTrailer] = useState<TrailerVideo | null>(null);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('Trailer');
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const [backendAvailable, setBackendAvailable] = useState<boolean | null>(null);
|
||||
|
||||
// Smooth reveal animation after trailers are fetched
|
||||
const sectionOpacitySV = useSharedValue(0);
|
||||
const sectionTranslateYSV = useSharedValue(8);
|
||||
const hasAnimatedRef = useRef(false);
|
||||
|
||||
const sectionAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: sectionOpacitySV.value,
|
||||
transform: [{ translateY: sectionTranslateYSV.value }],
|
||||
}));
|
||||
|
||||
// Reset animation state before a new fetch starts
|
||||
const resetSectionAnimation = useCallback(() => {
|
||||
hasAnimatedRef.current = false;
|
||||
sectionOpacitySV.value = 0;
|
||||
sectionTranslateYSV.value = 8;
|
||||
}, [sectionOpacitySV, sectionTranslateYSV]);
|
||||
|
||||
// Trigger animation once, 500ms after trailers are available
|
||||
const triggerSectionAnimation = useCallback(() => {
|
||||
if (hasAnimatedRef.current) return;
|
||||
hasAnimatedRef.current = true;
|
||||
sectionOpacitySV.value = withDelay(500, withTiming(1, { duration: 400 }));
|
||||
sectionTranslateYSV.value = withDelay(500, withTiming(0, { duration: 400 }));
|
||||
}, [sectionOpacitySV, sectionTranslateYSV]);
|
||||
|
||||
// Check if trailer service backend is available
|
||||
const checkBackendAvailability = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const serverStatus = TrailerService.getServerStatus();
|
||||
const healthUrl = `${serverStatus.localUrl.replace('/trailer', '/health')}`;
|
||||
|
||||
const response = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(3000), // 3 second timeout
|
||||
});
|
||||
const isAvailable = response.ok;
|
||||
logger.info('TrailersSection', `Backend availability check: ${isAvailable ? 'AVAILABLE' : 'UNAVAILABLE'}`);
|
||||
return isAvailable;
|
||||
} catch (error) {
|
||||
logger.warn('TrailersSection', 'Backend availability check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch trailers from TMDB
|
||||
useEffect(() => {
|
||||
if (!tmdbId) return;
|
||||
|
||||
const initializeTrailers = async () => {
|
||||
resetSectionAnimation();
|
||||
// First check if backend is available
|
||||
const available = await checkBackendAvailability();
|
||||
setBackendAvailable(available);
|
||||
|
||||
if (!available) {
|
||||
logger.warn('TrailersSection', 'Trailer service backend is not available - skipping trailer loading');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Backend is available, proceed with fetching trailers
|
||||
await fetchTrailers();
|
||||
};
|
||||
|
||||
const fetchTrailers = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.info('TrailersSection', `Fetching trailers for TMDB ID: ${tmdbId}, type: ${type}`);
|
||||
|
||||
// First check if the movie/TV show exists
|
||||
const basicEndpoint = type === 'movie'
|
||||
? `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de`
|
||||
: `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de`;
|
||||
|
||||
const basicResponse = await fetch(basicEndpoint);
|
||||
if (!basicResponse.ok) {
|
||||
if (basicResponse.status === 404) {
|
||||
// 404 on basic endpoint means TMDB ID doesn't exist - this is normal
|
||||
logger.info('TrailersSection', `TMDB ID ${tmdbId} not found in TMDB (404) - skipping trailers`);
|
||||
setTrailers({}); // Empty trailers - section won't render
|
||||
return;
|
||||
}
|
||||
logger.error('TrailersSection', `TMDB basic endpoint failed: ${basicResponse.status} ${basicResponse.statusText}`);
|
||||
setError(`Failed to verify content: ${basicResponse.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let allVideos: any[] = [];
|
||||
|
||||
if (type === 'movie') {
|
||||
// For movies, just fetch the main videos endpoint
|
||||
const videosEndpoint = `https://api.themoviedb.org/3/movie/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`;
|
||||
|
||||
logger.info('TrailersSection', `Fetching movie videos from: ${videosEndpoint}`);
|
||||
|
||||
const response = await fetch(videosEndpoint);
|
||||
if (!response.ok) {
|
||||
// 404 is normal - means no videos exist for this content
|
||||
if (response.status === 404) {
|
||||
logger.info('TrailersSection', `No videos found for movie TMDB ID ${tmdbId} (404 response)`);
|
||||
setTrailers({}); // Empty trailers - section won't render
|
||||
return;
|
||||
}
|
||||
logger.error('TrailersSection', `Videos endpoint failed: ${response.status} ${response.statusText}`);
|
||||
throw new Error(`Failed to fetch trailers: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
allVideos = data.results || [];
|
||||
logger.info('TrailersSection', `Received ${allVideos.length} videos for movie TMDB ID ${tmdbId}`);
|
||||
} else {
|
||||
// For TV shows, fetch both main TV videos and season-specific videos
|
||||
logger.info('TrailersSection', `Fetching TV show videos and season trailers for TMDB ID ${tmdbId}`);
|
||||
|
||||
// Get TV show details to know how many seasons there are
|
||||
const tvDetailsResponse = await fetch(basicEndpoint);
|
||||
const tvDetails = await tvDetailsResponse.json();
|
||||
const numberOfSeasons = tvDetails.number_of_seasons || 0;
|
||||
|
||||
logger.info('TrailersSection', `TV show has ${numberOfSeasons} seasons`);
|
||||
|
||||
// Fetch main TV show videos
|
||||
const tvVideosEndpoint = `https://api.themoviedb.org/3/tv/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`;
|
||||
const tvResponse = await fetch(tvVideosEndpoint);
|
||||
|
||||
if (tvResponse.ok) {
|
||||
const tvData = await tvResponse.json();
|
||||
// Add season info to main TV videos
|
||||
const mainVideos = (tvData.results || []).map((video: any) => ({
|
||||
...video,
|
||||
seasonNumber: null as number | null, // null indicates main TV show videos
|
||||
displayName: video.name
|
||||
}));
|
||||
allVideos.push(...mainVideos);
|
||||
logger.info('TrailersSection', `Received ${mainVideos.length} main TV videos`);
|
||||
}
|
||||
|
||||
// Fetch videos from each season (skip season 0 which is specials)
|
||||
const seasonPromises = [];
|
||||
for (let seasonNum = 1; seasonNum <= numberOfSeasons; seasonNum++) {
|
||||
seasonPromises.push(
|
||||
fetch(`https://api.themoviedb.org/3/tv/${tmdbId}/season/${seasonNum}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`)
|
||||
.then(res => res.json())
|
||||
.then(data => ({
|
||||
seasonNumber: seasonNum,
|
||||
videos: data.results || []
|
||||
}))
|
||||
.catch(err => {
|
||||
logger.warn('TrailersSection', `Failed to fetch season ${seasonNum} videos:`, err);
|
||||
return { seasonNumber: seasonNum, videos: [] };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const seasonResults = await Promise.all(seasonPromises);
|
||||
|
||||
// Add season videos to the collection
|
||||
seasonResults.forEach(result => {
|
||||
if (result.videos.length > 0) {
|
||||
const seasonVideos = result.videos.map((video: any) => ({
|
||||
...video,
|
||||
seasonNumber: result.seasonNumber as number | null,
|
||||
displayName: `Season ${result.seasonNumber} - ${video.name}`
|
||||
}));
|
||||
allVideos.push(...seasonVideos);
|
||||
logger.info('TrailersSection', `Season ${result.seasonNumber}: ${result.videos.length} videos`);
|
||||
}
|
||||
});
|
||||
|
||||
const totalSeasonVideos = seasonResults.reduce((sum, result) => sum + result.videos.length, 0);
|
||||
logger.info('TrailersSection', `Total videos collected: ${allVideos.length} (main: ${allVideos.filter(v => v.seasonNumber === null).length}, seasons: ${totalSeasonVideos})`);
|
||||
}
|
||||
|
||||
const categorized = categorizeTrailers(allVideos);
|
||||
const totalVideos = Object.values(categorized).reduce((sum, videos) => sum + videos.length, 0);
|
||||
|
||||
if (totalVideos === 0) {
|
||||
logger.info('TrailersSection', `No videos found for TMDB ID ${tmdbId} - this is normal`);
|
||||
setTrailers({}); // No trailers available
|
||||
setSelectedCategory(''); // No category selected
|
||||
} else {
|
||||
logger.info('TrailersSection', `Categorized ${totalVideos} videos into ${Object.keys(categorized).length} categories`);
|
||||
setTrailers(categorized);
|
||||
// Trigger smooth reveal after 1.5s since we have content
|
||||
triggerSectionAnimation();
|
||||
|
||||
// Auto-select the first available category, preferring "Trailer"
|
||||
const availableCategories = Object.keys(categorized);
|
||||
const preferredCategory = availableCategories.includes('Trailer') ? 'Trailer' :
|
||||
availableCategories.includes('Teaser') ? 'Teaser' : availableCategories[0];
|
||||
setSelectedCategory(preferredCategory);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load trailers';
|
||||
setError(errorMessage);
|
||||
logger.error('TrailersSection', 'Error fetching trailers:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializeTrailers();
|
||||
}, [tmdbId, type, checkBackendAvailability]);
|
||||
|
||||
// Categorize trailers by type
|
||||
const categorizeTrailers = (videos: any[]): CategorizedTrailers => {
|
||||
const categories: CategorizedTrailers = {};
|
||||
|
||||
videos.forEach(video => {
|
||||
if (video.site !== 'YouTube') return; // Only YouTube videos
|
||||
|
||||
const category = video.type;
|
||||
if (!categories[category]) {
|
||||
categories[category] = [];
|
||||
}
|
||||
categories[category].push(video);
|
||||
});
|
||||
|
||||
// Sort within each category: season trailers first (newest seasons), then main series, official first, then by date
|
||||
Object.keys(categories).forEach(category => {
|
||||
categories[category].sort((a, b) => {
|
||||
// Season trailers come before main series trailers
|
||||
if (a.seasonNumber !== null && b.seasonNumber === null) return -1;
|
||||
if (a.seasonNumber === null && b.seasonNumber !== null) return 1;
|
||||
|
||||
// If both have season numbers, sort by season number (newest seasons first)
|
||||
if (a.seasonNumber !== null && b.seasonNumber !== null) {
|
||||
if (a.seasonNumber !== b.seasonNumber) {
|
||||
return b.seasonNumber - a.seasonNumber; // Higher season numbers first
|
||||
}
|
||||
}
|
||||
|
||||
// Official trailers come first within the same season/main series group
|
||||
if (a.official && !b.official) return -1;
|
||||
if (!a.official && b.official) return 1;
|
||||
|
||||
// If both are official or both are not, sort by published date (newest first)
|
||||
return new Date(b.published_at).getTime() - new Date(a.published_at).getTime();
|
||||
});
|
||||
});
|
||||
|
||||
// Sort categories: "Trailer" category first, then categories with official trailers, then alphabetically
|
||||
const sortedCategories = Object.keys(categories).sort((a, b) => {
|
||||
// "Trailer" category always comes first
|
||||
if (a === 'Trailer') return -1;
|
||||
if (b === 'Trailer') return 1;
|
||||
|
||||
const aHasOfficial = categories[a].some(trailer => trailer.official);
|
||||
const bHasOfficial = categories[b].some(trailer => trailer.official);
|
||||
|
||||
// Categories with official trailers come first (after Trailer)
|
||||
if (aHasOfficial && !bHasOfficial) return -1;
|
||||
if (!aHasOfficial && bHasOfficial) return 1;
|
||||
|
||||
// If both have or don't have official trailers, sort alphabetically
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// Create new object with sorted categories
|
||||
const sortedCategoriesObj: CategorizedTrailers = {};
|
||||
sortedCategories.forEach(category => {
|
||||
sortedCategoriesObj[category] = categories[category];
|
||||
});
|
||||
|
||||
return sortedCategoriesObj;
|
||||
};
|
||||
|
||||
// Handle trailer selection
|
||||
const handleTrailerPress = (trailer: TrailerVideo) => {
|
||||
// Pause hero section trailer when modal opens
|
||||
try {
|
||||
pauseTrailer();
|
||||
} catch (error) {
|
||||
logger.warn('TrailersSection', 'Error pausing hero trailer:', error);
|
||||
}
|
||||
|
||||
setSelectedTrailer(trailer);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// Handle modal close
|
||||
const handleModalClose = () => {
|
||||
setModalVisible(false);
|
||||
setSelectedTrailer(null);
|
||||
// Note: Hero trailer will resume automatically when modal closes
|
||||
// The HeroSection component handles resuming based on scroll position
|
||||
};
|
||||
|
||||
// Handle category selection
|
||||
const handleCategorySelect = (category: string) => {
|
||||
setSelectedCategory(category);
|
||||
setDropdownVisible(false);
|
||||
};
|
||||
|
||||
// Toggle dropdown
|
||||
const toggleDropdown = () => {
|
||||
setDropdownVisible(!dropdownVisible);
|
||||
};
|
||||
|
||||
// Get thumbnail URL for YouTube video
|
||||
const getYouTubeThumbnail = (videoId: string, quality: 'default' | 'hq' | 'maxres' = 'hq') => {
|
||||
const qualities = {
|
||||
default: `https://img.youtube.com/vi/${videoId}/default.jpg`,
|
||||
hq: `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`,
|
||||
maxres: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`
|
||||
};
|
||||
return qualities[quality];
|
||||
};
|
||||
|
||||
// Format trailer type for display
|
||||
const formatTrailerType = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'Trailer':
|
||||
return 'Official Trailers';
|
||||
case 'Teaser':
|
||||
return 'Teasers';
|
||||
case 'Clip':
|
||||
return 'Clips & Scenes';
|
||||
case 'Featurette':
|
||||
return 'Featurettes';
|
||||
case 'Behind the Scenes':
|
||||
return 'Behind the Scenes';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon for trailer type
|
||||
const getTrailerTypeIcon = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'Trailer':
|
||||
return 'movie';
|
||||
case 'Teaser':
|
||||
return 'videocam';
|
||||
case 'Clip':
|
||||
return 'content-cut';
|
||||
case 'Featurette':
|
||||
return 'featured-video';
|
||||
case 'Behind the Scenes':
|
||||
return 'camera';
|
||||
default:
|
||||
return 'play-circle-outline';
|
||||
}
|
||||
};
|
||||
|
||||
if (!tmdbId) {
|
||||
return null; // Don't show if no TMDB ID
|
||||
}
|
||||
|
||||
// Don't render if backend availability is still being checked or backend is unavailable
|
||||
if (backendAvailable === null || backendAvailable === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't render if TMDB enrichment is disabled
|
||||
if (!settings?.enrichMetadataWithTMDB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trailerCategories = Object.keys(trailers);
|
||||
const totalVideos = Object.values(trailers).reduce((sum, videos) => sum + videos.length, 0);
|
||||
|
||||
// Don't show section if no trailers (this is normal for many movies/TV shows)
|
||||
if (trailerCategories.length === 0 || totalVideos === 0) {
|
||||
// In development, show a subtle indicator that the section checked but found no trailers
|
||||
if (__DEV__) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Trailers
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.noTrailersContainer}>
|
||||
<Text style={[styles.noTrailersText, { color: currentTheme.colors.textMuted }]}>
|
||||
No trailers available
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, sectionAnimatedStyle]}>
|
||||
{/* Enhanced Header with Category Selector */}
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Trailers & Videos
|
||||
</Text>
|
||||
|
||||
{/* Category Selector - Right Aligned */}
|
||||
{trailerCategories.length > 0 && selectedCategory && (
|
||||
<TouchableOpacity
|
||||
style={[styles.categorySelector, { borderColor: 'rgba(255,255,255,0.6)' }]}
|
||||
onPress={toggleDropdown}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text
|
||||
style={[styles.categorySelectorText, { color: currentTheme.colors.highEmphasis }]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{formatTrailerType(selectedCategory)}
|
||||
</Text>
|
||||
<MaterialIcons
|
||||
name={dropdownVisible ? "expand-less" : "expand-more"}
|
||||
size={18}
|
||||
color="rgba(255,255,255,0.7)"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Category Dropdown Modal */}
|
||||
<Modal
|
||||
visible={dropdownVisible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setDropdownVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.dropdownOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setDropdownVisible(false)}
|
||||
>
|
||||
<View style={[styles.dropdownContainer, {
|
||||
backgroundColor: currentTheme.colors.background,
|
||||
borderColor: currentTheme.colors.primary + '20'
|
||||
}]}>
|
||||
{trailerCategories.map(category => (
|
||||
<TouchableOpacity
|
||||
key={category}
|
||||
style={styles.dropdownItem}
|
||||
onPress={() => handleCategorySelect(category)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.dropdownItemContent}>
|
||||
<View style={[styles.categoryIconContainer, {
|
||||
backgroundColor: currentTheme.colors.primary + '15'
|
||||
}]}>
|
||||
<MaterialIcons
|
||||
name={getTrailerTypeIcon(category) as any}
|
||||
size={14}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.dropdownItemText,
|
||||
{ color: currentTheme.colors.highEmphasis }
|
||||
]}>
|
||||
{formatTrailerType(category)}
|
||||
</Text>
|
||||
<Text style={[styles.dropdownItemCount, { color: currentTheme.colors.textMuted }]}>
|
||||
{trailers[category].length}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
{/* Selected Category Trailers */}
|
||||
{selectedCategory && trailers[selectedCategory] && (
|
||||
<View style={styles.selectedCategoryContent}>
|
||||
{/* Trailers Horizontal Scroll */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.trailersScrollContent}
|
||||
style={styles.trailersScrollView}
|
||||
decelerationRate="fast"
|
||||
snapToInterval={isTablet ? 212 : 182} // card width + gap for smooth scrolling
|
||||
snapToAlignment="start"
|
||||
>
|
||||
{trailers[selectedCategory].map((trailer, index) => (
|
||||
<TouchableOpacity
|
||||
key={trailer.id}
|
||||
style={styles.trailerCard}
|
||||
onPress={() => handleTrailerPress(trailer)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{/* Thumbnail with Gradient Overlay */}
|
||||
<View style={styles.thumbnailWrapper}>
|
||||
<FastImage
|
||||
source={{ uri: getYouTubeThumbnail(trailer.key, 'hq') }}
|
||||
style={styles.thumbnail}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
{/* Subtle Gradient Overlay */}
|
||||
<View style={styles.thumbnailGradient} />
|
||||
</View>
|
||||
|
||||
{/* Trailer Info */}
|
||||
<View style={styles.trailerInfo}>
|
||||
<Text
|
||||
style={[styles.trailerTitle, { color: currentTheme.colors.highEmphasis }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{trailer.displayName || trailer.name}
|
||||
</Text>
|
||||
<Text style={[styles.trailerMeta, { color: currentTheme.colors.textMuted }]}>
|
||||
{new Date(trailer.published_at).getFullYear()}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
{/* Scroll Indicator - shows when there are more items to scroll */}
|
||||
{trailers[selectedCategory].length > (isTablet ? 4 : 3) && (
|
||||
<View style={styles.scrollIndicator}>
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={20}
|
||||
color={currentTheme.colors.textMuted}
|
||||
style={{ opacity: 0.6 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Trailer Modal */}
|
||||
<TrailerModal
|
||||
visible={modalVisible}
|
||||
onClose={handleModalClose}
|
||||
trailer={selectedTrailer}
|
||||
contentTitle={contentTitle}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 16,
|
||||
marginTop: 24,
|
||||
marginBottom: 16,
|
||||
},
|
||||
// Enhanced Header Styles
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
marginBottom: 0,
|
||||
gap: 12,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// Category Selector Styles
|
||||
categorySelector: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
gap: 6,
|
||||
maxWidth: 160, // Limit maximum width to prevent overflow
|
||||
},
|
||||
categorySelectorText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
maxWidth: 120, // Limit text width
|
||||
},
|
||||
|
||||
// Dropdown Styles
|
||||
dropdownOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
dropdownContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 320,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
elevation: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
dropdownItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
dropdownItemContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flex: 1,
|
||||
},
|
||||
dropdownItemText: {
|
||||
fontSize: 16,
|
||||
flex: 1,
|
||||
},
|
||||
dropdownItemCount: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 10,
|
||||
minWidth: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
// Selected Category Content
|
||||
selectedCategoryContent: {
|
||||
marginTop: 16,
|
||||
},
|
||||
|
||||
// Category Section Styles
|
||||
categorySection: {
|
||||
gap: 12,
|
||||
position: 'relative', // For scroll indicator positioning
|
||||
},
|
||||
categoryHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
categoryTitleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
categoryIconContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
categoryTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
categoryBadge: {
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
minWidth: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
categoryBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// Trailers Scroll View
|
||||
trailersScrollView: {
|
||||
marginHorizontal: -4, // Compensate for padding
|
||||
},
|
||||
trailersScrollContent: {
|
||||
paddingHorizontal: 4, // Restore padding for first/last items
|
||||
gap: 12,
|
||||
paddingRight: 20, // Extra padding at end for scroll indicator
|
||||
},
|
||||
|
||||
// Enhanced Trailer Card Styles
|
||||
trailerCard: {
|
||||
width: isTablet ? 200 : 170,
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
overflow: 'hidden',
|
||||
elevation: 2,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
},
|
||||
|
||||
// Thumbnail Styles
|
||||
thumbnailWrapper: {
|
||||
position: 'relative',
|
||||
aspectRatio: 16 / 9,
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
},
|
||||
thumbnailGradient: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.2)',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
},
|
||||
|
||||
|
||||
|
||||
// Trailer Info Styles
|
||||
trailerInfo: {
|
||||
padding: 12,
|
||||
},
|
||||
trailerTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
lineHeight: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
trailerMeta: {
|
||||
fontSize: 10,
|
||||
opacity: 0.7,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// Loading and Error States
|
||||
loadingContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 32,
|
||||
gap: 12,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
},
|
||||
errorContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 32,
|
||||
gap: 8,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
},
|
||||
|
||||
// Scroll Indicator
|
||||
scrollIndicator: {
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
top: '50%',
|
||||
transform: [{ translateY: -10 }],
|
||||
width: 24,
|
||||
height: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
borderRadius: 12,
|
||||
},
|
||||
|
||||
// No Trailers State
|
||||
noTrailersContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 24,
|
||||
},
|
||||
noTrailersText: {
|
||||
fontSize: 14,
|
||||
opacity: 0.6,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
export default TrailersSection;
|
||||
|
|
@ -86,7 +86,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}, [route.params]);
|
||||
// TEMP: force React Native Video for testing (disable VLC)
|
||||
const TEMP_FORCE_RNV = false;
|
||||
const TEMP_FORCE_VLC = true;
|
||||
const TEMP_FORCE_VLC = false;
|
||||
const useVLC = Platform.OS === 'android' && !TEMP_FORCE_RNV && (TEMP_FORCE_VLC || forceVlc);
|
||||
|
||||
// Log player selection
|
||||
|
|
@ -1614,7 +1614,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
resizeModes = ['contain', 'cover'];
|
||||
} else {
|
||||
// On Android with VLC backend, only 'none' (original) and 'cover' (client-side crop)
|
||||
resizeModes = useVLC ? ['none', 'cover'] : ['contain', 'cover', 'none'];
|
||||
resizeModes = useVLC ? ['none', 'cover'] : ['cover', 'none'];
|
||||
}
|
||||
|
||||
const currentIndex = resizeModes.indexOf(resizeMode);
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null);
|
||||
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
|
||||
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
|
||||
const [resizeMode, setResizeMode] = useState<ResizeModeType>('stretch');
|
||||
const [resizeMode, setResizeMode] = useState<ResizeModeType>('contain');
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [seekPosition, setSeekPosition] = useState<number | null>(null);
|
||||
const ksPlayerRef = useRef<KSPlayerRef>(null);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import Animated, {
|
|||
runOnJS,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTrailer } from '../../contexts/TrailerContext';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
|
@ -57,6 +58,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
hideControls = false,
|
||||
}, ref) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { isTrailerPlaying: globalTrailerPlaying } = useTrailer();
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -146,6 +148,22 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
}
|
||||
}, [autoPlay, isComponentMounted]);
|
||||
|
||||
// Respond to global trailer state changes (e.g., when modal opens)
|
||||
useEffect(() => {
|
||||
if (isComponentMounted) {
|
||||
// If global trailer is paused, pause this trailer too
|
||||
if (!globalTrailerPlaying && isPlaying) {
|
||||
logger.info('TrailerPlayer', 'Global trailer paused - pausing this trailer');
|
||||
setIsPlaying(false);
|
||||
}
|
||||
// If global trailer is resumed and autoPlay is enabled, resume this trailer
|
||||
else if (globalTrailerPlaying && !isPlaying && autoPlay) {
|
||||
logger.info('TrailerPlayer', 'Global trailer resumed - resuming this trailer');
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}
|
||||
}, [globalTrailerPlaying, isPlaying, autoPlay, isComponentMounted]);
|
||||
|
||||
const showControlsWithTimeout = useCallback(() => {
|
||||
if (!isComponentMounted) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -107,28 +107,6 @@ interface UseMetadataReturn {
|
|||
imdbId: string | null;
|
||||
scraperStatuses: ScraperStatus[];
|
||||
activeFetchingScrapers: string[];
|
||||
clearScraperCache: () => Promise<void>;
|
||||
invalidateScraperCache: (scraperId: string) => Promise<void>;
|
||||
invalidateContentCache: (type: string, tmdbId: string, season?: number, episode?: number) => Promise<void>;
|
||||
getScraperCacheStats: () => Promise<{
|
||||
local: {
|
||||
totalEntries: number;
|
||||
totalSize: number;
|
||||
oldestEntry: number | null;
|
||||
newestEntry: number | null;
|
||||
};
|
||||
global: {
|
||||
totalEntries: number;
|
||||
totalSize: number;
|
||||
oldestEntry: number | null;
|
||||
newestEntry: number | null;
|
||||
hitRate: number;
|
||||
};
|
||||
combined: {
|
||||
totalEntries: number;
|
||||
hitRate: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
|
||||
|
|
@ -287,9 +265,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
// Optimize streams before storing
|
||||
const optimizedStreams = optimizeStreams(streams);
|
||||
streamCountRef.current += optimizedStreams.length;
|
||||
|
||||
|
||||
if (__DEV__) logger.log(`📊 [${logPrefix}:${sourceName}] Optimized ${streams.length} → ${optimizedStreams.length} streams, total: ${streamCountRef.current}`);
|
||||
|
||||
|
||||
// Use debounced update to prevent rapid state changes
|
||||
debouncedStreamUpdate(() => {
|
||||
const updateState = (prevState: GroupedStreams): GroupedStreams => {
|
||||
|
|
@ -302,7 +280,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// Track response order for addons
|
||||
setAddonResponseOrder(prevOrder => {
|
||||
if (!prevOrder.includes(addonId)) {
|
||||
|
|
@ -310,7 +288,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
}
|
||||
return prevOrder;
|
||||
});
|
||||
|
||||
|
||||
if (isEpisode) {
|
||||
setEpisodeStreams(updateState);
|
||||
setLoadingEpisodeStreams(false);
|
||||
|
|
@ -320,7 +298,38 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
}
|
||||
});
|
||||
} else {
|
||||
if (__DEV__) logger.log(`🤷 [${logPrefix}:${sourceName}] No streams found for addon ${addonName} (${addonId})`);
|
||||
// Even providers with no streams should be added to the streams object
|
||||
// This ensures streamsEmpty becomes false and UI shows available streams progressively
|
||||
if (__DEV__) logger.log(`🤷 [${logPrefix}:${sourceName}] No streams found for addon ${addonName} (${addonId})`);
|
||||
|
||||
debouncedStreamUpdate(() => {
|
||||
const updateState = (prevState: GroupedStreams): GroupedStreams => {
|
||||
if (__DEV__) logger.log(`🔄 [${logPrefix}:${sourceName}] Adding empty provider ${addonName} (${addonId}) to state`);
|
||||
return {
|
||||
...prevState,
|
||||
[addonId]: {
|
||||
addonName: addonName,
|
||||
streams: [] // Empty array for providers with no streams
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Track response order for addons
|
||||
setAddonResponseOrder(prevOrder => {
|
||||
if (!prevOrder.includes(addonId)) {
|
||||
return [...prevOrder, addonId];
|
||||
}
|
||||
return prevOrder;
|
||||
});
|
||||
|
||||
if (isEpisode) {
|
||||
setEpisodeStreams(updateState);
|
||||
setLoadingEpisodeStreams(false);
|
||||
} else {
|
||||
setGroupedStreams(updateState);
|
||||
setLoadingStreams(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Handle case where callback provides null streams without error (e.g., empty results)
|
||||
|
|
@ -1974,36 +1983,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
};
|
||||
}, [cleanupStreams]);
|
||||
|
||||
// Cache management methods
|
||||
const clearScraperCache = useCallback(async () => {
|
||||
await localScraperService.clearScraperCache();
|
||||
}, []);
|
||||
|
||||
const invalidateScraperCache = useCallback(async (scraperId: string) => {
|
||||
await localScraperService.invalidateScraperCache(scraperId);
|
||||
}, []);
|
||||
|
||||
const invalidateContentCache = useCallback(async (type: string, tmdbId: string, season?: number, episode?: number) => {
|
||||
await localScraperService.invalidateContentCache(type, tmdbId, season, episode);
|
||||
}, []);
|
||||
|
||||
const getScraperCacheStats = useCallback(async () => {
|
||||
const localStats = await localScraperService.getCacheStats();
|
||||
return {
|
||||
local: localStats.local,
|
||||
global: {
|
||||
totalEntries: 0,
|
||||
totalSize: 0,
|
||||
oldestEntry: null,
|
||||
newestEntry: null,
|
||||
hitRate: 0
|
||||
},
|
||||
combined: {
|
||||
totalEntries: localStats.local.totalEntries,
|
||||
hitRate: 0
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
metadata,
|
||||
|
|
@ -2038,9 +2017,5 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
imdbId,
|
||||
scraperStatuses,
|
||||
activeFetchingScrapers,
|
||||
clearScraperCache,
|
||||
invalidateScraperCache,
|
||||
invalidateContentCache,
|
||||
getScraperCacheStats,
|
||||
};
|
||||
};
|
||||
|
|
@ -672,7 +672,7 @@ const MainTabs = () => {
|
|||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 85,
|
||||
height: 85 + insets.bottom,
|
||||
backgroundColor: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
|
|
@ -722,7 +722,7 @@ const MainTabs = () => {
|
|||
<View
|
||||
style={{
|
||||
height: '100%',
|
||||
paddingBottom: 20,
|
||||
paddingBottom: 20 + insets.bottom,
|
||||
paddingTop: 12,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
|
|
@ -819,6 +819,7 @@ const MainTabs = () => {
|
|||
// Dynamically require to avoid impacting Android bundle
|
||||
const { createNativeBottomTabNavigator } = require('@bottom-tabs/react-navigation');
|
||||
const IOSTab = createNativeBottomTabNavigator();
|
||||
const downloadsEnabled = appSettings?.enableDownloads !== false;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: currentTheme.colors.darkBackground }}>
|
||||
|
|
@ -828,6 +829,8 @@ const MainTabs = () => {
|
|||
backgroundColor="transparent"
|
||||
/>
|
||||
<IOSTab.Navigator
|
||||
key={`ios-tabs-${downloadsEnabled ? 'with-dl' : 'no-dl'}`}
|
||||
initialRouteName="Home"
|
||||
// Native tab bar handles its own visuals; keep options minimal
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
|
|
@ -863,7 +866,7 @@ const MainTabs = () => {
|
|||
tabBarIcon: () => ({ sfSymbol: 'magnifyingglass' }),
|
||||
}}
|
||||
/>
|
||||
{appSettings?.enableDownloads !== false && (
|
||||
{downloadsEnabled && (
|
||||
<IOSTab.Screen
|
||||
name="Downloads"
|
||||
component={DownloadsScreen}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
InteractionManager,
|
||||
AppState
|
||||
} from 'react-native';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { LegendList } from '@legendapp/list';
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
|
|
@ -764,7 +764,7 @@ const HomeScreen = () => {
|
|||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
<FlashList
|
||||
<LegendList
|
||||
data={listData}
|
||||
renderItem={renderListItem}
|
||||
keyExtractor={keyExtractor}
|
||||
|
|
@ -774,8 +774,8 @@ const HomeScreen = () => {
|
|||
ListFooterComponent={ListFooterComponent}
|
||||
onEndReached={handleLoadMoreCatalogs}
|
||||
onEndReachedThreshold={0.6}
|
||||
removeClippedSubviews={true}
|
||||
scrollEventThrottle={64}
|
||||
recycleItems={true}
|
||||
maintainVisibleContentPosition
|
||||
onScroll={handleScroll}
|
||||
/>
|
||||
{/* Toasts are rendered globally at root */}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { MovieContent } from '../components/metadata/MovieContent';
|
|||
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
|
||||
import { RatingsSection } from '../components/metadata/RatingsSection';
|
||||
import { CommentsSection, CommentBottomSheet } from '../components/metadata/CommentsSection';
|
||||
import TrailersSection from '../components/metadata/TrailersSection';
|
||||
import { RouteParams, Episode } from '../types/metadata';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
|
|
@ -210,6 +211,9 @@ const MetadataScreen: React.FC = () => {
|
|||
const watchProgressData = useWatchProgress(id, Object.keys(groupedEpisodes).length > 0 ? 'series' : type as 'movie' | 'series', episodeId, episodes);
|
||||
const assetData = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata);
|
||||
const animations = useMetadataAnimations(safeAreaTop, watchProgressData.watchProgress);
|
||||
|
||||
// Stable logo URI from HeroSection
|
||||
const [stableLogoUri, setStableLogoUri] = React.useState<string | null>(null);
|
||||
|
||||
// Extract dominant color from hero image for dynamic background
|
||||
const heroImageUri = useMemo(() => {
|
||||
|
|
@ -869,7 +873,7 @@ const MetadataScreen: React.FC = () => {
|
|||
{metadata && (
|
||||
<>
|
||||
{/* Floating Header - Optimized */}
|
||||
<FloatingHeader
|
||||
<FloatingHeader
|
||||
metadata={metadata}
|
||||
logoLoadError={assetData.logoLoadError}
|
||||
handleBack={handleBack}
|
||||
|
|
@ -880,6 +884,7 @@ const MetadataScreen: React.FC = () => {
|
|||
headerElementsOpacity={animations.headerElementsOpacity}
|
||||
safeAreaTop={safeAreaTop}
|
||||
setLogoLoadError={assetData.setLogoLoadError}
|
||||
stableLogoUri={stableLogoUri}
|
||||
/>
|
||||
|
||||
<Animated.ScrollView
|
||||
|
|
@ -907,6 +912,7 @@ const MetadataScreen: React.FC = () => {
|
|||
watchProgressOpacity={animations.watchProgressOpacity}
|
||||
watchProgressWidth={animations.watchProgressWidth}
|
||||
watchProgress={watchProgressData.watchProgress}
|
||||
onStableLogoUriChange={setStableLogoUri}
|
||||
type={Object.keys(groupedEpisodes).length > 0 ? 'series' : type as 'movie' | 'series'}
|
||||
getEpisodeDetails={watchProgressData.getEpisodeDetails}
|
||||
handleShowStreams={handleShowStreams}
|
||||
|
|
@ -992,6 +998,16 @@ const MetadataScreen: React.FC = () => {
|
|||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Trailers Section - Lazy loaded */}
|
||||
{shouldLoadSecondaryData && tmdbId && settings.enrichMetadataWithTMDB && (
|
||||
<TrailersSection
|
||||
tmdbId={tmdbId}
|
||||
type={Object.keys(groupedEpisodes).length > 0 ? 'tv' : 'movie'}
|
||||
contentId={id}
|
||||
contentTitle={metadata?.name || (metadata as any)?.title || 'Unknown'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Comments Section - Lazy loaded */}
|
||||
{shouldLoadSecondaryData && imdbId && (
|
||||
<MemoizedCommentsSection
|
||||
|
|
|
|||
|
|
@ -409,17 +409,7 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
|
|||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.streamAction}
|
||||
onPress={() => onPress()}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="play-arrow"
|
||||
size={22}
|
||||
color={theme.colors.white}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{settings?.enableDownloads !== false && (
|
||||
<TouchableOpacity
|
||||
style={[styles.streamAction, { marginLeft: 8, backgroundColor: theme.colors.elevation2 }]}
|
||||
|
|
|
|||
|
|
@ -1,267 +0,0 @@
|
|||
import { localScraperCacheService, CachedScraperResult } from './localScraperCacheService';
|
||||
import { logger } from '../utils/logger';
|
||||
import { Stream } from '../types/streams';
|
||||
|
||||
export interface HybridCacheResult {
|
||||
validResults: Array<CachedScraperResult>;
|
||||
expiredScrapers: string[];
|
||||
allExpired: boolean;
|
||||
source: 'local';
|
||||
}
|
||||
|
||||
export interface HybridCacheStats {
|
||||
local: {
|
||||
totalEntries: number;
|
||||
totalSize: number;
|
||||
oldestEntry: number | null;
|
||||
newestEntry: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
class HybridCacheService {
|
||||
private static instance: HybridCacheService;
|
||||
// Global caching removed; local-only
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): HybridCacheService {
|
||||
if (!HybridCacheService.instance) {
|
||||
HybridCacheService.instance = new HybridCacheService();
|
||||
}
|
||||
return HybridCacheService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached results (local-only)
|
||||
*/
|
||||
async getCachedResults(
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
season?: number,
|
||||
episode?: number,
|
||||
userSettings?: { enableLocalScrapers?: boolean; enabledScrapers?: Set<string> }
|
||||
): Promise<HybridCacheResult> {
|
||||
try {
|
||||
// Filter function to check if scraper is enabled for current user
|
||||
const isScraperEnabled = (scraperId: string): boolean => {
|
||||
if (!userSettings?.enableLocalScrapers) return false;
|
||||
if (userSettings?.enabledScrapers) {
|
||||
return userSettings.enabledScrapers.has(scraperId);
|
||||
}
|
||||
// If no specific scraper settings, assume all are enabled if local scrapers are enabled
|
||||
return true;
|
||||
};
|
||||
|
||||
// Local cache only
|
||||
const localResults = await localScraperCacheService.getCachedResults(type, tmdbId, season, episode);
|
||||
|
||||
// Filter results based on user settings
|
||||
const filteredLocalResults = {
|
||||
...localResults,
|
||||
validResults: localResults.validResults.filter(result => isScraperEnabled(result.scraperId)),
|
||||
expiredScrapers: localResults.expiredScrapers.filter(scraperId => isScraperEnabled(scraperId))
|
||||
};
|
||||
|
||||
logger.log(`[HybridCache] Using local cache: ${filteredLocalResults.validResults.length} results (filtered from ${localResults.validResults.length})`);
|
||||
return {
|
||||
...filteredLocalResults,
|
||||
source: 'local'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[HybridCache] Error getting cached results:', error);
|
||||
return {
|
||||
validResults: [],
|
||||
expiredScrapers: [],
|
||||
allExpired: true,
|
||||
source: 'local'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache results (local-only)
|
||||
*/
|
||||
async cacheResults(
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
results: Array<{
|
||||
scraperId: string;
|
||||
scraperName: string;
|
||||
streams: Stream[] | null;
|
||||
error: Error | null;
|
||||
}>,
|
||||
season?: number,
|
||||
episode?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Cache in local storage
|
||||
const localPromises = results.map(result =>
|
||||
localScraperCacheService.cacheScraperResult(
|
||||
type, tmdbId, result.scraperId, result.scraperName,
|
||||
result.streams, result.error, season, episode
|
||||
)
|
||||
);
|
||||
await Promise.all(localPromises);
|
||||
logger.log(`[HybridCache] Cached ${results.length} results in local cache`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[HybridCache] Error caching results:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a single scraper result
|
||||
*/
|
||||
async cacheScraperResult(
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
scraperId: string,
|
||||
scraperName: string,
|
||||
streams: Stream[] | null,
|
||||
error: Error | null,
|
||||
season?: number,
|
||||
episode?: number
|
||||
): Promise<void> {
|
||||
await this.cacheResults(type, tmdbId, [{
|
||||
scraperId,
|
||||
scraperName,
|
||||
streams,
|
||||
error
|
||||
}], season, episode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of scrapers that need to be re-run (expired, failed, or not cached)
|
||||
*/
|
||||
async getScrapersToRerun(
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
availableScrapers: Array<{ id: string; name: string }>,
|
||||
season?: number,
|
||||
episode?: number,
|
||||
userSettings?: { enableLocalScrapers?: boolean; enabledScrapers?: Set<string> }
|
||||
): Promise<string[]> {
|
||||
const { validResults, expiredScrapers } = await this.getCachedResults(type, tmdbId, season, episode, userSettings);
|
||||
|
||||
const validScraperIds = new Set(validResults.map(r => r.scraperId));
|
||||
const expiredScraperIds = new Set(expiredScrapers);
|
||||
|
||||
// Get scrapers that previously failed (returned no streams)
|
||||
const failedScraperIds = new Set(
|
||||
validResults
|
||||
.filter(r => !r.success || r.streams.length === 0)
|
||||
.map(r => r.scraperId)
|
||||
);
|
||||
|
||||
// Return scrapers that are:
|
||||
// 1. Not cached at all
|
||||
// 2. Expired
|
||||
// 3. Previously failed (regardless of cache status)
|
||||
const scrapersToRerun = availableScrapers
|
||||
.filter(scraper =>
|
||||
!validScraperIds.has(scraper.id) ||
|
||||
expiredScraperIds.has(scraper.id) ||
|
||||
failedScraperIds.has(scraper.id)
|
||||
)
|
||||
.map(scraper => scraper.id);
|
||||
|
||||
logger.log(`[HybridCache] Scrapers to re-run: ${scrapersToRerun.join(', ')} (not cached: ${availableScrapers.filter(s => !validScraperIds.has(s.id)).length}, expired: ${expiredScrapers.length}, failed: ${failedScraperIds.size})`);
|
||||
|
||||
return scrapersToRerun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all valid cached streams
|
||||
*/
|
||||
async getCachedStreams(
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
season?: number,
|
||||
episode?: number,
|
||||
userSettings?: { enableLocalScrapers?: boolean; enabledScrapers?: Set<string> }
|
||||
): Promise<Stream[]> {
|
||||
const { validResults } = await this.getCachedResults(type, tmdbId, season, episode, userSettings);
|
||||
|
||||
// Flatten all valid streams
|
||||
const allStreams: Stream[] = [];
|
||||
for (const result of validResults) {
|
||||
if (result.success && result.streams) {
|
||||
allStreams.push(...result.streams);
|
||||
}
|
||||
}
|
||||
|
||||
return allStreams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for specific content (local-only)
|
||||
*/
|
||||
async invalidateContent(
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
season?: number,
|
||||
episode?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await localScraperCacheService.invalidateContent(type, tmdbId, season, episode);
|
||||
logger.log(`[HybridCache] Invalidated cache for ${type}:${tmdbId}`);
|
||||
} catch (error) {
|
||||
logger.error('[HybridCache] Error invalidating cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for specific scraper (local-only)
|
||||
*/
|
||||
async invalidateScraper(scraperId: string): Promise<void> {
|
||||
try {
|
||||
await localScraperCacheService.invalidateScraper(scraperId);
|
||||
logger.log(`[HybridCache] Invalidated cache for scraper ${scraperId}`);
|
||||
} catch (error) {
|
||||
logger.error('[HybridCache] Error invalidating scraper cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached results (local-only)
|
||||
*/
|
||||
async clearAllCache(): Promise<void> {
|
||||
try {
|
||||
await localScraperCacheService.clearAllCache();
|
||||
logger.log('[HybridCache] Cleared all local cache');
|
||||
} catch (error) {
|
||||
logger.error('[HybridCache] Error clearing cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics (local-only)
|
||||
*/
|
||||
async getCacheStats(): Promise<HybridCacheStats> {
|
||||
try {
|
||||
const localStats = await localScraperCacheService.getCacheStats();
|
||||
return { local: localStats };
|
||||
} catch (error) {
|
||||
logger.error('[HybridCache] Error getting cache stats:', error);
|
||||
return { local: { totalEntries: 0, totalSize: 0, oldestEntry: null, newestEntry: null } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old entries (local-only)
|
||||
*/
|
||||
async cleanupOldEntries(): Promise<void> {
|
||||
try {
|
||||
await localScraperCacheService.clearAllCache();
|
||||
logger.log('[HybridCache] Cleaned up old entries');
|
||||
} catch (error) {
|
||||
logger.error('[HybridCache] Error cleaning up old entries:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration APIs removed; local-only
|
||||
}
|
||||
|
||||
export const hybridCacheService = HybridCacheService.getInstance();
|
||||
export default hybridCacheService;
|
||||
|
|
@ -1,437 +0,0 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { logger } from '../utils/logger';
|
||||
import { Stream } from '../types/streams';
|
||||
|
||||
export interface CachedScraperResult {
|
||||
streams: Stream[];
|
||||
timestamp: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
scraperId: string;
|
||||
scraperName: string;
|
||||
}
|
||||
|
||||
export interface CachedContentResult {
|
||||
contentKey: string; // e.g., "movie:123" or "tv:123:1:2"
|
||||
results: CachedScraperResult[];
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
class LocalScraperCacheService {
|
||||
private static instance: LocalScraperCacheService;
|
||||
private readonly CACHE_KEY_PREFIX = 'local-scraper-cache';
|
||||
private readonly DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes default TTL
|
||||
private readonly MAX_CACHE_SIZE = 200; // Maximum number of cached content items
|
||||
private readonly FAILED_RETRY_TTL_MS = 5 * 60 * 1000; // 5 minutes for failed scrapers
|
||||
private readonly SUCCESS_TTL_MS = 60 * 60 * 1000; // 1 hour for successful scrapers
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): LocalScraperCacheService {
|
||||
if (!LocalScraperCacheService.instance) {
|
||||
LocalScraperCacheService.instance = new LocalScraperCacheService();
|
||||
}
|
||||
return LocalScraperCacheService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for content
|
||||
*/
|
||||
private getContentKey(type: string, tmdbId: string, season?: number, episode?: number): string {
|
||||
if (season !== undefined && episode !== undefined) {
|
||||
return `${type}:${tmdbId}:${season}:${episode}`;
|
||||
}
|
||||
return `${type}:${tmdbId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AsyncStorage key for cached content
|
||||
*/
|
||||
private getStorageKey(contentKey: string): string {
|
||||
return `${this.CACHE_KEY_PREFIX}:${contentKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cached result is still valid based on TTL
|
||||
*/
|
||||
private isCacheValid(timestamp: number, ttl: number): boolean {
|
||||
return Date.now() - timestamp < ttl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached results for content, filtering out expired results
|
||||
*/
|
||||
async getCachedResults(
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
season?: number,
|
||||
episode?: number
|
||||
): Promise<{
|
||||
validResults: CachedScraperResult[];
|
||||
expiredScrapers: string[];
|
||||
allExpired: boolean;
|
||||
}> {
|
||||
try {
|
||||
const contentKey = this.getContentKey(type, tmdbId, season, episode);
|
||||
const storageKey = this.getStorageKey(contentKey);
|
||||
|
||||
const cachedData = await AsyncStorage.getItem(storageKey);
|
||||
if (!cachedData) {
|
||||
return {
|
||||
validResults: [],
|
||||
expiredScrapers: [],
|
||||
allExpired: true
|
||||
};
|
||||
}
|
||||
|
||||
const parsed: CachedContentResult = JSON.parse(cachedData);
|
||||
|
||||
// Check if the entire cache entry is expired
|
||||
if (!this.isCacheValid(parsed.timestamp, parsed.ttl)) {
|
||||
// Remove expired entry
|
||||
await AsyncStorage.removeItem(storageKey);
|
||||
return {
|
||||
validResults: [],
|
||||
expiredScrapers: parsed.results.map(r => r.scraperId),
|
||||
allExpired: true
|
||||
};
|
||||
}
|
||||
|
||||
// Filter valid results and identify expired scrapers
|
||||
const validResults: CachedScraperResult[] = [];
|
||||
const expiredScrapers: string[] = [];
|
||||
|
||||
for (const result of parsed.results) {
|
||||
// Use different TTL based on success/failure
|
||||
const ttl = result.success ? this.SUCCESS_TTL_MS : this.FAILED_RETRY_TTL_MS;
|
||||
|
||||
if (this.isCacheValid(result.timestamp, ttl)) {
|
||||
validResults.push(result);
|
||||
} else {
|
||||
expiredScrapers.push(result.scraperId);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log(`[LocalScraperCache] Retrieved ${validResults.length} valid results, ${expiredScrapers.length} expired scrapers for ${contentKey}`);
|
||||
|
||||
return {
|
||||
validResults,
|
||||
expiredScrapers,
|
||||
allExpired: validResults.length === 0
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[LocalScraperCache] Error getting cached results:', error);
|
||||
return {
|
||||
validResults: [],
|
||||
expiredScrapers: [],
|
||||
allExpired: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache results for specific scrapers
|
||||
*/
|
||||
async cacheResults(
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
results: CachedScraperResult[],
|
||||
season?: number,
|
||||
episode?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const contentKey = this.getContentKey(type, tmdbId, season, episode);
|
||||
const storageKey = this.getStorageKey(contentKey);
|
||||
|
||||
// Get existing cached data
|
||||
const existingData = await AsyncStorage.getItem(storageKey);
|
||||
let cachedContent: CachedContentResult;
|
||||
|
||||
if (existingData) {
|
||||
cachedContent = JSON.parse(existingData);
|
||||
|
||||
// Update existing results or add new ones
|
||||
for (const newResult of results) {
|
||||
const existingIndex = cachedContent.results.findIndex(r => r.scraperId === newResult.scraperId);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing result
|
||||
cachedContent.results[existingIndex] = newResult;
|
||||
} else {
|
||||
// Add new result
|
||||
cachedContent.results.push(newResult);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new cache entry
|
||||
cachedContent = {
|
||||
contentKey,
|
||||
results,
|
||||
timestamp: Date.now(),
|
||||
ttl: this.DEFAULT_TTL_MS
|
||||
};
|
||||
}
|
||||
|
||||
// Update timestamp
|
||||
cachedContent.timestamp = Date.now();
|
||||
|
||||
// Store updated cache
|
||||
await AsyncStorage.setItem(storageKey, JSON.stringify(cachedContent));
|
||||
|
||||
// Clean up old cache entries if we exceed the limit
|
||||
await this.cleanupOldEntries();
|
||||
|
||||
logger.log(`[LocalScraperCache] Cached ${results.length} results for ${contentKey}`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[LocalScraperCache] Error caching results:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a single scraper result
|
||||
*/
|
||||
async cacheScraperResult(
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
scraperId: string,
|
||||
scraperName: string,
|
||||
streams: Stream[] | null,
|
||||
error: Error | null,
|
||||
season?: number,
|
||||
episode?: number
|
||||
): Promise<void> {
|
||||
const result: CachedScraperResult = {
|
||||
streams: streams || [],
|
||||
timestamp: Date.now(),
|
||||
success: !error && streams !== null,
|
||||
error: error?.message,
|
||||
scraperId,
|
||||
scraperName
|
||||
};
|
||||
|
||||
await this.cacheResults(type, tmdbId, [result], season, episode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of scrapers that need to be re-run (expired, failed, or not cached)
|
||||
*/
|
||||
async getScrapersToRerun(
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
availableScrapers: Array<{ id: string; name: string }>,
|
||||
season?: number,
|
||||
episode?: number
|
||||
): Promise<string[]> {
|
||||
const { validResults, expiredScrapers } = await this.getCachedResults(type, tmdbId, season, episode);
|
||||
|
||||
const validScraperIds = new Set(validResults.map(r => r.scraperId));
|
||||
const expiredScraperIds = new Set(expiredScrapers);
|
||||
|
||||
// Get scrapers that previously failed (returned no streams)
|
||||
const failedScraperIds = new Set(
|
||||
validResults
|
||||
.filter(r => !r.success || r.streams.length === 0)
|
||||
.map(r => r.scraperId)
|
||||
);
|
||||
|
||||
// Return scrapers that are:
|
||||
// 1. Not cached at all
|
||||
// 2. Expired
|
||||
// 3. Previously failed (regardless of cache status)
|
||||
const scrapersToRerun = availableScrapers
|
||||
.filter(scraper =>
|
||||
!validScraperIds.has(scraper.id) ||
|
||||
expiredScraperIds.has(scraper.id) ||
|
||||
failedScraperIds.has(scraper.id)
|
||||
)
|
||||
.map(scraper => scraper.id);
|
||||
|
||||
logger.log(`[LocalScraperCache] Scrapers to re-run: ${scrapersToRerun.join(', ')} (not cached: ${availableScrapers.filter(s => !validScraperIds.has(s.id)).length}, expired: ${expiredScrapers.length}, failed: ${failedScraperIds.size})`);
|
||||
|
||||
return scrapersToRerun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all valid cached streams for content
|
||||
*/
|
||||
async getCachedStreams(
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
season?: number,
|
||||
episode?: number
|
||||
): Promise<Stream[]> {
|
||||
const { validResults } = await this.getCachedResults(type, tmdbId, season, episode);
|
||||
|
||||
// Flatten all valid streams
|
||||
const allStreams: Stream[] = [];
|
||||
for (const result of validResults) {
|
||||
if (result.success && result.streams) {
|
||||
allStreams.push(...result.streams);
|
||||
}
|
||||
}
|
||||
|
||||
return allStreams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for specific content
|
||||
*/
|
||||
async invalidateContent(
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
season?: number,
|
||||
episode?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const contentKey = this.getContentKey(type, tmdbId, season, episode);
|
||||
const storageKey = this.getStorageKey(contentKey);
|
||||
|
||||
await AsyncStorage.removeItem(storageKey);
|
||||
logger.log(`[LocalScraperCache] Invalidated cache for ${contentKey}`);
|
||||
} catch (error) {
|
||||
logger.error('[LocalScraperCache] Error invalidating cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for specific scraper across all content
|
||||
*/
|
||||
async invalidateScraper(scraperId: string): Promise<void> {
|
||||
try {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const cacheKeys = keys.filter(key => key.startsWith(this.CACHE_KEY_PREFIX));
|
||||
|
||||
for (const key of cacheKeys) {
|
||||
const cachedData = await AsyncStorage.getItem(key);
|
||||
if (cachedData) {
|
||||
const parsed: CachedContentResult = JSON.parse(cachedData);
|
||||
|
||||
// Remove results from this scraper
|
||||
parsed.results = parsed.results.filter(r => r.scraperId !== scraperId);
|
||||
|
||||
if (parsed.results.length === 0) {
|
||||
// Remove entire cache entry if no results left
|
||||
await AsyncStorage.removeItem(key);
|
||||
} else {
|
||||
// Update cache with remaining results
|
||||
await AsyncStorage.setItem(key, JSON.stringify(parsed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.log(`[LocalScraperCache] Invalidated cache for scraper ${scraperId}`);
|
||||
} catch (error) {
|
||||
logger.error('[LocalScraperCache] Error invalidating scraper cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached results
|
||||
*/
|
||||
async clearAllCache(): Promise<void> {
|
||||
try {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const cacheKeys = keys.filter(key => key.startsWith(this.CACHE_KEY_PREFIX));
|
||||
|
||||
await AsyncStorage.multiRemove(cacheKeys);
|
||||
logger.log(`[LocalScraperCache] Cleared ${cacheKeys.length} cache entries`);
|
||||
} catch (error) {
|
||||
logger.error('[LocalScraperCache] Error clearing cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old cache entries to stay within size limit
|
||||
*/
|
||||
private async cleanupOldEntries(): Promise<void> {
|
||||
try {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const cacheKeys = keys.filter(key => key.startsWith(this.CACHE_KEY_PREFIX));
|
||||
|
||||
if (cacheKeys.length <= this.MAX_CACHE_SIZE) {
|
||||
return; // No cleanup needed
|
||||
}
|
||||
|
||||
// Get all cache entries with their timestamps
|
||||
const entriesWithTimestamps = await Promise.all(
|
||||
cacheKeys.map(async (key) => {
|
||||
const data = await AsyncStorage.getItem(key);
|
||||
if (data) {
|
||||
const parsed: CachedContentResult = JSON.parse(data);
|
||||
return { key, timestamp: parsed.timestamp };
|
||||
}
|
||||
return { key, timestamp: 0 };
|
||||
})
|
||||
);
|
||||
|
||||
// Sort by timestamp (oldest first)
|
||||
entriesWithTimestamps.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
// Remove oldest entries
|
||||
const entriesToRemove = entriesWithTimestamps.slice(0, cacheKeys.length - this.MAX_CACHE_SIZE);
|
||||
const keysToRemove = entriesToRemove.map(entry => entry.key);
|
||||
|
||||
if (keysToRemove.length > 0) {
|
||||
await AsyncStorage.multiRemove(keysToRemove);
|
||||
logger.log(`[LocalScraperCache] Cleaned up ${keysToRemove.length} old cache entries`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[LocalScraperCache] Error cleaning up cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
async getCacheStats(): Promise<{
|
||||
totalEntries: number;
|
||||
totalSize: number;
|
||||
oldestEntry: number | null;
|
||||
newestEntry: number | null;
|
||||
}> {
|
||||
try {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const cacheKeys = keys.filter(key => key.startsWith(this.CACHE_KEY_PREFIX));
|
||||
|
||||
let totalSize = 0;
|
||||
let oldestTimestamp: number | null = null;
|
||||
let newestTimestamp: number | null = null;
|
||||
|
||||
for (const key of cacheKeys) {
|
||||
const data = await AsyncStorage.getItem(key);
|
||||
if (data) {
|
||||
totalSize += data.length;
|
||||
const parsed: CachedContentResult = JSON.parse(data);
|
||||
|
||||
if (oldestTimestamp === null || parsed.timestamp < oldestTimestamp) {
|
||||
oldestTimestamp = parsed.timestamp;
|
||||
}
|
||||
if (newestTimestamp === null || parsed.timestamp > newestTimestamp) {
|
||||
newestTimestamp = parsed.timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalEntries: cacheKeys.length,
|
||||
totalSize,
|
||||
oldestEntry: oldestTimestamp,
|
||||
newestEntry: newestTimestamp
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[LocalScraperCache] Error getting cache stats:', error);
|
||||
return {
|
||||
totalEntries: 0,
|
||||
totalSize: 0,
|
||||
oldestEntry: null,
|
||||
newestEntry: null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const localScraperCacheService = LocalScraperCacheService.getInstance();
|
||||
export default localScraperCacheService;
|
||||
|
|
@ -4,8 +4,6 @@ import { Platform } from 'react-native';
|
|||
import { logger } from '../utils/logger';
|
||||
import { Stream } from '../types/streams';
|
||||
import { cacheService } from './cacheService';
|
||||
import { localScraperCacheService } from './localScraperCacheService';
|
||||
import { hybridCacheService } from './hybridCacheService';
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
// Types for local scrapers
|
||||
|
|
@ -862,86 +860,44 @@ class LocalScraperService {
|
|||
}
|
||||
}
|
||||
|
||||
// Execute scrapers for streams with caching
|
||||
// Execute scrapers for streams
|
||||
async getStreams(type: string, tmdbId: string, season?: number, episode?: number, callback?: ScraperCallback): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
|
||||
// Get available scrapers from manifest (respects manifestEnabled)
|
||||
const availableScrapers = await this.getAvailableScrapers();
|
||||
const enabledScrapers = availableScrapers
|
||||
.filter(scraper =>
|
||||
scraper.enabled &&
|
||||
scraper.manifestEnabled !== false &&
|
||||
.filter(scraper =>
|
||||
scraper.enabled &&
|
||||
scraper.manifestEnabled !== false &&
|
||||
scraper.supportedTypes.includes(type as 'movie' | 'tv')
|
||||
);
|
||||
|
||||
|
||||
if (enabledScrapers.length === 0) {
|
||||
logger.log('[LocalScraperService] No enabled scrapers found for type:', type);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current user settings for enabled scrapers
|
||||
const userSettings = await this.getUserScraperSettings();
|
||||
|
||||
// Check cache for existing results (hybrid: global first, then local)
|
||||
const { validResults, expiredScrapers, allExpired, source } = await hybridCacheService.getCachedResults(type, tmdbId, season, episode, userSettings);
|
||||
|
||||
// Immediately return cached results for valid scrapers
|
||||
if (validResults.length > 0) {
|
||||
logger.log(`[LocalScraperService] Returning ${validResults.length} cached results for ${type}:${tmdbId} (source: ${source})`);
|
||||
|
||||
for (const cachedResult of validResults) {
|
||||
if (cachedResult.success && cachedResult.streams.length > 0) {
|
||||
// Streams are already in the correct format, just pass them through
|
||||
if (callback) {
|
||||
callback(cachedResult.streams, cachedResult.scraperId, cachedResult.scraperName, null);
|
||||
}
|
||||
} else if (callback) {
|
||||
// Return error for failed cached results
|
||||
const error = cachedResult.error ? new Error(cachedResult.error) : new Error('Scraper failed');
|
||||
callback(null, cachedResult.scraperId, cachedResult.scraperName, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which scrapers need to be re-run
|
||||
const scrapersToRerun = enabledScrapers.filter(scraper => {
|
||||
const hasValidResult = validResults.some(r => r.scraperId === scraper.id);
|
||||
const isExpired = expiredScrapers.includes(scraper.id);
|
||||
const hasFailedResult = validResults.some(r => r.scraperId === scraper.id && (!r.success || r.streams.length === 0));
|
||||
|
||||
return !hasValidResult || isExpired || hasFailedResult;
|
||||
});
|
||||
|
||||
if (scrapersToRerun.length === 0) {
|
||||
logger.log('[LocalScraperService] All scrapers have valid cached results');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`[LocalScraperService] Re-running ${scrapersToRerun.length} scrapers for ${type}:${tmdbId}`, {
|
||||
totalEnabled: enabledScrapers.length,
|
||||
expired: expiredScrapers.length,
|
||||
failed: validResults.filter(r => !r.success || r.streams.length === 0).length,
|
||||
notCached: enabledScrapers.length - validResults.length,
|
||||
scrapersToRerun: scrapersToRerun.map(s => s.name)
|
||||
logger.log(`[LocalScraperService] Executing ${enabledScrapers.length} scrapers for ${type}:${tmdbId}`, {
|
||||
scrapers: enabledScrapers.map(s => s.name)
|
||||
});
|
||||
|
||||
// Generate a lightweight request id for tracing
|
||||
const requestId = `rs_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// Execute only scrapers that need to be re-run
|
||||
for (const scraper of scrapersToRerun) {
|
||||
this.executeScraperWithCaching(scraper, type, tmdbId, season, episode, callback, requestId);
|
||||
// Execute all enabled scrapers
|
||||
for (const scraper of enabledScrapers) {
|
||||
this.executeScraper(scraper, type, tmdbId, season, episode, callback, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute individual scraper with caching
|
||||
private async executeScraperWithCaching(
|
||||
scraper: ScraperInfo,
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
season?: number,
|
||||
episode?: number,
|
||||
// Execute individual scraper
|
||||
private async executeScraper(
|
||||
scraper: ScraperInfo,
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
season?: number,
|
||||
episode?: number,
|
||||
callback?: ScraperCallback,
|
||||
requestId?: string
|
||||
): Promise<void> {
|
||||
|
|
@ -950,10 +906,10 @@ class LocalScraperService {
|
|||
if (!code) {
|
||||
throw new Error(`No code found for scraper ${scraper.id}`);
|
||||
}
|
||||
|
||||
|
||||
// Load per-scraper settings
|
||||
const scraperSettings = await this.getScraperSettings(scraper.id);
|
||||
|
||||
|
||||
// Build single-flight key
|
||||
const flightKey = `${scraper.id}|${type}|${tmdbId}|${season ?? ''}|${episode ?? ''}`;
|
||||
|
||||
|
|
@ -980,60 +936,23 @@ class LocalScraperService {
|
|||
}
|
||||
|
||||
const results = await promise;
|
||||
|
||||
|
||||
// Convert results to Nuvio Stream format
|
||||
const streams = this.convertToStreams(results, scraper);
|
||||
|
||||
// Cache the successful result (hybrid: both local and global)
|
||||
await hybridCacheService.cacheScraperResult(
|
||||
type,
|
||||
tmdbId,
|
||||
scraper.id,
|
||||
scraper.name,
|
||||
streams,
|
||||
null,
|
||||
season,
|
||||
episode
|
||||
);
|
||||
|
||||
|
||||
if (callback) {
|
||||
callback(streams, scraper.id, scraper.name, null);
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[LocalScraperService] Scraper', scraper.name, 'failed:', error);
|
||||
|
||||
// Cache the failed result (hybrid: both local and global)
|
||||
await hybridCacheService.cacheScraperResult(
|
||||
type,
|
||||
tmdbId,
|
||||
scraper.id,
|
||||
scraper.name,
|
||||
null,
|
||||
error as Error,
|
||||
season,
|
||||
episode
|
||||
);
|
||||
|
||||
|
||||
if (callback) {
|
||||
callback(null, scraper.id, scraper.name, error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute individual scraper (legacy method - kept for compatibility)
|
||||
private async executeScraper(
|
||||
scraper: ScraperInfo,
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
season?: number,
|
||||
episode?: number,
|
||||
callback?: ScraperCallback,
|
||||
requestId?: string
|
||||
): Promise<void> {
|
||||
// Delegate to the caching version
|
||||
return this.executeScraperWithCaching(scraper, type, tmdbId, season, episode, callback, requestId);
|
||||
}
|
||||
|
||||
// Execute scraper code in sandboxed environment
|
||||
private async executeSandboxed(code: string, params: any): Promise<LocalScraperResult[]> {
|
||||
|
|
@ -1161,7 +1080,7 @@ class LocalScraperService {
|
|||
...options.headers
|
||||
},
|
||||
data: options.body,
|
||||
timeout: 60000,
|
||||
timeout: 120000, // Increased to 2 minutes for complex scrapers
|
||||
validateStatus: () => true // Don't throw on HTTP error status codes
|
||||
};
|
||||
|
||||
|
|
@ -1201,7 +1120,7 @@ class LocalScraperService {
|
|||
},
|
||||
// Add axios for HTTP requests
|
||||
axios: axios.create({
|
||||
timeout: 30000,
|
||||
timeout: 120000, // Increased to 2 minutes for complex scrapers
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
|
|
@ -1217,27 +1136,29 @@ class LocalScraperService {
|
|||
SCRAPER_ID: params?.scraperId
|
||||
};
|
||||
|
||||
// Execute the scraper code without timeout
|
||||
// Execute the scraper code with 1 minute timeout
|
||||
const SCRAPER_EXECUTION_TIMEOUT_MS = 60000; // 1 minute
|
||||
|
||||
const executionPromise = new Promise<LocalScraperResult[]>((resolve, reject) => {
|
||||
try {
|
||||
// Create function from code
|
||||
const func = new Function('sandbox', 'params', 'PRIMARY_KEY', 'TMDB_API_KEY', `
|
||||
const { console, setTimeout, clearTimeout, Promise, JSON, Date, Math, parseInt, parseFloat, encodeURIComponent, decodeURIComponent, require, axios, fetch, module, exports, global, URL_VALIDATION_ENABLED, SCRAPER_SETTINGS, SCRAPER_ID } = sandbox;
|
||||
|
||||
|
||||
// Inject MovieBox constants into global scope
|
||||
global.PRIMARY_KEY = PRIMARY_KEY;
|
||||
global.TMDB_API_KEY = TMDB_API_KEY;
|
||||
window.PRIMARY_KEY = PRIMARY_KEY;
|
||||
window.TMDB_API_KEY = TMDB_API_KEY;
|
||||
|
||||
|
||||
// Expose per-scraper context to plugin globals
|
||||
global.SCRAPER_SETTINGS = SCRAPER_SETTINGS;
|
||||
global.SCRAPER_ID = SCRAPER_ID;
|
||||
window.SCRAPER_SETTINGS = SCRAPER_SETTINGS;
|
||||
window.SCRAPER_ID = SCRAPER_ID;
|
||||
|
||||
|
||||
${code}
|
||||
|
||||
|
||||
// Call the main function (assuming it's exported)
|
||||
if (typeof getStreams === 'function') {
|
||||
return getStreams(params.tmdbId, params.mediaType, params.season, params.episode);
|
||||
|
|
@ -1249,9 +1170,9 @@ class LocalScraperService {
|
|||
throw new Error('No getStreams function found in scraper');
|
||||
}
|
||||
`);
|
||||
|
||||
|
||||
const result = func(sandbox, params, MOVIEBOX_PRIMARY_KEY, MOVIEBOX_TMDB_API_KEY);
|
||||
|
||||
|
||||
// Handle both sync and async results
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(resolve).catch(reject);
|
||||
|
|
@ -1262,8 +1183,14 @@ class LocalScraperService {
|
|||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
return await executionPromise;
|
||||
|
||||
// Apply 1-minute timeout to prevent hanging scrapers
|
||||
return await Promise.race([
|
||||
executionPromise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Scraper execution timed out after ${SCRAPER_EXECUTION_TIMEOUT_MS}ms`)), SCRAPER_EXECUTION_TIMEOUT_MS)
|
||||
)
|
||||
]);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[LocalScraperService] Sandbox execution failed:', error);
|
||||
|
|
@ -1365,6 +1292,19 @@ class LocalScraperService {
|
|||
// Check if local scrapers are available
|
||||
async hasScrapers(): Promise<boolean> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
// Get user settings to check if local scrapers are enabled
|
||||
const userSettings = await this.getUserScraperSettings();
|
||||
if (!userSettings.enableLocalScrapers) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if there are any enabled scrapers based on user settings
|
||||
if (userSettings.enabledScrapers && userSettings.enabledScrapers.size > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: check if any scrapers are enabled in the internal state
|
||||
return Array.from(this.installedScrapers.values()).some(scraper => scraper.enabled);
|
||||
}
|
||||
|
||||
|
|
@ -1384,8 +1324,11 @@ class LocalScraperService {
|
|||
};
|
||||
}
|
||||
|
||||
// Get user settings from AsyncStorage
|
||||
const settingsData = await AsyncStorage.getItem('app_settings');
|
||||
// Get user settings from AsyncStorage (scoped with fallback)
|
||||
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
|
||||
const scopedSettingsJson = await AsyncStorage.getItem(`@user:${scope}:app_settings`);
|
||||
const legacySettingsJson = await AsyncStorage.getItem('app_settings');
|
||||
const settingsData = scopedSettingsJson || legacySettingsJson;
|
||||
const settings = settingsData ? JSON.parse(settingsData) : {};
|
||||
|
||||
// Get enabled scrapers based on current user settings
|
||||
|
|
@ -1408,32 +1351,6 @@ class LocalScraperService {
|
|||
}
|
||||
}
|
||||
|
||||
// Cache management methods (hybrid: local + global)
|
||||
async clearScraperCache(): Promise<void> {
|
||||
await hybridCacheService.clearAllCache();
|
||||
logger.log('[LocalScraperService] Cleared all scraper cache (local + global)');
|
||||
}
|
||||
|
||||
async invalidateScraperCache(scraperId: string): Promise<void> {
|
||||
await hybridCacheService.invalidateScraper(scraperId);
|
||||
logger.log('[LocalScraperService] Invalidated cache for scraper:', scraperId);
|
||||
}
|
||||
|
||||
async invalidateContentCache(type: string, tmdbId: string, season?: number, episode?: number): Promise<void> {
|
||||
await hybridCacheService.invalidateContent(type, tmdbId, season, episode);
|
||||
logger.log('[LocalScraperService] Invalidated cache for content:', `${type}:${tmdbId}`);
|
||||
}
|
||||
|
||||
async getCacheStats(): Promise<{
|
||||
local: {
|
||||
totalEntries: number;
|
||||
totalSize: number;
|
||||
oldestEntry: number | null;
|
||||
newestEntry: number | null;
|
||||
};
|
||||
}> {
|
||||
return await hybridCacheService.getCacheStats();
|
||||
}
|
||||
}
|
||||
|
||||
export const localScraperService = LocalScraperService.getInstance();
|
||||
|
|
|
|||
|
|
@ -1235,13 +1235,16 @@ class StremioService {
|
|||
// Execute local scrapers asynchronously with TMDB ID (when available)
|
||||
if (tmdbId) {
|
||||
localScraperService.getStreams(scraperType, tmdbId, season, episode, (streams, scraperId, scraperName, error) => {
|
||||
if (error) {
|
||||
if (callback) {
|
||||
// Always call callback to ensure UI updates, regardless of result
|
||||
if (callback) {
|
||||
if (error) {
|
||||
callback(null, scraperId, scraperName, error);
|
||||
}
|
||||
} else if (streams && streams.length > 0) {
|
||||
if (callback) {
|
||||
} else if (streams && streams.length > 0) {
|
||||
callback(streams, scraperId, scraperName, null);
|
||||
} else {
|
||||
// Handle case where scraper completed successfully but returned no streams
|
||||
// This ensures the scraper is removed from "fetching" state in UI
|
||||
callback([], scraperId, scraperName, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,10 +7,16 @@ export interface TrailerData {
|
|||
}
|
||||
|
||||
export class TrailerService {
|
||||
private static readonly XPRIME_URL = 'https://db.xprime.tv/trailers';
|
||||
private static readonly LOCAL_SERVER_URL = 'http://192.168.1.11:3001/trailer';
|
||||
private static readonly AUTO_SEARCH_URL = 'http://192.168.1.11:3001/search-trailer';
|
||||
private static readonly TIMEOUT = 10000; // 10 seconds
|
||||
// Environment-configurable values (Expo public env)
|
||||
private static readonly ENV_LOCAL_BASE = process.env.EXPO_PUBLIC_TRAILER_LOCAL_BASE || 'http://46.62.173.157:3001';
|
||||
private static readonly ENV_LOCAL_TRAILER_PATH = process.env.EXPO_PUBLIC_TRAILER_LOCAL_TRAILER_PATH || '/trailer';
|
||||
private static readonly ENV_LOCAL_SEARCH_PATH = process.env.EXPO_PUBLIC_TRAILER_LOCAL_SEARCH_PATH || '/search-trailer';
|
||||
private static readonly ENV_XPRIME_URL = process.env.EXPO_PUBLIC_XPRIME_URL || 'https://db.xprime.tv/trailers';
|
||||
|
||||
private static readonly XPRIME_URL = TrailerService.ENV_XPRIME_URL;
|
||||
private static readonly LOCAL_SERVER_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_TRAILER_PATH}`;
|
||||
private static readonly AUTO_SEARCH_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_SEARCH_PATH}`;
|
||||
private static readonly TIMEOUT = 20000; // 20 seconds
|
||||
private static readonly USE_LOCAL_SERVER = true; // Toggle between local and XPrime
|
||||
|
||||
/**
|
||||
|
|
@ -22,10 +28,12 @@ export class TrailerService {
|
|||
* @returns Promise<string | null> - The trailer URL or null if not found
|
||||
*/
|
||||
static async getTrailerUrl(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise<string | null> {
|
||||
logger.info('TrailerService', `getTrailerUrl requested: title="${title}", year=${year}, tmdbId=${tmdbId || 'n/a'}, type=${type || 'n/a'}, useLocal=${this.USE_LOCAL_SERVER}`);
|
||||
if (this.USE_LOCAL_SERVER) {
|
||||
// Try local server first, fallback to XPrime if it fails
|
||||
const localResult = await this.getTrailerFromLocalServer(title, year, tmdbId, type);
|
||||
if (localResult) {
|
||||
logger.info('TrailerService', 'Returning trailer URL from local server');
|
||||
return localResult;
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +54,7 @@ export class TrailerService {
|
|||
*/
|
||||
private static async getTrailerFromLocalServer(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise<string | null> {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
|
||||
|
||||
|
|
@ -65,6 +74,8 @@ export class TrailerService {
|
|||
}
|
||||
|
||||
const url = `${this.AUTO_SEARCH_URL}?${params.toString()}`;
|
||||
logger.info('TrailerService', `Local server request URL: ${url}`);
|
||||
logger.info('TrailerService', `Local server timeout set to ${this.TIMEOUT}ms`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
|
|
@ -77,25 +88,55 @@ export class TrailerService {
|
|||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const contentType = response.headers.get('content-type') || 'unknown';
|
||||
logger.info('TrailerService', `Local server response: status=${response.status} ok=${response.ok} content-type=${contentType} elapsedMs=${elapsed}`);
|
||||
|
||||
// Read body as text first so we can log it even on non-200s
|
||||
let rawText = '';
|
||||
try {
|
||||
rawText = await response.text();
|
||||
if (rawText) {
|
||||
const preview = rawText.length > 200 ? `${rawText.slice(0, 200)}...` : rawText;
|
||||
logger.info('TrailerService', `Local server body preview: ${preview}`);
|
||||
} else {
|
||||
logger.info('TrailerService', 'Local server body is empty');
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
||||
logger.warn('TrailerService', `Failed reading local server body text: ${msg}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('TrailerService', `Auto-search failed: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Attempt to parse JSON from the raw text
|
||||
let data: any = null;
|
||||
try {
|
||||
data = rawText ? JSON.parse(rawText) : null;
|
||||
const keys = typeof data === 'object' && data !== null ? Object.keys(data).join(',') : typeof data;
|
||||
logger.info('TrailerService', `Local server JSON parsed. Keys/Type: ${keys}`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
||||
logger.warn('TrailerService', `Failed to parse local server JSON: ${msg}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!data.url || !this.isValidTrailerUrl(data.url)) {
|
||||
logger.warn('TrailerService', `Invalid trailer URL from auto-search: ${data.url}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('TrailerService', `Successfully found trailer: ${data.url.substring(0, 50)}...`);
|
||||
logger.info('TrailerService', `Successfully found trailer: ${String(data.url).substring(0, 80)}...`);
|
||||
return data.url;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
logger.warn('TrailerService', 'Auto-search request timed out');
|
||||
logger.warn('TrailerService', `Auto-search request timed out after ${this.TIMEOUT}ms`);
|
||||
} else {
|
||||
logger.error('TrailerService', 'Error in auto-search:', error);
|
||||
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
logger.error('TrailerService', `Error in auto-search: ${msg}`);
|
||||
}
|
||||
return null; // Return null to trigger XPrime fallback
|
||||
}
|
||||
|
|
@ -115,6 +156,8 @@ export class TrailerService {
|
|||
const url = `${this.XPRIME_URL}?title=${encodeURIComponent(title)}&year=${year}`;
|
||||
|
||||
logger.info('TrailerService', `Fetching trailer from XPrime for: ${title} (${year})`);
|
||||
logger.info('TrailerService', `XPrime request URL: ${url}`);
|
||||
logger.info('TrailerService', `XPrime timeout set to ${this.TIMEOUT}ms`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
|
|
@ -127,12 +170,14 @@ export class TrailerService {
|
|||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
logger.info('TrailerService', `XPrime response: status=${response.status} ok=${response.ok}`);
|
||||
if (!response.ok) {
|
||||
logger.warn('TrailerService', `XPrime failed: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const trailerUrl = await response.text();
|
||||
logger.info('TrailerService', `XPrime raw URL length: ${trailerUrl ? trailerUrl.length : 0}`);
|
||||
|
||||
if (!trailerUrl || !this.isValidTrailerUrl(trailerUrl.trim())) {
|
||||
logger.warn('TrailerService', `Invalid trailer URL from XPrime: ${trailerUrl}`);
|
||||
|
|
@ -145,9 +190,10 @@ export class TrailerService {
|
|||
return cleanUrl;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
logger.warn('TrailerService', 'XPrime request timed out');
|
||||
logger.warn('TrailerService', `XPrime request timed out after ${this.TIMEOUT}ms`);
|
||||
} else {
|
||||
logger.error('TrailerService', 'Error fetching from XPrime:', error);
|
||||
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
logger.error('TrailerService', `Error fetching from XPrime: ${msg}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -218,16 +264,21 @@ export class TrailerService {
|
|||
if (url.includes('M3U')) {
|
||||
// Try to get M3U without encryption first, then with encryption
|
||||
const baseUrl = url.split('?')[0];
|
||||
return `${baseUrl}?formats=M3U+none,M3U+appleHlsEncryption`;
|
||||
const best = `${baseUrl}?formats=M3U+none,M3U+appleHlsEncryption`;
|
||||
logger.info('TrailerService', `Optimized format URL from M3U: ${best.substring(0, 80)}...`);
|
||||
return best;
|
||||
}
|
||||
// Fallback to MP4 if available
|
||||
if (url.includes('MPEG4')) {
|
||||
const baseUrl = url.split('?')[0];
|
||||
return `${baseUrl}?formats=MPEG4`;
|
||||
const best = `${baseUrl}?formats=MPEG4`;
|
||||
logger.info('TrailerService', `Optimized format URL from MPEG4: ${best.substring(0, 80)}...`);
|
||||
return best;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the original URL if no format optimization is needed
|
||||
logger.info('TrailerService', 'No format optimization applied');
|
||||
return url;
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +289,9 @@ export class TrailerService {
|
|||
* @returns Promise<boolean> - True if trailer is available
|
||||
*/
|
||||
static async isTrailerAvailable(title: string, year: number): Promise<boolean> {
|
||||
logger.info('TrailerService', `Checking trailer availability for: ${title} (${year})`);
|
||||
const trailerUrl = await this.getTrailerUrl(title, year);
|
||||
logger.info('TrailerService', `Trailer availability for ${title} (${year}): ${trailerUrl ? 'available' : 'not available'}`);
|
||||
return trailerUrl !== null;
|
||||
}
|
||||
|
||||
|
|
@ -249,9 +302,11 @@ export class TrailerService {
|
|||
* @returns Promise<TrailerData | null> - Trailer data or null if not found
|
||||
*/
|
||||
static async getTrailerData(title: string, year: number): Promise<TrailerData | null> {
|
||||
logger.info('TrailerService', `getTrailerData for: ${title} (${year})`);
|
||||
const url = await this.getTrailerUrl(title, year);
|
||||
|
||||
if (!url) {
|
||||
logger.info('TrailerService', 'No trailer URL found for getTrailerData');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -262,6 +317,65 @@ export class TrailerService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches trailer directly from a known YouTube URL
|
||||
* @param youtubeUrl - The YouTube URL to process
|
||||
* @param title - Optional title for logging/caching
|
||||
* @param year - Optional year for logging/caching
|
||||
* @returns Promise<string | null> - The direct streaming URL or null if failed
|
||||
*/
|
||||
static async getTrailerFromYouTubeUrl(youtubeUrl: string, title?: string, year?: string): Promise<string | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('youtube_url', youtubeUrl);
|
||||
if (title) params.append('title', title);
|
||||
if (year) params.append('year', year.toString());
|
||||
|
||||
const url = `${this.ENV_LOCAL_BASE}${this.ENV_LOCAL_TRAILER_PATH}?${params.toString()}`;
|
||||
logger.info('TrailerService', `Fetching trailer directly from YouTube URL: ${youtubeUrl}`);
|
||||
logger.info('TrailerService', `Direct trailer request URL: ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Nuvio/1.0',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
logger.info('TrailerService', `Direct trailer response: status=${response.status} ok=${response.ok}`);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('TrailerService', `Direct trailer failed: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.url || !this.isValidTrailerUrl(data.url)) {
|
||||
logger.warn('TrailerService', `Invalid trailer URL from direct fetch: ${data.url}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('TrailerService', `Successfully got direct trailer: ${String(data.url).substring(0, 80)}...`);
|
||||
return data.url;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
logger.warn('TrailerService', `Direct trailer request timed out after ${this.TIMEOUT}ms`);
|
||||
} else {
|
||||
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
logger.error('TrailerService', `Error in direct trailer fetch: ${msg}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch between local server and XPrime API
|
||||
* @param useLocal - true for local server, false for XPrime
|
||||
|
|
@ -292,6 +406,7 @@ export class TrailerService {
|
|||
localServer: { status: 'online' | 'offline'; responseTime?: number };
|
||||
xprimeServer: { status: 'online' | 'offline'; responseTime?: number };
|
||||
}> {
|
||||
logger.info('TrailerService', 'Testing servers (local and XPrime)');
|
||||
const results: {
|
||||
localServer: { status: 'online' | 'offline'; responseTime?: number };
|
||||
xprimeServer: { status: 'online' | 'offline'; responseTime?: number };
|
||||
|
|
@ -312,9 +427,11 @@ export class TrailerService {
|
|||
status: 'online',
|
||||
responseTime: Date.now() - startTime
|
||||
};
|
||||
logger.info('TrailerService', `Local server online. Response time: ${results.localServer.responseTime}ms`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('TrailerService', 'Local server test failed:', error);
|
||||
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
logger.warn('TrailerService', `Local server test failed: ${msg}`);
|
||||
}
|
||||
|
||||
// Test XPrime server
|
||||
|
|
@ -329,11 +446,14 @@ export class TrailerService {
|
|||
status: 'online',
|
||||
responseTime: Date.now() - startTime
|
||||
};
|
||||
logger.info('TrailerService', `XPrime server online. Response time: ${results.xprimeServer.responseTime}ms`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('TrailerService', 'XPrime server test failed:', error);
|
||||
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
logger.warn('TrailerService', `XPrime server test failed: ${msg}`);
|
||||
}
|
||||
|
||||
logger.info('TrailerService', `Server test results -> local: ${results.localServer.status}, xprime: ${results.xprimeServer.status}`);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { TMDBEpisode } from '../services/tmdbService';
|
||||
import { StreamingContent } from '../services/catalogService';
|
||||
|
||||
// Re-export StreamingContent for convenience
|
||||
export { StreamingContent };
|
||||
|
||||
// Types for route params
|
||||
export type RouteParams = {
|
||||
id: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue