mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
improved vlc behaviour
This commit is contained in:
parent
b1e9f9b3f8
commit
e2719c373d
10 changed files with 306 additions and 208 deletions
0
patches/react-native-video+6.12.0.patch
Normal file
0
patches/react-native-video+6.12.0.patch
Normal file
|
|
@ -29,8 +29,7 @@ export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, Metada
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
|
||||||
// Animation values - removed fadeAnim since parent handles transitions
|
// Animation values - shimmer removed
|
||||||
const shimmerAnim = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
// Scene transition animation values (matching tab navigator)
|
// Scene transition animation values (matching tab navigator)
|
||||||
const sceneOpacity = useRef(new Animated.Value(0)).current;
|
const sceneOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
@ -95,28 +94,14 @@ export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, Metada
|
||||||
|
|
||||||
sceneAnimation.start();
|
sceneAnimation.start();
|
||||||
|
|
||||||
// Shimmer effect for skeleton elements
|
// Shimmer effect removed
|
||||||
const shimmerAnimation = Animated.loop(
|
|
||||||
Animated.timing(shimmerAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 2500,
|
|
||||||
easing: Easing.inOut(Easing.ease),
|
|
||||||
useNativeDriver: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
shimmerAnimation.start();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
sceneAnimation.stop();
|
sceneAnimation.stop();
|
||||||
shimmerAnimation.stop();
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const shimmerTranslateX = shimmerAnim.interpolate({
|
// Shimmer translate removed
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [-width, width],
|
|
||||||
});
|
|
||||||
|
|
||||||
const SkeletonElement = ({
|
const SkeletonElement = ({
|
||||||
width: elementWidth,
|
width: elementWidth,
|
||||||
|
|
@ -143,23 +128,7 @@ export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, Metada
|
||||||
style
|
style
|
||||||
]}>
|
]}>
|
||||||
{/* Pulsating overlay removed */}
|
{/* Pulsating overlay removed */}
|
||||||
<Animated.View style={[
|
{/* Shimmer overlay removed */}
|
||||||
StyleSheet.absoluteFill,
|
|
||||||
{
|
|
||||||
transform: [{ translateX: shimmerTranslateX }],
|
|
||||||
}
|
|
||||||
]}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={[
|
|
||||||
'transparent',
|
|
||||||
currentTheme.colors.white + '20',
|
|
||||||
'transparent'
|
|
||||||
]}
|
|
||||||
style={StyleSheet.absoluteFill}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -627,10 +627,9 @@ const WatchProgressDisplay = memo(({
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!progressData) return null;
|
// Determine visibility; if not visible, don't render to avoid fixed blank space
|
||||||
|
const isVisible = !!progressData && !(isTrailerPlaying && !trailerMuted && trailerReady);
|
||||||
// Hide watch progress when trailer is playing AND unmuted AND trailer is ready
|
if (!isVisible) return null;
|
||||||
if (isTrailerPlaying && !trailerMuted && trailerReady) return null;
|
|
||||||
|
|
||||||
const isCompleted = progressData.isWatched || progressData.progressPercent >= 85;
|
const isCompleted = progressData.isWatched || progressData.progressPercent >= 85;
|
||||||
|
|
||||||
|
|
@ -816,7 +815,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
const trailerVideoRef = useRef<any>(null);
|
const trailerVideoRef = useRef<any>(null);
|
||||||
const imageOpacity = useSharedValue(1);
|
const imageOpacity = useSharedValue(1);
|
||||||
const imageLoadOpacity = useSharedValue(0);
|
const imageLoadOpacity = useSharedValue(0);
|
||||||
const shimmerOpacity = useSharedValue(0.3);
|
// Shimmer overlay removed
|
||||||
const trailerOpacity = useSharedValue(0);
|
const trailerOpacity = useSharedValue(0);
|
||||||
const thumbnailOpacity = useSharedValue(1);
|
const thumbnailOpacity = useSharedValue(1);
|
||||||
// Scroll-based pause/resume control
|
// Scroll-based pause/resume control
|
||||||
|
|
@ -940,24 +939,56 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
}, [metadata?.logo]);
|
}, [metadata?.logo]);
|
||||||
|
|
||||||
// Stable logo state management - prevent flickering between logo and text
|
// Stable logo state management - prevent flickering between logo and text
|
||||||
const [stableLogoUri, setStableLogoUri] = useState<string | null>(null);
|
const [stableLogoUri, setStableLogoUri] = useState<string | null>(metadata?.logo || null);
|
||||||
const [logoHasLoadedSuccessfully, setLogoHasLoadedSuccessfully] = useState(false);
|
const [logoHasLoadedSuccessfully, setLogoHasLoadedSuccessfully] = useState(false);
|
||||||
|
// Smooth fade-in for logo when it finishes loading
|
||||||
|
const logoLoadOpacity = useSharedValue(0);
|
||||||
|
// Grace delay before showing text fallback to avoid flashing when logo arrives late
|
||||||
|
const [shouldShowTextFallback, setShouldShowTextFallback] = useState<boolean>(!metadata?.logo);
|
||||||
|
const logoWaitTimerRef = useRef<any>(null);
|
||||||
|
|
||||||
// Update stable logo URI when metadata logo changes
|
// Update stable logo URI when metadata logo changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Reset text fallback and timers on logo updates
|
||||||
|
if (logoWaitTimerRef.current) {
|
||||||
|
try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {}
|
||||||
|
logoWaitTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (metadata?.logo && metadata.logo !== stableLogoUri) {
|
if (metadata?.logo && metadata.logo !== stableLogoUri) {
|
||||||
setStableLogoUri(metadata.logo);
|
setStableLogoUri(metadata.logo);
|
||||||
setLogoHasLoadedSuccessfully(false); // Reset for new logo
|
setLogoHasLoadedSuccessfully(false); // Reset for new logo
|
||||||
|
logoLoadOpacity.value = 0; // reset fade for new logo
|
||||||
|
setShouldShowTextFallback(false);
|
||||||
} else if (!metadata?.logo && stableLogoUri) {
|
} else if (!metadata?.logo && stableLogoUri) {
|
||||||
// Clear logo if metadata no longer has one
|
// Clear logo if metadata no longer has one
|
||||||
setStableLogoUri(null);
|
setStableLogoUri(null);
|
||||||
setLogoHasLoadedSuccessfully(false);
|
setLogoHasLoadedSuccessfully(false);
|
||||||
|
// Start a short grace period before showing text fallback
|
||||||
|
setShouldShowTextFallback(false);
|
||||||
|
logoWaitTimerRef.current = setTimeout(() => {
|
||||||
|
setShouldShowTextFallback(true);
|
||||||
|
}, 600);
|
||||||
|
} else if (!metadata?.logo && !stableLogoUri) {
|
||||||
|
// No logo currently; wait briefly before showing text to avoid flash
|
||||||
|
setShouldShowTextFallback(false);
|
||||||
|
logoWaitTimerRef.current = setTimeout(() => {
|
||||||
|
setShouldShowTextFallback(true);
|
||||||
|
}, 600);
|
||||||
}
|
}
|
||||||
|
return () => {
|
||||||
|
if (logoWaitTimerRef.current) {
|
||||||
|
try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {}
|
||||||
|
logoWaitTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [metadata?.logo, stableLogoUri]);
|
}, [metadata?.logo, stableLogoUri]);
|
||||||
|
|
||||||
// Handle logo load success - once loaded successfully, keep it stable
|
// Handle logo load success - once loaded successfully, keep it stable
|
||||||
const handleLogoLoad = useCallback(() => {
|
const handleLogoLoad = useCallback(() => {
|
||||||
setLogoHasLoadedSuccessfully(true);
|
setLogoHasLoadedSuccessfully(true);
|
||||||
|
// Fade in smoothly once the image reports loaded
|
||||||
|
logoLoadOpacity.value = withTiming(1, { duration: 300 });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle logo load error - only set error if logo hasn't loaded successfully before
|
// Handle logo load error - only set error if logo hasn't loaded successfully before
|
||||||
|
|
@ -1053,22 +1084,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
};
|
};
|
||||||
}, [metadata?.name, metadata?.year, tmdbId, settings?.showTrailers, isFocused]);
|
}, [metadata?.name, metadata?.year, tmdbId, settings?.showTrailers, isFocused]);
|
||||||
|
|
||||||
// Optimized shimmer animation for loading state
|
// Shimmer animation removed
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldLoadSecondaryData) return;
|
|
||||||
|
|
||||||
if (!imageLoaded && imageSource) {
|
|
||||||
// Start shimmer animation
|
|
||||||
shimmerOpacity.value = withRepeat(
|
|
||||||
withTiming(0.8, { duration: 1200 }),
|
|
||||||
-1,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Stop shimmer when loaded
|
|
||||||
shimmerOpacity.value = withTiming(0.3, { duration: 300 });
|
|
||||||
}
|
|
||||||
}, [imageLoaded, imageSource, shouldLoadSecondaryData]);
|
|
||||||
|
|
||||||
// Optimized loading state reset when image source changes
|
// Optimized loading state reset when image source changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1128,6 +1144,11 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
};
|
};
|
||||||
}, [watchProgress]);
|
}, [watchProgress]);
|
||||||
|
|
||||||
|
// Logo fade style applies only to the image to avoid affecting layout
|
||||||
|
const logoFadeStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: logoLoadOpacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
const watchProgressAnimatedStyle = useAnimatedStyle(() => ({
|
const watchProgressAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
opacity: watchProgressOpacity.value,
|
opacity: watchProgressOpacity.value,
|
||||||
}), []);
|
}), []);
|
||||||
|
|
@ -1350,7 +1371,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
try {
|
try {
|
||||||
imageOpacity.value = 1;
|
imageOpacity.value = 1;
|
||||||
imageLoadOpacity.value = 0;
|
imageLoadOpacity.value = 0;
|
||||||
shimmerOpacity.value = 0.3;
|
// shimmer removed
|
||||||
trailerOpacity.value = 0;
|
trailerOpacity.value = 0;
|
||||||
thumbnailOpacity.value = 1;
|
thumbnailOpacity.value = 1;
|
||||||
actionButtonsOpacity.value = 1;
|
actionButtonsOpacity.value = 1;
|
||||||
|
|
@ -1368,7 +1389,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
|
|
||||||
interactionComplete.current = false;
|
interactionComplete.current = false;
|
||||||
};
|
};
|
||||||
}, [imageOpacity, imageLoadOpacity, shimmerOpacity, trailerOpacity, thumbnailOpacity, actionButtonsOpacity, titleCardTranslateY, genreOpacity, watchProgressOpacity, buttonsOpacity, buttonsTranslateY, logoOpacity, heroOpacity, heroHeight]);
|
}, [imageOpacity, imageLoadOpacity, trailerOpacity, thumbnailOpacity, actionButtonsOpacity, titleCardTranslateY, genreOpacity, watchProgressOpacity, buttonsOpacity, buttonsTranslateY, logoOpacity, heroOpacity, heroHeight]);
|
||||||
|
|
||||||
// Disabled performance monitoring to reduce CPU overhead in production
|
// Disabled performance monitoring to reduce CPU overhead in production
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
|
|
@ -1391,19 +1412,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
{/* Optimized Background */}
|
{/* Optimized Background */}
|
||||||
<View style={[styles.absoluteFill, { backgroundColor: themeColors.black }]} />
|
<View style={[styles.absoluteFill, { backgroundColor: themeColors.black }]} />
|
||||||
|
|
||||||
{/* Optimized shimmer loading effect */}
|
{/* Shimmer loading effect removed */}
|
||||||
{shouldLoadSecondaryData && (trailerLoading || ((imageSource && !imageLoaded) || loadingBanner)) && (
|
|
||||||
<Animated.View style={[styles.absoluteFill, {
|
|
||||||
opacity: shimmerOpacity,
|
|
||||||
}]}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#111', '#222', '#111']}
|
|
||||||
style={styles.absoluteFill}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Background thumbnail image - always rendered when available */}
|
{/* Background thumbnail image - always rendered when available */}
|
||||||
{shouldLoadSecondaryData && imageSource && !loadingBanner && (
|
{shouldLoadSecondaryData && imageSource && !loadingBanner && (
|
||||||
|
|
@ -1574,18 +1583,21 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
{/* Optimized Title/Logo - Show logo immediately when available */}
|
{/* Optimized Title/Logo - Show logo immediately when available */}
|
||||||
<Animated.View style={[styles.logoContainer, titleCardAnimatedStyle]}>
|
<Animated.View style={[styles.logoContainer, titleCardAnimatedStyle]}>
|
||||||
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
|
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
|
||||||
{stableLogoUri ? (
|
{metadata?.logo ? (
|
||||||
<Image
|
<Animated.Image
|
||||||
source={{ uri: stableLogoUri }}
|
source={{ uri: stableLogoUri || (metadata?.logo as string) }}
|
||||||
style={isTablet ? styles.tabletTitleLogo : styles.titleLogo}
|
style={[isTablet ? styles.tabletTitleLogo : styles.titleLogo, logoFadeStyle]}
|
||||||
resizeMode={'contain'}
|
resizeMode={'contain'}
|
||||||
onLoad={handleLogoLoad}
|
onLoad={handleLogoLoad}
|
||||||
onError={handleLogoError}
|
onError={handleLogoError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : shouldShowTextFallback ? (
|
||||||
<Text style={[isTablet ? styles.tabletHeroTitle : styles.heroTitle, { color: themeColors.highEmphasis }]}>
|
<Text style={[isTablet ? styles.tabletHeroTitle : styles.heroTitle, { color: themeColors.highEmphasis }]}>
|
||||||
{metadata.name}
|
{metadata.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : (
|
||||||
|
// Reserve space to prevent layout jump while waiting briefly for logo
|
||||||
|
<View style={isTablet ? styles.tabletTitleLogo : styles.titleLogo} />
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
@ -1602,7 +1614,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
trailerReady={trailerReady}
|
trailerReady={trailerReady}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Optimized genre display with lazy loading */}
|
{/* Optimized genre display with lazy loading; no fixed blank space */}
|
||||||
{shouldLoadSecondaryData && genreElements && (
|
{shouldLoadSecondaryData && genreElements && (
|
||||||
<Animated.View style={[isTablet ? styles.tabletGenreContainer : styles.genreContainer, genreAnimatedStyle]}>
|
<Animated.View style={[isTablet ? styles.tabletGenreContainer : styles.genreContainer, genreAnimatedStyle]}>
|
||||||
{genreElements}
|
{genreElements}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ const getVideoResizeMode = (resizeMode: ResizeModeType) => {
|
||||||
switch (resizeMode) {
|
switch (resizeMode) {
|
||||||
case 'contain': return 'contain';
|
case 'contain': return 'contain';
|
||||||
case 'cover': return 'cover';
|
case 'cover': return 'cover';
|
||||||
case 'stretch': return 'stretch';
|
|
||||||
case 'none': return 'contain';
|
case 'none': return 'contain';
|
||||||
default: return 'contain';
|
default: return 'contain';
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +86,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
}, [route.params]);
|
}, [route.params]);
|
||||||
// TEMP: force React Native Video for testing (disable VLC)
|
// TEMP: force React Native Video for testing (disable VLC)
|
||||||
const TEMP_FORCE_RNV = false;
|
const TEMP_FORCE_RNV = false;
|
||||||
const TEMP_FORCE_VLC = false;
|
const TEMP_FORCE_VLC = true;
|
||||||
const useVLC = Platform.OS === 'android' && !TEMP_FORCE_RNV && (TEMP_FORCE_VLC || forceVlc);
|
const useVLC = Platform.OS === 'android' && !TEMP_FORCE_RNV && (TEMP_FORCE_VLC || forceVlc);
|
||||||
|
|
||||||
// Log player selection
|
// Log player selection
|
||||||
|
|
@ -381,13 +380,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
|
|
||||||
// Memoize zoom factor calculations to prevent expensive recalculations
|
// Memoize zoom factor calculations to prevent expensive recalculations
|
||||||
const zoomFactor = useMemo(() => {
|
const zoomFactor = useMemo(() => {
|
||||||
if (resizeMode === 'cover' && videoAspectRatio && screenDimensions.width > 0 && screenDimensions.height > 0) {
|
// Zoom disabled
|
||||||
const screenAspect = screenDimensions.width / screenDimensions.height;
|
|
||||||
return Math.max(screenAspect / videoAspectRatio, videoAspectRatio / screenAspect);
|
|
||||||
} else if (resizeMode === 'none') {
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
|
||||||
return 1; // Default for other modes
|
|
||||||
}, [resizeMode, videoAspectRatio, screenDimensions.width, screenDimensions.height]);
|
}, [resizeMode, videoAspectRatio, screenDimensions.width, screenDimensions.height]);
|
||||||
const [customVideoStyles, setCustomVideoStyles] = useState<any>({});
|
const [customVideoStyles, setCustomVideoStyles] = useState<any>({});
|
||||||
const [zoomScale, setZoomScale] = useState(1);
|
const [zoomScale, setZoomScale] = useState(1);
|
||||||
|
|
@ -683,21 +677,13 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
|
|
||||||
|
|
||||||
const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => {
|
const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => {
|
||||||
const { scale } = event.nativeEvent;
|
// Zoom disabled
|
||||||
const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1));
|
return;
|
||||||
setZoomScale(newScale);
|
|
||||||
if (DEBUG_MODE) {
|
|
||||||
if (__DEV__) logger.log(`[AndroidVideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => {
|
const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => {
|
||||||
if (event.nativeEvent.state === State.END) {
|
// Zoom disabled
|
||||||
setLastZoomScale(zoomScale);
|
return;
|
||||||
if (DEBUG_MODE) {
|
|
||||||
if (__DEV__) logger.log(`[AndroidVideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Volume gesture handler (right side of screen) - optimized with debouncing
|
// Volume gesture handler (right side of screen) - optimized with debouncing
|
||||||
|
|
@ -1625,10 +1611,10 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
// Android: exclude 'contain' for both VLC and RN Video (not well supported)
|
// Android: exclude 'contain' for both VLC and RN Video (not well supported)
|
||||||
let resizeModes: ResizeModeType[];
|
let resizeModes: ResizeModeType[];
|
||||||
if (Platform.OS === 'ios') {
|
if (Platform.OS === 'ios') {
|
||||||
resizeModes = ['cover', 'fill'];
|
resizeModes = ['contain', 'cover'];
|
||||||
} else {
|
} else {
|
||||||
// Android: both VLC and RN Video skip 'contain' mode
|
// On Android with VLC backend, only 'none' (original) and 'cover' (client-side crop)
|
||||||
resizeModes = ['cover', 'fill', 'none'];
|
resizeModes = useVLC ? ['none', 'cover'] : ['contain', 'cover', 'none'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentIndex = resizeModes.indexOf(resizeMode);
|
const currentIndex = resizeModes.indexOf(resizeMode);
|
||||||
|
|
@ -3320,8 +3306,6 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
activeOpacity={1}
|
activeOpacity={1}
|
||||||
onPress={toggleControls}
|
onPress={toggleControls}
|
||||||
onLongPress={resetZoom}
|
|
||||||
delayLongPress={300}
|
|
||||||
>
|
>
|
||||||
{useVLC && !forceVlcRemount ? (
|
{useVLC && !forceVlcRemount ? (
|
||||||
<VlcVideoPlayer
|
<VlcVideoPlayer
|
||||||
|
|
@ -3362,7 +3346,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<Video
|
<Video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
style={[styles.video, customVideoStyles, { transform: [{ scale: zoomScale }] }]}
|
style={[styles.video, customVideoStyles]}
|
||||||
source={{
|
source={{
|
||||||
uri: currentStreamUrl,
|
uri: currentStreamUrl,
|
||||||
headers: headers || getStreamHeaders(),
|
headers: headers || getStreamHeaders(),
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ interface VlcVideoPlayerProps {
|
||||||
source: string;
|
source: string;
|
||||||
volume: number;
|
volume: number;
|
||||||
zoomScale: number;
|
zoomScale: number;
|
||||||
resizeMode: 'contain' | 'cover' | 'fill' | 'stretch' | 'none';
|
resizeMode: 'contain' | 'cover' | 'none';
|
||||||
onLoad: (data: any) => void;
|
onLoad: (data: any) => void;
|
||||||
onProgress: (data: any) => void;
|
onProgress: (data: any) => void;
|
||||||
onSeek: (data: any) => void;
|
onSeek: (data: any) => void;
|
||||||
|
|
@ -63,6 +63,7 @@ const VlcVideoPlayer = forwardRef<VlcPlayerRef, VlcVideoPlayerProps>(({
|
||||||
const vlcRef = useRef<any>(null);
|
const vlcRef = useRef<any>(null);
|
||||||
const [vlcActive, setVlcActive] = useState(true);
|
const [vlcActive, setVlcActive] = useState(true);
|
||||||
const [duration, setDuration] = useState<number>(0);
|
const [duration, setDuration] = useState<number>(0);
|
||||||
|
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
|
||||||
|
|
||||||
// Expose imperative methods to parent component
|
// Expose imperative methods to parent component
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
|
|
@ -99,19 +100,21 @@ const VlcVideoPlayer = forwardRef<VlcPlayerRef, VlcVideoPlayerProps>(({
|
||||||
const screenDimensions = Dimensions.get('screen');
|
const screenDimensions = Dimensions.get('screen');
|
||||||
|
|
||||||
const vlcAspectRatio = useMemo(() => {
|
const vlcAspectRatio = useMemo(() => {
|
||||||
// For VLC, we handle aspect ratio through custom zoom for cover mode
|
// For VLC, no forced aspect ratio - let it preserve natural aspect
|
||||||
// Only force aspect for fill mode (stretch to fit)
|
|
||||||
if (resizeMode === 'fill') {
|
|
||||||
const sw = screenDimensions.width || 0;
|
|
||||||
const sh = screenDimensions.height || 0;
|
|
||||||
if (sw > 0 && sh > 0) {
|
|
||||||
return toVlcRatio(sw, sh);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For cover/contain/none: let VLC preserve natural aspect, we handle zoom separately
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [resizeMode, screenDimensions.width, screenDimensions.height, toVlcRatio]);
|
}, [resizeMode, screenDimensions.width, screenDimensions.height, toVlcRatio]);
|
||||||
|
|
||||||
|
const clientScale = useMemo(() => {
|
||||||
|
if (!videoAspectRatio || screenDimensions.width <= 0 || screenDimensions.height <= 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (resizeMode === 'cover') {
|
||||||
|
const screenAR = screenDimensions.width / screenDimensions.height;
|
||||||
|
return Math.max(screenAR / videoAspectRatio, videoAspectRatio / screenAR);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}, [resizeMode, videoAspectRatio, screenDimensions.width, screenDimensions.height]);
|
||||||
|
|
||||||
// VLC options for better playback
|
// VLC options for better playback
|
||||||
const vlcOptions = useMemo(() => {
|
const vlcOptions = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
|
@ -145,6 +148,10 @@ const VlcVideoPlayer = forwardRef<VlcPlayerRef, VlcVideoPlayerProps>(({
|
||||||
setDuration(lenSec);
|
setDuration(lenSec);
|
||||||
onLoad({ duration: lenSec, naturalSize: width && height ? { width, height } : undefined });
|
onLoad({ duration: lenSec, naturalSize: width && height ? { width, height } : undefined });
|
||||||
|
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
setVideoAspectRatio(width / height);
|
||||||
|
}
|
||||||
|
|
||||||
// Restore playback position after remount (workaround for surface detach)
|
// Restore playback position after remount (workaround for surface detach)
|
||||||
if (restoreTime !== undefined && restoreTime !== null && restoreTime > 0) {
|
if (restoreTime !== undefined && restoreTime !== null && restoreTime > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -304,6 +311,16 @@ const VlcVideoPlayer = forwardRef<VlcPlayerRef, VlcVideoPlayerProps>(({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: screenDimensions.width,
|
||||||
|
height: screenDimensions.height,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<LibVlcPlayerViewComponent
|
<LibVlcPlayerViewComponent
|
||||||
ref={vlcRef}
|
ref={vlcRef}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -312,12 +329,14 @@ const VlcVideoPlayer = forwardRef<VlcPlayerRef, VlcVideoPlayerProps>(({
|
||||||
left: 0,
|
left: 0,
|
||||||
width: screenDimensions.width,
|
width: screenDimensions.width,
|
||||||
height: screenDimensions.height,
|
height: screenDimensions.height,
|
||||||
transform: [{ scale: zoomScale }]
|
transform: [{ scale: clientScale }]
|
||||||
}}
|
}}
|
||||||
// Force remount when surfaces are recreated
|
// Force remount when surfaces are recreated
|
||||||
key={key || 'vlc-default'}
|
key={key || 'vlc-default'}
|
||||||
source={processedSource}
|
source={processedSource}
|
||||||
aspectRatio={vlcAspectRatio}
|
aspectRatio={vlcAspectRatio}
|
||||||
|
// Let VLC auto-fit the video to the view to prevent flicker on mode changes
|
||||||
|
scale={0}
|
||||||
options={vlcOptions}
|
options={vlcOptions}
|
||||||
tracks={vlcTracks}
|
tracks={vlcTracks}
|
||||||
volume={Math.round(Math.max(0, Math.min(1, volume)) * 100)}
|
volume={Math.round(Math.max(0, Math.min(1, volume)) * 100)}
|
||||||
|
|
@ -334,6 +353,7 @@ const VlcVideoPlayer = forwardRef<VlcPlayerRef, VlcVideoPlayerProps>(({
|
||||||
onBackground={handleBackground}
|
onBackground={handleBackground}
|
||||||
onESAdded={handleESAdded}
|
onESAdded={handleESAdded}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,15 +169,15 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||||
<View style={styles.bottomControls}>
|
<View style={styles.bottomControls}>
|
||||||
{/* Bottom Buttons Row */}
|
{/* Bottom Buttons Row */}
|
||||||
<View style={styles.bottomButtons}>
|
<View style={styles.bottomButtons}>
|
||||||
{/* Fill/Cover Button - Updated to show fill/cover modes */}
|
{/* Aspect Ratio Button - uses official resize modes */}
|
||||||
<TouchableOpacity style={styles.bottomButton} onPress={cycleAspectRatio}>
|
<TouchableOpacity style={styles.bottomButton} onPress={cycleAspectRatio}>
|
||||||
<Ionicons name="resize" size={20} color="white" />
|
<Ionicons name="resize" size={20} color="white" />
|
||||||
<Text style={[styles.bottomButtonText, { fontSize: 14, textAlign: 'center' }]}>
|
<Text style={[styles.bottomButtonText, { fontSize: 14, textAlign: 'center' }]}>
|
||||||
{currentResizeMode ?
|
{currentResizeMode
|
||||||
(currentResizeMode === 'none' ? 'Original' :
|
? (currentResizeMode === 'none'
|
||||||
currentResizeMode.charAt(0).toUpperCase() + currentResizeMode.slice(1)) :
|
? 'Original'
|
||||||
(zoomScale === 1.1 ? 'Fill' : 'Cover')
|
: currentResizeMode.charAt(0).toUpperCase() + currentResizeMode.slice(1))
|
||||||
}
|
: 'Contain'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,8 @@ export interface TextTrack {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define the possible resize modes - force to stretch for absolute full screen
|
// Define the possible resize modes - force to stretch for absolute full screen
|
||||||
export type ResizeModeType = 'contain' | 'cover' | 'fill' | 'none' | 'stretch';
|
export type ResizeModeType = 'contain' | 'cover' | 'none';
|
||||||
export const resizeModes: ResizeModeType[] = ['stretch']; // Force stretch mode for absolute full screen
|
export const resizeModes: ResizeModeType[] = ['cover']; // Force cover mode for absolute full screen
|
||||||
|
|
||||||
// Add VLC specific interface for their event structure
|
// Add VLC specific interface for their event structure
|
||||||
export interface VlcMediaEvent {
|
export interface VlcMediaEvent {
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,8 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [isFetchingMore, setIsFetchingMore] = useState(false);
|
||||||
const [dataSource, setDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS);
|
const [dataSource, setDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS);
|
||||||
const [actualCatalogName, setActualCatalogName] = useState<string | null>(null);
|
const [actualCatalogName, setActualCatalogName] = useState<string | null>(null);
|
||||||
const [screenData, setScreenData] = useState(() => {
|
const [screenData, setScreenData] = useState(() => {
|
||||||
|
|
@ -365,10 +367,20 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
loadNowPlayingMovies();
|
loadNowPlayingMovies();
|
||||||
}, [type]);
|
}, [type]);
|
||||||
|
|
||||||
const loadItems = useCallback(async (shouldRefresh: boolean = false) => {
|
const loadItems = useCallback(async (shouldRefresh: boolean = false, pageParam: number = 1) => {
|
||||||
|
logger.log('[CatalogScreen] loadItems called', {
|
||||||
|
shouldRefresh,
|
||||||
|
pageParam,
|
||||||
|
addonId,
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
dataSource,
|
||||||
|
genreFilter
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
|
setPage(1);
|
||||||
} else {
|
} else {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
|
|
@ -425,6 +437,11 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
setHasMore(false); // TMDB already returns a full set
|
setHasMore(false); // TMDB already returns a full set
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
|
setIsFetchingMore(false);
|
||||||
|
logger.log('[CatalogScreen] TMDB set items', {
|
||||||
|
count: uniqueItems.length,
|
||||||
|
hasMore: false
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -433,6 +450,8 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
|
setIsFetchingMore(false);
|
||||||
|
logger.log('[CatalogScreen] TMDB returned no items');
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -443,6 +462,8 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
|
setIsFetchingMore(false);
|
||||||
|
logger.log('[CatalogScreen] TMDB error, cleared items');
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -467,12 +488,40 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
const filters = effectiveGenreFilter ? [{ title: 'genre', value: effectiveGenreFilter }] : [];
|
const filters = effectiveGenreFilter ? [{ title: 'genre', value: effectiveGenreFilter }] : [];
|
||||||
|
|
||||||
// Load items from the catalog
|
// Load items from the catalog
|
||||||
const catalogItems = await stremioService.getCatalog(addon, type, id, 1, filters);
|
const catalogItems = await stremioService.getCatalog(addon, type, id, pageParam, filters);
|
||||||
|
logger.log('[CatalogScreen] Fetched addon catalog page', {
|
||||||
|
addon: addon.id,
|
||||||
|
page: pageParam,
|
||||||
|
fetched: catalogItems.length
|
||||||
|
});
|
||||||
|
|
||||||
if (catalogItems.length > 0) {
|
if (catalogItems.length > 0) {
|
||||||
foundItems = true;
|
foundItems = true;
|
||||||
InteractionManager.runAfterInteractions(() => {
|
InteractionManager.runAfterInteractions(() => {
|
||||||
|
if (shouldRefresh || pageParam === 1) {
|
||||||
setItems(catalogItems);
|
setItems(catalogItems);
|
||||||
|
} else {
|
||||||
|
setItems(prev => {
|
||||||
|
const map = new Map<string, Meta>();
|
||||||
|
for (const it of prev) map.set(`${it.id}-${it.type}`, it);
|
||||||
|
for (const it of catalogItems) map.set(`${it.id}-${it.type}`, it);
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Prefer service-provided hasMore for addons that support it; fallback to page-size heuristic
|
||||||
|
let nextHasMore = false;
|
||||||
|
try {
|
||||||
|
const svcHasMore = addonId ? stremioService.getCatalogHasMore(addonId, type, id) : undefined;
|
||||||
|
nextHasMore = typeof svcHasMore === 'boolean' ? svcHasMore : (catalogItems.length >= 50);
|
||||||
|
} catch {
|
||||||
|
nextHasMore = catalogItems.length >= 50;
|
||||||
|
}
|
||||||
|
setHasMore(nextHasMore);
|
||||||
|
logger.log('[CatalogScreen] Updated items and hasMore', {
|
||||||
|
total: (shouldRefresh || pageParam === 1) ? catalogItems.length : undefined,
|
||||||
|
appended: !(shouldRefresh || pageParam === 1) ? catalogItems.length : undefined,
|
||||||
|
hasMore: nextHasMore
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (effectiveGenreFilter) {
|
} else if (effectiveGenreFilter) {
|
||||||
|
|
@ -554,6 +603,8 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
foundItems = true;
|
foundItems = true;
|
||||||
InteractionManager.runAfterInteractions(() => {
|
InteractionManager.runAfterInteractions(() => {
|
||||||
setItems(uniqueItems);
|
setItems(uniqueItems);
|
||||||
|
setHasMore(false);
|
||||||
|
logger.log('[CatalogScreen] Genre aggregated uniqueItems', { count: uniqueItems.length });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -561,6 +612,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
if (!foundItems) {
|
if (!foundItems) {
|
||||||
InteractionManager.runAfterInteractions(() => {
|
InteractionManager.runAfterInteractions(() => {
|
||||||
setError("No content found for the selected filters");
|
setError("No content found for the selected filters");
|
||||||
|
logger.log('[CatalogScreen] No items found after loading');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -572,12 +624,17 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
InteractionManager.runAfterInteractions(() => {
|
InteractionManager.runAfterInteractions(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
|
setIsFetchingMore(false);
|
||||||
|
logger.log('[CatalogScreen] loadItems finished', {
|
||||||
|
shouldRefresh,
|
||||||
|
pageParam
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [addonId, type, id, genreFilter, dataSource]);
|
}, [addonId, type, id, genreFilter, dataSource]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadItems(true);
|
loadItems(true, 1);
|
||||||
}, [loadItems]);
|
}, [loadItems]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
|
|
@ -796,6 +853,42 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
removeClippedSubviews={true}
|
removeClippedSubviews={true}
|
||||||
getItemType={() => 'item'}
|
getItemType={() => 'item'}
|
||||||
|
onEndReachedThreshold={0.6}
|
||||||
|
onEndReached={() => {
|
||||||
|
logger.log('[CatalogScreen] onEndReached fired', {
|
||||||
|
hasMore,
|
||||||
|
loading,
|
||||||
|
refreshing,
|
||||||
|
isFetchingMore,
|
||||||
|
page
|
||||||
|
});
|
||||||
|
if (!hasMore) {
|
||||||
|
logger.log('[CatalogScreen] onEndReached guard: hasMore is false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
logger.log('[CatalogScreen] onEndReached guard: initial loading is true');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (refreshing) {
|
||||||
|
logger.log('[CatalogScreen] onEndReached guard: refreshing is true');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isFetchingMore) {
|
||||||
|
logger.log('[CatalogScreen] onEndReached guard: already fetching more');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsFetchingMore(true);
|
||||||
|
const next = page + 1;
|
||||||
|
setPage(next);
|
||||||
|
logger.log('[CatalogScreen] onEndReached loading next page', { next });
|
||||||
|
loadItems(false, next);
|
||||||
|
}}
|
||||||
|
ListFooterComponent={isFetchingMore ? (
|
||||||
|
<View style={{ paddingVertical: 16 }}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
/>
|
/>
|
||||||
) : renderEmptyState()}
|
) : renderEmptyState()}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ const MetadataScreen: React.FC = () => {
|
||||||
const [revealedSpoilers, setRevealedSpoilers] = useState<Set<string>>(new Set());
|
const [revealedSpoilers, setRevealedSpoilers] = useState<Set<string>>(new Set());
|
||||||
const loadingScreenRef = useRef<MetadataLoadingScreenRef>(null);
|
const loadingScreenRef = useRef<MetadataLoadingScreenRef>(null);
|
||||||
const [loadingScreenExited, setLoadingScreenExited] = useState(false);
|
const [loadingScreenExited, setLoadingScreenExited] = useState(false);
|
||||||
|
// Delay flag to show sections 800ms after cast is rendered (if present)
|
||||||
|
const [postCastDelayDone, setPostCastDelayDone] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
// Debug state changes
|
// Debug state changes
|
||||||
|
|
@ -161,26 +163,48 @@ const MetadataScreen: React.FC = () => {
|
||||||
const hasNetworks = metadata?.networks && metadata.networks.length > 0;
|
const hasNetworks = metadata?.networks && metadata.networks.length > 0;
|
||||||
const hasDescription = !!metadata?.description;
|
const hasDescription = !!metadata?.description;
|
||||||
const isSeries = Object.keys(groupedEpisodes).length > 0;
|
const isSeries = Object.keys(groupedEpisodes).length > 0;
|
||||||
// Defer showing until cast (if any) has finished fetching to avoid layout jump
|
// Defer showing until cast (if any) has finished fetching and 800ms delay elapsed
|
||||||
const shouldShow = shouldLoadSecondaryData && !loadingCast && hasNetworks && hasDescription && isSeries;
|
const shouldShow = shouldLoadSecondaryData && postCastDelayDone && hasNetworks && hasDescription && isSeries;
|
||||||
|
|
||||||
if (shouldShow && networkSectionOpacity.value === 0) {
|
if (shouldShow && networkSectionOpacity.value === 0) {
|
||||||
networkSectionOpacity.value = withTiming(1, { duration: 400 });
|
networkSectionOpacity.value = withTiming(1, { duration: 400 });
|
||||||
}
|
}
|
||||||
}, [metadata?.networks, metadata?.description, Object.keys(groupedEpisodes).length, shouldLoadSecondaryData, loadingCast, networkSectionOpacity]);
|
}, [metadata?.networks, metadata?.description, Object.keys(groupedEpisodes).length, shouldLoadSecondaryData, postCastDelayDone, networkSectionOpacity]);
|
||||||
|
|
||||||
// Animate production section when data becomes available (for movies)
|
// Animate production section when data becomes available (for movies)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasNetworks = metadata?.networks && metadata.networks.length > 0;
|
const hasNetworks = metadata?.networks && metadata.networks.length > 0;
|
||||||
const hasDescription = !!metadata?.description;
|
const hasDescription = !!metadata?.description;
|
||||||
const isMovie = Object.keys(groupedEpisodes).length === 0;
|
const isMovie = Object.keys(groupedEpisodes).length === 0;
|
||||||
// Defer showing until cast (if any) has finished fetching to avoid layout jump
|
// Defer showing until cast (if any) has finished fetching and 800ms delay elapsed
|
||||||
const shouldShow = shouldLoadSecondaryData && !loadingCast && hasNetworks && hasDescription && isMovie;
|
const shouldShow = shouldLoadSecondaryData && postCastDelayDone && hasNetworks && hasDescription && isMovie;
|
||||||
|
|
||||||
if (shouldShow && productionSectionOpacity.value === 0) {
|
if (shouldShow && productionSectionOpacity.value === 0) {
|
||||||
productionSectionOpacity.value = withTiming(1, { duration: 400 });
|
productionSectionOpacity.value = withTiming(1, { duration: 400 });
|
||||||
}
|
}
|
||||||
}, [metadata?.networks, metadata?.description, Object.keys(groupedEpisodes).length, shouldLoadSecondaryData, loadingCast, productionSectionOpacity]);
|
}, [metadata?.networks, metadata?.description, Object.keys(groupedEpisodes).length, shouldLoadSecondaryData, postCastDelayDone, productionSectionOpacity]);
|
||||||
|
|
||||||
|
// Manage 800ms delay after cast finishes loading (only if cast is present)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldLoadSecondaryData) {
|
||||||
|
setPostCastDelayDone(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loadingCast) {
|
||||||
|
if (cast && cast.length > 0) {
|
||||||
|
setPostCastDelayDone(false);
|
||||||
|
const t = setTimeout(() => setPostCastDelayDone(true), 800);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
} else {
|
||||||
|
// If no cast present, no need to delay
|
||||||
|
setPostCastDelayDone(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset while cast is loading
|
||||||
|
setPostCastDelayDone(false);
|
||||||
|
}
|
||||||
|
}, [loadingCast, cast.length, shouldLoadSecondaryData]);
|
||||||
|
|
||||||
// Optimized hooks with memoization and conditional loading
|
// Optimized hooks with memoization and conditional loading
|
||||||
const watchProgressData = useWatchProgress(id, Object.keys(groupedEpisodes).length > 0 ? 'series' : type as 'movie' | 'series', episodeId, episodes);
|
const watchProgressData = useWatchProgress(id, Object.keys(groupedEpisodes).length > 0 ? 'series' : type as 'movie' | 'series', episodeId, episodes);
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,7 @@ class StremioService {
|
||||||
private readonly DEFAULT_PAGE_SIZE = 50;
|
private readonly DEFAULT_PAGE_SIZE = 50;
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
private initializationPromise: Promise<void> | null = null;
|
private initializationPromise: Promise<void> | null = null;
|
||||||
|
private catalogHasMore: Map<string, boolean> = new Map();
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
// Start initialization but don't wait for it
|
// Start initialization but don't wait for it
|
||||||
|
|
@ -734,66 +735,56 @@ class StremioService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise<Meta[]> {
|
async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise<Meta[]> {
|
||||||
// Special handling for Cinemeta
|
// Build URLs (path-style skip and query-style skip) and try both for broad addon support
|
||||||
if (manifest.id === 'com.linvo.cinemeta') {
|
|
||||||
const baseUrl = 'https://v3-cinemeta.strem.io';
|
|
||||||
const encodedId = encodeURIComponent(id);
|
const encodedId = encodeURIComponent(id);
|
||||||
let url = `${baseUrl}/catalog/${type}/${encodedId}.json`;
|
const pageSkip = (page - 1) * this.DEFAULT_PAGE_SIZE;
|
||||||
|
const filterQuery = (filters || [])
|
||||||
|
.filter(f => f && f.value)
|
||||||
|
.map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
// Add paging
|
// For all addons
|
||||||
url += `?skip=${(page - 1) * this.DEFAULT_PAGE_SIZE}`;
|
|
||||||
|
|
||||||
// Add filters
|
|
||||||
if (filters.length > 0) {
|
|
||||||
filters.forEach(filter => {
|
|
||||||
if (filter.value) {
|
|
||||||
url += `&${encodeURIComponent(filter.title)}=${encodeURIComponent(filter.value)}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.retryRequest(async () => {
|
|
||||||
return await axios.get(url);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data && response.data.metas && Array.isArray(response.data.metas)) {
|
|
||||||
return response.data.metas;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other addons
|
|
||||||
if (!manifest.url) {
|
if (!manifest.url) {
|
||||||
throw new Error('Addon URL is missing');
|
throw new Error('Addon URL is missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url);
|
const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url);
|
||||||
|
// Candidate 1: Path-style skip URL: /catalog/{type}/{id}/skip={N}.json
|
||||||
|
const urlPathStyle = `${baseUrl}/catalog/${type}/${encodedId}/skip=${pageSkip}.json${queryParams ? `?${queryParams}` : ''}`;
|
||||||
|
// Add filters to path style (append with & or ? based on presence of queryParams)
|
||||||
|
const urlPathWithFilters = urlPathStyle + (urlPathStyle.includes('?') ? filterQuery : (filterQuery ? `?${filterQuery.slice(1)}` : ''));
|
||||||
|
try { logger.log('[StremioService] getCatalog URL (path-style)', { url: urlPathWithFilters, page, pageSize: this.DEFAULT_PAGE_SIZE, type, id, filters, manifest: manifest.id }); } catch {}
|
||||||
|
|
||||||
// Build the catalog URL
|
// Candidate 2: Query-style skip URL: /catalog/{type}/{id}.json?skip={N}&limit={PAGE_SIZE}
|
||||||
const encodedId = encodeURIComponent(id);
|
let urlQueryStyle = `${baseUrl}/catalog/${type}/${encodedId}.json?skip=${pageSkip}&limit=${this.DEFAULT_PAGE_SIZE}`;
|
||||||
let url = `${baseUrl}/catalog/${type}/${encodedId}.json`;
|
if (queryParams) urlQueryStyle += `&${queryParams}`;
|
||||||
|
urlQueryStyle += filterQuery;
|
||||||
|
try { logger.log('[StremioService] getCatalog URL (query-style)', { url: urlQueryStyle, page, pageSize: this.DEFAULT_PAGE_SIZE, type, id, filters, manifest: manifest.id }); } catch {}
|
||||||
|
|
||||||
// Add paging
|
// Try path-style first, then fallback to query-style
|
||||||
url += `?skip=${(page - 1) * this.DEFAULT_PAGE_SIZE}`;
|
let response;
|
||||||
|
try {
|
||||||
// Add filters
|
response = await this.retryRequest(async () => axios.get(urlPathWithFilters));
|
||||||
if (filters.length > 0) {
|
} catch (e) {
|
||||||
filters.forEach(filter => {
|
try {
|
||||||
if (filter.value) {
|
response = await this.retryRequest(async () => axios.get(urlQueryStyle));
|
||||||
url += `&${encodeURIComponent(filter.title)}=${encodeURIComponent(filter.value)}`;
|
} catch (e2) {
|
||||||
|
throw e2;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response && response.data) {
|
||||||
const response = await this.retryRequest(async () => {
|
const hasMore = typeof response.data.hasMore === 'boolean' ? response.data.hasMore : undefined;
|
||||||
return await axios.get(url);
|
try {
|
||||||
});
|
const key = `${manifest.id}|${type}|${id}`;
|
||||||
|
if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore);
|
||||||
if (response.data && response.data.metas && Array.isArray(response.data.metas)) {
|
logger.log('[StremioService] getCatalog response meta', { hasMore, count: Array.isArray(response.data.metas) ? response.data.metas.length : 0 });
|
||||||
|
} catch {}
|
||||||
|
if (response.data.metas && Array.isArray(response.data.metas)) {
|
||||||
return response.data.metas;
|
return response.data.metas;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to fetch catalog from ${manifest.name}:`, error);
|
logger.error(`Failed to fetch catalog from ${manifest.name}:`, error);
|
||||||
|
|
@ -801,6 +792,11 @@ class StremioService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getCatalogHasMore(manifestId: string, type: string, id: string): boolean | undefined {
|
||||||
|
const key = `${manifestId}|${type}|${id}`;
|
||||||
|
return this.catalogHasMore.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise<MetaDetails | null> {
|
async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise<MetaDetails | null> {
|
||||||
console.log(`🔍 [StremioService] getMetaDetails called:`, { type, id, preferredAddonId });
|
console.log(`🔍 [StremioService] getMetaDetails called:`, { type, id, preferredAddonId });
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue