improved vlc behaviour

This commit is contained in:
tapframe 2025-10-16 01:53:30 +05:30
parent b1e9f9b3f8
commit e2719c373d
10 changed files with 306 additions and 208 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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