diff --git a/patches/react-native-video+6.12.0.patch b/patches/react-native-video+6.12.0.patch new file mode 100644 index 0000000..e69de29 diff --git a/src/components/loading/MetadataLoadingScreen.tsx b/src/components/loading/MetadataLoadingScreen.tsx index f9d1dd6..034fa6a 100644 --- a/src/components/loading/MetadataLoadingScreen.tsx +++ b/src/components/loading/MetadataLoadingScreen.tsx @@ -29,8 +29,7 @@ export const MetadataLoadingScreen = forwardRef { 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 { 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 {/* Pulsating overlay removed */} - - - + {/* Shimmer overlay removed */} ); diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index c5fdee3..3d18114 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -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 = memo(({ const trailerVideoRef = useRef(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 = memo(({ }, [metadata?.logo]); // Stable logo state management - prevent flickering between logo and text - const [stableLogoUri, setStableLogoUri] = useState(null); + const [stableLogoUri, setStableLogoUri] = useState(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(!metadata?.logo); + const logoWaitTimerRef = useRef(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 = 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 = 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 = 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 = 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 = memo(({ {/* Optimized Background */} - {/* Optimized shimmer loading effect */} - {shouldLoadSecondaryData && (trailerLoading || ((imageSource && !imageLoaded) || loadingBanner)) && ( - - - - )} + {/* Shimmer loading effect removed */} {/* Background thumbnail image - always rendered when available */} {shouldLoadSecondaryData && imageSource && !loadingBanner && ( @@ -1574,18 +1583,21 @@ const HeroSection: React.FC = memo(({ {/* Optimized Title/Logo - Show logo immediately when available */} - {stableLogoUri ? ( - - ) : ( - + ) : shouldShowTextFallback ? ( + {metadata.name} + ) : ( + // Reserve space to prevent layout jump while waiting briefly for logo + )} @@ -1602,7 +1614,7 @@ const HeroSection: React.FC = memo(({ trailerReady={trailerReady} /> - {/* Optimized genre display with lazy loading */} + {/* Optimized genre display with lazy loading; no fixed blank space */} {shouldLoadSecondaryData && genreElements && ( {genreElements} diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index ee9efc4..0a89cdc 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -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({}); 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 ? ( { ) : ( ); }); diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx index dbc2ec8..44a92e5 100644 --- a/src/components/player/controls/PlayerControls.tsx +++ b/src/components/player/controls/PlayerControls.tsx @@ -169,15 +169,15 @@ export const PlayerControls: React.FC = ({ {/* Bottom Buttons Row */} - {/* Fill/Cover Button - Updated to show fill/cover modes */} + {/* Aspect Ratio Button - uses official resize modes */} - {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'} diff --git a/src/components/player/utils/playerTypes.ts b/src/components/player/utils/playerTypes.ts index a65f854..2a58008 100644 --- a/src/components/player/utils/playerTypes.ts +++ b/src/components/player/utils/playerTypes.ts @@ -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 { diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 00c6da8..ce72e9c 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -243,6 +243,8 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [hasMore, setHasMore] = useState(false); + const [page, setPage] = useState(1); + const [isFetchingMore, setIsFetchingMore] = useState(false); const [dataSource, setDataSource] = useState(DataSource.STREMIO_ADDONS); const [actualCatalogName, setActualCatalogName] = useState(null); const [screenData, setScreenData] = useState(() => { @@ -365,10 +367,20 @@ const CatalogScreen: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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(); + 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 ? ( + + + + ) : null} /> ) : renderEmptyState()} diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index ce15c3e..d37128a 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -105,6 +105,8 @@ const MetadataScreen: React.FC = () => { const [revealedSpoilers, setRevealedSpoilers] = useState>(new Set()); const loadingScreenRef = useRef(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); diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 78d9ea6..49593b0 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -182,6 +182,7 @@ class StremioService { private readonly DEFAULT_PAGE_SIZE = 50; private initialized: boolean = false; private initializationPromise: Promise | null = null; + private catalogHasMore: Map = 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 { - // 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 { console.log(`🔍 [StremioService] getMetaDetails called:`, { type, id, preferredAddonId }); try {