This commit is contained in:
Qarqun 2025-10-19 01:21:48 +08:00
commit f895428e3d
31 changed files with 3695 additions and 1218 deletions

1
TrailerService Submodule

@ -0,0 +1 @@
Subproject commit 2cb2c6d1a3ca60416160bdb28be800fe249207fc

View file

@ -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

@ -0,0 +1 @@
Subproject commit 3a70b36f873307cd83fb3178bb891f73cf73aa87

View file

@ -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";

View file

@ -82,7 +82,7 @@
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -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>

View file

@ -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>

View file

@ -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
View file

@ -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": {

View file

@ -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",

File diff suppressed because it is too large Load diff

View file

@ -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>
);

View file

@ -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);

View file

@ -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);

View file

@ -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,

View 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;

View 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;

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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,
};
};

View file

@ -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}

View file

@ -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 */}

View file

@ -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

View file

@ -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 }]}

View file

@ -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;

View file

@ -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;

View file

@ -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();

View file

@ -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);
}
}
});

View file

@ -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;
}
}

View file

@ -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;