mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +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) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// Animation values - removed fadeAnim since parent handles transitions
|
||||
const shimmerAnim = useRef(new Animated.Value(0)).current;
|
||||
// Animation values - shimmer removed
|
||||
|
||||
// Scene transition animation values (matching tab navigator)
|
||||
const sceneOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
|
@ -95,28 +94,14 @@ export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, Metada
|
|||
|
||||
sceneAnimation.start();
|
||||
|
||||
// Shimmer effect for skeleton elements
|
||||
const shimmerAnimation = Animated.loop(
|
||||
Animated.timing(shimmerAnim, {
|
||||
toValue: 1,
|
||||
duration: 2500,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
|
||||
shimmerAnimation.start();
|
||||
// Shimmer effect removed
|
||||
|
||||
return () => {
|
||||
sceneAnimation.stop();
|
||||
shimmerAnimation.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shimmerTranslateX = shimmerAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [-width, width],
|
||||
});
|
||||
// Shimmer translate removed
|
||||
|
||||
const SkeletonElement = ({
|
||||
width: elementWidth,
|
||||
|
|
@ -143,23 +128,7 @@ export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, Metada
|
|||
style
|
||||
]}>
|
||||
{/* Pulsating overlay removed */}
|
||||
<Animated.View style={[
|
||||
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>
|
||||
{/* Shimmer overlay removed */}
|
||||
</View>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -627,10 +627,9 @@ const WatchProgressDisplay = memo(({
|
|||
],
|
||||
}));
|
||||
|
||||
if (!progressData) return null;
|
||||
|
||||
// Hide watch progress when trailer is playing AND unmuted AND trailer is ready
|
||||
if (isTrailerPlaying && !trailerMuted && trailerReady) return null;
|
||||
// Determine visibility; if not visible, don't render to avoid fixed blank space
|
||||
const isVisible = !!progressData && !(isTrailerPlaying && !trailerMuted && trailerReady);
|
||||
if (!isVisible) return null;
|
||||
|
||||
const isCompleted = progressData.isWatched || progressData.progressPercent >= 85;
|
||||
|
||||
|
|
@ -816,7 +815,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
const trailerVideoRef = useRef<any>(null);
|
||||
const imageOpacity = useSharedValue(1);
|
||||
const imageLoadOpacity = useSharedValue(0);
|
||||
const shimmerOpacity = useSharedValue(0.3);
|
||||
// Shimmer overlay removed
|
||||
const trailerOpacity = useSharedValue(0);
|
||||
const thumbnailOpacity = useSharedValue(1);
|
||||
// Scroll-based pause/resume control
|
||||
|
|
@ -940,24 +939,56 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
}, [metadata?.logo]);
|
||||
|
||||
// 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);
|
||||
// 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
|
||||
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) {
|
||||
setStableLogoUri(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);
|
||||
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]);
|
||||
|
||||
// Handle logo load success - once loaded successfully, keep it stable
|
||||
const handleLogoLoad = useCallback(() => {
|
||||
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
|
||||
|
|
@ -1053,22 +1084,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
};
|
||||
}, [metadata?.name, metadata?.year, tmdbId, settings?.showTrailers, isFocused]);
|
||||
|
||||
// Optimized shimmer animation for loading state
|
||||
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]);
|
||||
// Shimmer animation removed
|
||||
|
||||
// Optimized loading state reset when image source changes
|
||||
useEffect(() => {
|
||||
|
|
@ -1128,6 +1144,11 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
};
|
||||
}, [watchProgress]);
|
||||
|
||||
// Logo fade style applies only to the image to avoid affecting layout
|
||||
const logoFadeStyle = useAnimatedStyle(() => ({
|
||||
opacity: logoLoadOpacity.value,
|
||||
}));
|
||||
|
||||
const watchProgressAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: watchProgressOpacity.value,
|
||||
}), []);
|
||||
|
|
@ -1350,7 +1371,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
try {
|
||||
imageOpacity.value = 1;
|
||||
imageLoadOpacity.value = 0;
|
||||
shimmerOpacity.value = 0.3;
|
||||
// shimmer removed
|
||||
trailerOpacity.value = 0;
|
||||
thumbnailOpacity.value = 1;
|
||||
actionButtonsOpacity.value = 1;
|
||||
|
|
@ -1368,7 +1389,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
|
||||
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
|
||||
// useEffect(() => {
|
||||
|
|
@ -1391,19 +1412,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
{/* Optimized Background */}
|
||||
<View style={[styles.absoluteFill, { backgroundColor: themeColors.black }]} />
|
||||
|
||||
{/* Optimized shimmer loading effect */}
|
||||
{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>
|
||||
)}
|
||||
{/* Shimmer loading effect removed */}
|
||||
|
||||
{/* Background thumbnail image - always rendered when available */}
|
||||
{shouldLoadSecondaryData && imageSource && !loadingBanner && (
|
||||
|
|
@ -1574,18 +1583,21 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
{/* Optimized Title/Logo - Show logo immediately when available */}
|
||||
<Animated.View style={[styles.logoContainer, titleCardAnimatedStyle]}>
|
||||
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
|
||||
{stableLogoUri ? (
|
||||
<Image
|
||||
source={{ uri: stableLogoUri }}
|
||||
style={isTablet ? styles.tabletTitleLogo : styles.titleLogo}
|
||||
{metadata?.logo ? (
|
||||
<Animated.Image
|
||||
source={{ uri: stableLogoUri || (metadata?.logo as string) }}
|
||||
style={[isTablet ? styles.tabletTitleLogo : styles.titleLogo, logoFadeStyle]}
|
||||
resizeMode={'contain'}
|
||||
onLoad={handleLogoLoad}
|
||||
onError={handleLogoError}
|
||||
/>
|
||||
) : (
|
||||
<Text style={[isTablet ? styles.tabletHeroTitle : styles.heroTitle, { color: themeColors.highEmphasis }]}>
|
||||
) : shouldShowTextFallback ? (
|
||||
<Text style={[isTablet ? styles.tabletHeroTitle : styles.heroTitle, { color: themeColors.highEmphasis }]}>
|
||||
{metadata.name}
|
||||
</Text>
|
||||
) : (
|
||||
// Reserve space to prevent layout jump while waiting briefly for logo
|
||||
<View style={isTablet ? styles.tabletTitleLogo : styles.titleLogo} />
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
|
@ -1602,7 +1614,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
trailerReady={trailerReady}
|
||||
/>
|
||||
|
||||
{/* Optimized genre display with lazy loading */}
|
||||
{/* Optimized genre display with lazy loading; no fixed blank space */}
|
||||
{shouldLoadSecondaryData && genreElements && (
|
||||
<Animated.View style={[isTablet ? styles.tabletGenreContainer : styles.genreContainer, genreAnimatedStyle]}>
|
||||
{genreElements}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ const getVideoResizeMode = (resizeMode: ResizeModeType) => {
|
|||
switch (resizeMode) {
|
||||
case 'contain': return 'contain';
|
||||
case 'cover': return 'cover';
|
||||
case 'stretch': return 'stretch';
|
||||
case 'none': return 'contain';
|
||||
default: return 'contain';
|
||||
}
|
||||
|
|
@ -87,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 = false;
|
||||
const TEMP_FORCE_VLC = true;
|
||||
const useVLC = Platform.OS === 'android' && !TEMP_FORCE_RNV && (TEMP_FORCE_VLC || forceVlc);
|
||||
|
||||
// Log player selection
|
||||
|
|
@ -381,13 +380,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
// Memoize zoom factor calculations to prevent expensive recalculations
|
||||
const zoomFactor = useMemo(() => {
|
||||
if (resizeMode === 'cover' && videoAspectRatio && screenDimensions.width > 0 && screenDimensions.height > 0) {
|
||||
const screenAspect = screenDimensions.width / screenDimensions.height;
|
||||
return Math.max(screenAspect / videoAspectRatio, videoAspectRatio / screenAspect);
|
||||
} else if (resizeMode === 'none') {
|
||||
return 1;
|
||||
}
|
||||
return 1; // Default for other modes
|
||||
// Zoom disabled
|
||||
return 1;
|
||||
}, [resizeMode, videoAspectRatio, screenDimensions.width, screenDimensions.height]);
|
||||
const [customVideoStyles, setCustomVideoStyles] = useState<any>({});
|
||||
const [zoomScale, setZoomScale] = useState(1);
|
||||
|
|
@ -683,21 +677,13 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
|
||||
const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => {
|
||||
const { scale } = event.nativeEvent;
|
||||
const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1));
|
||||
setZoomScale(newScale);
|
||||
if (DEBUG_MODE) {
|
||||
if (__DEV__) logger.log(`[AndroidVideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`);
|
||||
}
|
||||
// Zoom disabled
|
||||
return;
|
||||
};
|
||||
|
||||
const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => {
|
||||
if (event.nativeEvent.state === State.END) {
|
||||
setLastZoomScale(zoomScale);
|
||||
if (DEBUG_MODE) {
|
||||
if (__DEV__) logger.log(`[AndroidVideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`);
|
||||
}
|
||||
}
|
||||
// Zoom disabled
|
||||
return;
|
||||
};
|
||||
|
||||
// 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)
|
||||
let resizeModes: ResizeModeType[];
|
||||
if (Platform.OS === 'ios') {
|
||||
resizeModes = ['cover', 'fill'];
|
||||
resizeModes = ['contain', 'cover'];
|
||||
} else {
|
||||
// Android: both VLC and RN Video skip 'contain' mode
|
||||
resizeModes = ['cover', 'fill', 'none'];
|
||||
// On Android with VLC backend, only 'none' (original) and 'cover' (client-side crop)
|
||||
resizeModes = useVLC ? ['none', 'cover'] : ['contain', 'cover', 'none'];
|
||||
}
|
||||
|
||||
const currentIndex = resizeModes.indexOf(resizeMode);
|
||||
|
|
@ -3320,8 +3306,6 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
style={{ flex: 1 }}
|
||||
activeOpacity={1}
|
||||
onPress={toggleControls}
|
||||
onLongPress={resetZoom}
|
||||
delayLongPress={300}
|
||||
>
|
||||
{useVLC && !forceVlcRemount ? (
|
||||
<VlcVideoPlayer
|
||||
|
|
@ -3362,7 +3346,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
) : (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
style={[styles.video, customVideoStyles, { transform: [{ scale: zoomScale }] }]}
|
||||
style={[styles.video, customVideoStyles]}
|
||||
source={{
|
||||
uri: currentStreamUrl,
|
||||
headers: headers || getStreamHeaders(),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ interface VlcVideoPlayerProps {
|
|||
source: string;
|
||||
volume: number;
|
||||
zoomScale: number;
|
||||
resizeMode: 'contain' | 'cover' | 'fill' | 'stretch' | 'none';
|
||||
resizeMode: 'contain' | 'cover' | 'none';
|
||||
onLoad: (data: any) => void;
|
||||
onProgress: (data: any) => void;
|
||||
onSeek: (data: any) => void;
|
||||
|
|
@ -63,6 +63,7 @@ const VlcVideoPlayer = forwardRef<VlcPlayerRef, VlcVideoPlayerProps>(({
|
|||
const vlcRef = useRef<any>(null);
|
||||
const [vlcActive, setVlcActive] = useState(true);
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null);
|
||||
|
||||
// Expose imperative methods to parent component
|
||||
useImperativeHandle(ref, () => ({
|
||||
|
|
@ -99,19 +100,21 @@ const VlcVideoPlayer = forwardRef<VlcPlayerRef, VlcVideoPlayerProps>(({
|
|||
const screenDimensions = Dimensions.get('screen');
|
||||
|
||||
const vlcAspectRatio = useMemo(() => {
|
||||
// For VLC, we handle aspect ratio through custom zoom for cover mode
|
||||
// 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
|
||||
// For VLC, no forced aspect ratio - let it preserve natural aspect
|
||||
return undefined;
|
||||
}, [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
|
||||
const vlcOptions = useMemo(() => {
|
||||
return [
|
||||
|
|
@ -145,6 +148,10 @@ const VlcVideoPlayer = forwardRef<VlcPlayerRef, VlcVideoPlayerProps>(({
|
|||
setDuration(lenSec);
|
||||
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)
|
||||
if (restoreTime !== undefined && restoreTime !== null && restoreTime > 0) {
|
||||
setTimeout(() => {
|
||||
|
|
@ -304,36 +311,49 @@ const VlcVideoPlayer = forwardRef<VlcPlayerRef, VlcVideoPlayerProps>(({
|
|||
}
|
||||
|
||||
return (
|
||||
<LibVlcPlayerViewComponent
|
||||
ref={vlcRef}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
transform: [{ scale: zoomScale }]
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
// Force remount when surfaces are recreated
|
||||
key={key || 'vlc-default'}
|
||||
source={processedSource}
|
||||
aspectRatio={vlcAspectRatio}
|
||||
options={vlcOptions}
|
||||
tracks={vlcTracks}
|
||||
volume={Math.round(Math.max(0, Math.min(1, volume)) * 100)}
|
||||
mute={false}
|
||||
repeat={false}
|
||||
rate={1}
|
||||
autoplay={false}
|
||||
onFirstPlay={handleFirstPlay}
|
||||
onPositionChanged={handlePositionChanged}
|
||||
onPlaying={handlePlaying}
|
||||
onPaused={handlePaused}
|
||||
onEndReached={handleEndReached}
|
||||
onEncounteredError={handleEncounteredError}
|
||||
onBackground={handleBackground}
|
||||
onESAdded={handleESAdded}
|
||||
/>
|
||||
>
|
||||
<LibVlcPlayerViewComponent
|
||||
ref={vlcRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
transform: [{ scale: clientScale }]
|
||||
}}
|
||||
// Force remount when surfaces are recreated
|
||||
key={key || 'vlc-default'}
|
||||
source={processedSource}
|
||||
aspectRatio={vlcAspectRatio}
|
||||
// Let VLC auto-fit the video to the view to prevent flicker on mode changes
|
||||
scale={0}
|
||||
options={vlcOptions}
|
||||
tracks={vlcTracks}
|
||||
volume={Math.round(Math.max(0, Math.min(1, volume)) * 100)}
|
||||
mute={false}
|
||||
repeat={false}
|
||||
rate={1}
|
||||
autoplay={false}
|
||||
onFirstPlay={handleFirstPlay}
|
||||
onPositionChanged={handlePositionChanged}
|
||||
onPlaying={handlePlaying}
|
||||
onPaused={handlePaused}
|
||||
onEndReached={handleEndReached}
|
||||
onEncounteredError={handleEncounteredError}
|
||||
onBackground={handleBackground}
|
||||
onESAdded={handleESAdded}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -169,15 +169,15 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
<View style={styles.bottomControls}>
|
||||
{/* Bottom Buttons Row */}
|
||||
<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}>
|
||||
<Ionicons name="resize" size={20} color="white" />
|
||||
<Text style={[styles.bottomButtonText, { fontSize: 14, textAlign: 'center' }]}>
|
||||
{currentResizeMode ?
|
||||
(currentResizeMode === 'none' ? 'Original' :
|
||||
currentResizeMode.charAt(0).toUpperCase() + currentResizeMode.slice(1)) :
|
||||
(zoomScale === 1.1 ? 'Fill' : 'Cover')
|
||||
}
|
||||
{currentResizeMode
|
||||
? (currentResizeMode === 'none'
|
||||
? 'Original'
|
||||
: currentResizeMode.charAt(0).toUpperCase() + currentResizeMode.slice(1))
|
||||
: 'Contain'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ export interface TextTrack {
|
|||
}
|
||||
|
||||
// Define the possible resize modes - force to stretch for absolute full screen
|
||||
export type ResizeModeType = 'contain' | 'cover' | 'fill' | 'none' | 'stretch';
|
||||
export const resizeModes: ResizeModeType[] = ['stretch']; // Force stretch mode for absolute full screen
|
||||
export type ResizeModeType = 'contain' | 'cover' | 'none';
|
||||
export const resizeModes: ResizeModeType[] = ['cover']; // Force cover mode for absolute full screen
|
||||
|
||||
// Add VLC specific interface for their event structure
|
||||
export interface VlcMediaEvent {
|
||||
|
|
|
|||
|
|
@ -243,6 +243,8 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [isFetchingMore, setIsFetchingMore] = useState(false);
|
||||
const [dataSource, setDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS);
|
||||
const [actualCatalogName, setActualCatalogName] = useState<string | null>(null);
|
||||
const [screenData, setScreenData] = useState(() => {
|
||||
|
|
@ -365,10 +367,20 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
loadNowPlayingMovies();
|
||||
}, [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 {
|
||||
if (shouldRefresh) {
|
||||
setRefreshing(true);
|
||||
setPage(1);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
|
@ -425,6 +437,11 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
setHasMore(false); // TMDB already returns a full set
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setIsFetchingMore(false);
|
||||
logger.log('[CatalogScreen] TMDB set items', {
|
||||
count: uniqueItems.length,
|
||||
hasMore: false
|
||||
});
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
|
|
@ -433,6 +450,8 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
setItems([]);
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setIsFetchingMore(false);
|
||||
logger.log('[CatalogScreen] TMDB returned no items');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -443,6 +462,8 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
setItems([]);
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setIsFetchingMore(false);
|
||||
logger.log('[CatalogScreen] TMDB error, cleared items');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -467,12 +488,40 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
const filters = effectiveGenreFilter ? [{ title: 'genre', value: effectiveGenreFilter }] : [];
|
||||
|
||||
// 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) {
|
||||
foundItems = true;
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
setItems(catalogItems);
|
||||
if (shouldRefresh || pageParam === 1) {
|
||||
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) {
|
||||
|
|
@ -554,6 +603,8 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
foundItems = true;
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
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) {
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
setError("No content found for the selected filters");
|
||||
logger.log('[CatalogScreen] No items found after loading');
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -572,12 +624,17 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
InteractionManager.runAfterInteractions(() => {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setIsFetchingMore(false);
|
||||
logger.log('[CatalogScreen] loadItems finished', {
|
||||
shouldRefresh,
|
||||
pageParam
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [addonId, type, id, genreFilter, dataSource]);
|
||||
|
||||
useEffect(() => {
|
||||
loadItems(true);
|
||||
loadItems(true, 1);
|
||||
}, [loadItems]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
|
|
@ -796,6 +853,42 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
showsVerticalScrollIndicator={false}
|
||||
removeClippedSubviews={true}
|
||||
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()}
|
||||
</SafeAreaView>
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ const MetadataScreen: React.FC = () => {
|
|||
const [revealedSpoilers, setRevealedSpoilers] = useState<Set<string>>(new Set());
|
||||
const loadingScreenRef = useRef<MetadataLoadingScreenRef>(null);
|
||||
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
|
||||
|
|
@ -161,26 +163,48 @@ const MetadataScreen: React.FC = () => {
|
|||
const hasNetworks = metadata?.networks && metadata.networks.length > 0;
|
||||
const hasDescription = !!metadata?.description;
|
||||
const isSeries = Object.keys(groupedEpisodes).length > 0;
|
||||
// Defer showing until cast (if any) has finished fetching to avoid layout jump
|
||||
const shouldShow = shouldLoadSecondaryData && !loadingCast && hasNetworks && hasDescription && isSeries;
|
||||
// Defer showing until cast (if any) has finished fetching and 800ms delay elapsed
|
||||
const shouldShow = shouldLoadSecondaryData && postCastDelayDone && hasNetworks && hasDescription && isSeries;
|
||||
|
||||
if (shouldShow && networkSectionOpacity.value === 0) {
|
||||
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)
|
||||
useEffect(() => {
|
||||
const hasNetworks = metadata?.networks && metadata.networks.length > 0;
|
||||
const hasDescription = !!metadata?.description;
|
||||
const isMovie = Object.keys(groupedEpisodes).length === 0;
|
||||
// Defer showing until cast (if any) has finished fetching to avoid layout jump
|
||||
const shouldShow = shouldLoadSecondaryData && !loadingCast && hasNetworks && hasDescription && isMovie;
|
||||
// Defer showing until cast (if any) has finished fetching and 800ms delay elapsed
|
||||
const shouldShow = shouldLoadSecondaryData && postCastDelayDone && hasNetworks && hasDescription && isMovie;
|
||||
|
||||
if (shouldShow && productionSectionOpacity.value === 0) {
|
||||
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
|
||||
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 initialized: boolean = false;
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
private catalogHasMore: Map<string, boolean> = new Map();
|
||||
|
||||
private constructor() {
|
||||
// Start initialization but don't wait for it
|
||||
|
|
@ -734,65 +735,55 @@ class StremioService {
|
|||
}
|
||||
|
||||
async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise<Meta[]> {
|
||||
// Special handling for Cinemeta
|
||||
if (manifest.id === 'com.linvo.cinemeta') {
|
||||
const baseUrl = 'https://v3-cinemeta.strem.io';
|
||||
const encodedId = encodeURIComponent(id);
|
||||
let url = `${baseUrl}/catalog/${type}/${encodedId}.json`;
|
||||
|
||||
// Add paging
|
||||
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 [];
|
||||
}
|
||||
// Build URLs (path-style skip and query-style skip) and try both for broad addon support
|
||||
const encodedId = encodeURIComponent(id);
|
||||
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('');
|
||||
|
||||
// For other addons
|
||||
// For all addons
|
||||
if (!manifest.url) {
|
||||
throw new Error('Addon URL is missing');
|
||||
}
|
||||
|
||||
try {
|
||||
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
|
||||
const encodedId = encodeURIComponent(id);
|
||||
let url = `${baseUrl}/catalog/${type}/${encodedId}.json`;
|
||||
|
||||
// Add paging
|
||||
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)}`;
|
||||
}
|
||||
});
|
||||
// Candidate 2: Query-style skip URL: /catalog/{type}/{id}.json?skip={N}&limit={PAGE_SIZE}
|
||||
let urlQueryStyle = `${baseUrl}/catalog/${type}/${encodedId}.json?skip=${pageSkip}&limit=${this.DEFAULT_PAGE_SIZE}`;
|
||||
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 {}
|
||||
|
||||
// Try path-style first, then fallback to query-style
|
||||
let response;
|
||||
try {
|
||||
response = await this.retryRequest(async () => axios.get(urlPathWithFilters));
|
||||
} catch (e) {
|
||||
try {
|
||||
response = await this.retryRequest(async () => axios.get(urlQueryStyle));
|
||||
} catch (e2) {
|
||||
throw e2;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
|
||||
if (response && response.data) {
|
||||
const hasMore = typeof response.data.hasMore === 'boolean' ? response.data.hasMore : undefined;
|
||||
try {
|
||||
const key = `${manifest.id}|${type}|${id}`;
|
||||
if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore);
|
||||
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 [];
|
||||
} catch (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> {
|
||||
console.log(`🔍 [StremioService] getMetaDetails called:`, { type, id, preferredAddonId });
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in a new issue