diff --git a/ios/KSPlayerManager.m b/ios/KSPlayerManager.m index 5744e62..708118a 100644 --- a/ios/KSPlayerManager.m +++ b/ios/KSPlayerManager.m @@ -48,8 +48,8 @@ RCT_EXTERN_METHOD(showAirPlayPicker:(nonnull NSNumber *)node) @interface RCT_EXTERN_MODULE(KSPlayerModule, RCTEventEmitter) -RCT_EXTERN_METHOD(getTracks:(nonnull NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(getAirPlayState:(nonnull NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(showAirPlayPicker:(nonnull NSNumber *)nodeTag) +RCT_EXTERN_METHOD(getTracks:(NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(getAirPlayState:(NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(showAirPlayPicker:(NSNumber *)nodeTag) @end diff --git a/ios/KSPlayerModule.swift b/ios/KSPlayerModule.swift index 58ace7c..27dd614 100644 --- a/ios/KSPlayerModule.swift +++ b/ios/KSPlayerModule.swift @@ -25,7 +25,11 @@ class KSPlayerModule: RCTEventEmitter { ] } - @objc func getTracks(_ nodeTag: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + @objc func getTracks(_ nodeTag: NSNumber?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + guard let nodeTag = nodeTag else { + reject("INVALID_ARGUMENT", "nodeTag must not be nil", nil) + return + } DispatchQueue.main.async { if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager { viewManager.getTracks(nodeTag, resolve: resolve, reject: reject) @@ -35,7 +39,11 @@ class KSPlayerModule: RCTEventEmitter { } } - @objc func getAirPlayState(_ nodeTag: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + @objc func getAirPlayState(_ nodeTag: NSNumber?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + guard let nodeTag = nodeTag else { + reject("INVALID_ARGUMENT", "nodeTag must not be nil", nil) + return + } DispatchQueue.main.async { if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager { viewManager.getAirPlayState(nodeTag, resolve: resolve, reject: reject) @@ -45,7 +53,11 @@ class KSPlayerModule: RCTEventEmitter { } } - @objc func showAirPlayPicker(_ nodeTag: NSNumber) { + @objc func showAirPlayPicker(_ nodeTag: NSNumber?) { + guard let nodeTag = nodeTag else { + print("[KSPlayerModule] showAirPlayPicker called with nil nodeTag") + return + } print("[KSPlayerModule] showAirPlayPicker called for nodeTag: \(nodeTag)") DispatchQueue.main.async { if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager { diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 152ddbb..d9daf84 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -543,7 +543,11 @@ const SeriesContentComponent: React.FC = ({ - const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b); + const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => { + if (a === 0) return 1; + if (b === 0) return -1; + return a - b; + }); return ( = ({ { color: currentTheme.colors.highEmphasis } ] ]} numberOfLines={1}> - Season {season} + {season === 0 ? 'Specials' : `Season ${season}`} @@ -723,7 +727,7 @@ const SeriesContentComponent: React.FC = ({ ] ]} > - Season {season} + {season === 0 ? 'Specials' : `Season ${season}`} diff --git a/src/components/player/KSPlayerComponent.tsx b/src/components/player/KSPlayerComponent.tsx index 55d3cf8..569ca06 100644 --- a/src/components/player/KSPlayerComponent.tsx +++ b/src/components/player/KSPlayerComponent.tsx @@ -130,7 +130,9 @@ const KSPlayer = forwardRef((props, ref) => { getTracks: async () => { if (nativeRef.current) { const node = findNodeHandle(nativeRef.current); - return await KSPlayerModule.getTracks(node); + if (node) { + return await KSPlayerModule.getTracks(node); + } } return { audioTracks: [], textTracks: [] }; }, @@ -153,15 +155,21 @@ const KSPlayer = forwardRef((props, ref) => { getAirPlayState: async () => { if (nativeRef.current) { const node = findNodeHandle(nativeRef.current); - return await KSPlayerModule.getAirPlayState(node); + if (node) { + return await KSPlayerModule.getAirPlayState(node); + } } return { allowsExternalPlayback: false, usesExternalPlaybackWhileExternalScreenIsActive: false, isExternalPlaybackActive: false }; }, showAirPlayPicker: () => { if (nativeRef.current) { const node = findNodeHandle(nativeRef.current); - console.log('[KSPlayerComponent] Calling showAirPlayPicker with node:', node); - KSPlayerModule.showAirPlayPicker(node); + if (node) { + console.log('[KSPlayerComponent] Calling showAirPlayPicker with node:', node); + KSPlayerModule.showAirPlayPicker(node); + } else { + console.warn('[KSPlayerComponent] Cannot call showAirPlayPicker: node is null'); + } } else { console.log('[KSPlayerComponent] nativeRef.current is null'); } diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index c105b1a..ac7192e 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -1181,14 +1181,59 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Determine initial season only once per series const seasons = Object.keys(groupedAddonEpisodes).map(Number); - const firstSeason = Math.min(...seasons); + const nonZeroSeasons = seasons.filter(s => s !== 0); + const firstSeason = nonZeroSeasons.length > 0 ? Math.min(...nonZeroSeasons) : Math.min(...seasons); if (!initializedSeasonRef.current) { - const nextSeason = firstSeason; - if (selectedSeason !== nextSeason) { - logger.log(`📺 Setting season ${nextSeason} as selected (${groupedAddonEpisodes[nextSeason]?.length || 0} episodes)`); - setSelectedSeason(nextSeason); + // Check for watch progress to auto-select season + let selectedSeasonNumber = firstSeason; + try { + const allProgress = await storageService.getAllWatchProgress(); + let mostRecentEpisodeId = ''; + let mostRecentTimestamp = 0; + Object.entries(allProgress).forEach(([key, progress]) => { + if (key.includes(`series:${id}:`)) { + const episodeId = key.split(`series:${id}:`)[1]; + if (progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) { + mostRecentTimestamp = progress.lastUpdated; + mostRecentEpisodeId = episodeId; + } + } + }); + + if (mostRecentEpisodeId) { + // Try to parse season from ID or find matching episode + const parts = mostRecentEpisodeId.split(':'); + if (parts.length === 3) { + // Format: showId:season:episode + const watchProgressSeason = parseInt(parts[1], 10); + if (groupedAddonEpisodes[watchProgressSeason]) { + selectedSeasonNumber = watchProgressSeason; + logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for ${mostRecentEpisodeId}`); + } + } else { + // Try to find by stremioId + const allEpisodesList = Object.values(groupedAddonEpisodes).flat(); + const episode = allEpisodesList.find(ep => ep.stremioId === mostRecentEpisodeId); + if (episode) { + selectedSeasonNumber = episode.season_number; + logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for episode with stremioId ${mostRecentEpisodeId}`); + } + } + } else { + // No watch progress, try persistent storage + selectedSeasonNumber = getSeason(id, firstSeason); + logger.log(`[useMetadata] No watch progress found, using persistent season ${selectedSeasonNumber}`); + } + } catch (error) { + logger.error('[useMetadata] Error checking watch progress for season selection:', error); + selectedSeasonNumber = getSeason(id, firstSeason); } - setEpisodes(groupedAddonEpisodes[nextSeason] || []); + + if (selectedSeason !== selectedSeasonNumber) { + logger.log(`📺 Setting season ${selectedSeasonNumber} as selected`); + setSelectedSeason(selectedSeasonNumber); + } + setEpisodes(groupedAddonEpisodes[selectedSeasonNumber] || []); initializedSeasonRef.current = true; } else { // Keep current selection; refresh episode list for selected season @@ -1238,8 +1283,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setGroupedEpisodes(transformedEpisodes); - // Get the first available season as fallback - const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number)); + // Get the first available season as fallback (preferring non-zero seasons) + const availableSeasons = Object.keys(allEpisodes).map(Number); + const nonZeroSeasons = availableSeasons.filter(s => s !== 0); + const firstSeason = nonZeroSeasons.length > 0 ? Math.min(...nonZeroSeasons) : Math.min(...availableSeasons); if (!initializedSeasonRef.current) { // Check for watch progress to auto-select season diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index fbb2c6e..3bdb515 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -135,7 +135,7 @@ const HomeScreen = () => { const [hasAddons, setHasAddons] = useState(null); const [hintVisible, setHintVisible] = useState(false); const totalCatalogsRef = useRef(0); - const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory + const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory const insets = useSafeAreaInsets(); // Stabilize insets to prevent iOS layout shifts @@ -147,7 +147,7 @@ const HomeScreen = () => { }, 100); return () => clearTimeout(timer); }, [insets.top]); - + const { featuredContent, allFeaturedContent, @@ -158,43 +158,49 @@ const HomeScreen = () => { refreshFeatured } = useFeaturedContent(); + // Guard to prevent overlapping fetch calls + const isFetchingRef = useRef(false); + // Progressive catalog loading function with performance optimizations const loadCatalogsProgressively = useCallback(async () => { + if (isFetchingRef.current) return; + isFetchingRef.current = true; + setCatalogsLoading(true); setCatalogs([]); setLoadedCatalogCount(0); - + try { // Check cache first let catalogSettings: Record = {}; const now = Date.now(); - + if (cachedCatalogSettings && (now - catalogSettingsCacheTimestamp) < CATALOG_SETTINGS_CACHE_TTL) { catalogSettings = cachedCatalogSettings; } else { // Load from storage const catalogSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY); catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {}; - + // Update cache cachedCatalogSettings = catalogSettings; catalogSettingsCacheTimestamp = now; } - + const [addons, addonManifests] = await Promise.all([ catalogService.getAllAddons(), stremioService.getInstalledAddonsAsync() ]); - + // Set hasAddons state based on whether we have any addons - ensure on main thread InteractionManager.runAfterInteractions(() => { setHasAddons(addons.length > 0); }); - + // Create placeholder array with proper order and track indices let catalogIndex = 0; const catalogQueue: (() => Promise)[] = []; - + // Launch all catalog loaders in parallel const launchAllCatalogs = () => { while (catalogQueue.length > 0) { @@ -204,18 +210,18 @@ const HomeScreen = () => { } } }; - + for (const addon of addons) { if (addon.catalogs) { for (const catalog of addon.catalogs) { // Check if this catalog is enabled (default to true if no setting exists) const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`; const isEnabled = catalogSettings[settingKey] ?? true; - + // Only load enabled catalogs if (isEnabled) { const currentIndex = catalogIndex; - + const catalogLoader = async () => { try { const manifest = addonManifests.find((a: any) => a.id === addon.id); @@ -226,7 +232,7 @@ const HomeScreen = () => { // Aggressively limit items per catalog on Android to reduce memory usage const limit = Platform.OS === 'android' ? 18 : 30; const limitedMetas = metas.slice(0, limit); - + const items = limitedMetas.map((meta: any) => ({ id: meta.id, type: meta.type, @@ -267,7 +273,7 @@ const HomeScreen = () => { displayName = `${displayName} ${contentType}`; } } - + const catalogContent = { addon: addon.id, type: catalog.type, @@ -275,7 +281,7 @@ const HomeScreen = () => { name: displayName, items }; - + // Update the catalog at its specific position - ensure on main thread InteractionManager.runAfterInteractions(() => { setCatalogs(prevCatalogs => { @@ -296,26 +302,37 @@ const HomeScreen = () => { if (prev === 0) { setCatalogsLoading(false); } + // ** Crucial: If all catalogs processed, release the fetch guard ** + if (next >= totalCatalogsRef.current) { + isFetchingRef.current = false; + } return next; }); }); } }; - + catalogQueue.push(catalogLoader); catalogIndex++; } } } } - + totalCatalogsRef.current = catalogIndex; - + + // If no catalogs to load, release locks immediately + if (catalogIndex === 0) { + setCatalogsLoading(false); + isFetchingRef.current = false; + return; + } + // Initialize catalogs array with proper length - ensure on main thread InteractionManager.runAfterInteractions(() => { setCatalogs(new Array(catalogIndex).fill(null)); }); - + // Start all catalog requests in parallel launchAllCatalogs(); } catch (error) { @@ -323,6 +340,7 @@ const HomeScreen = () => { InteractionManager.runAfterInteractions(() => { setCatalogsLoading(false); }); + isFetchingRef.current = false; } }, []); @@ -371,7 +389,7 @@ const HomeScreen = () => { // Also show a global toast for consistency across screens // showInfo('Sign In Available', 'You can sign in anytime from Settings → Account'); } - } catch {} + } catch { } })(); return () => { if (hideTimer) clearTimeout(hideTimer); @@ -389,10 +407,10 @@ const HomeScreen = () => { setShowHeroSection(settings.showHeroSection); setFeaturedContentSource(settings.featuredContentSource); }; - + // Subscribe to settings changes const unsubscribe = settingsEmitter.addListener(handleSettingsChange); - + return unsubscribe; }, [settings.showHeroSection, settings.featuredContentSource]); @@ -409,12 +427,12 @@ const HomeScreen = () => { StatusBar.setHidden(false); } }; - + statusBarConfig(); - + // Unlock orientation to allow free rotation - ScreenOrientation.unlockAsync().catch(() => {}); - + ScreenOrientation.unlockAsync().catch(() => { }); + return () => { // Stop trailer when screen loses focus (navigating to other screens) setTrailerPlaying(false); @@ -450,12 +468,12 @@ const HomeScreen = () => { StatusBar.setTranslucent(false); StatusBar.setBackgroundColor(currentTheme.colors.darkBackground); } - + // Clean up any lingering timeouts if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); } - + // Don't clear FastImage cache on unmount - it causes broken images on remount // FastImage's native libraries (SDWebImage/Glide) handle memory automatically // Cache clearing only happens on app background (see AppState handler above) @@ -468,11 +486,11 @@ const HomeScreen = () => { // Balanced preload images function using FastImage const preloadImages = useCallback(async (content: StreamingContent[]) => { if (!content.length) return; - + try { // Moderate prefetching for better performance balance const MAX_IMAGES = 10; // Preload 10 most important images - + // Only preload poster images (skip banner and logo entirely) const posterImages = content.slice(0, MAX_IMAGES) .map(item => item.poster) @@ -499,24 +517,24 @@ const HomeScreen = () => { const handlePlayStream = useCallback(async (stream: Stream) => { if (!featuredContent) return; - + try { // Don't clear cache before player - causes broken images on return // FastImage's native libraries handle memory efficiently - + // Lock orientation to landscape before navigation to prevent glitches try { - await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); - + await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); + // Longer delay to ensure orientation is fully set before navigation await new Promise(resolve => setTimeout(resolve, 200)); } catch (orientationError) { // If orientation lock fails, continue anyway but log it logger.warn('[HomeScreen] Orientation lock failed:', orientationError); // Still add a small delay - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise(resolve => setTimeout(resolve, 100)); } - + navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', { uri: stream.url, title: featuredContent.name, @@ -528,7 +546,7 @@ const HomeScreen = () => { }); } catch (error) { logger.error('[HomeScreen] Error in handlePlayStream:', error); - + // Fallback: navigate anyway navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', { uri: stream.url, @@ -545,9 +563,9 @@ const HomeScreen = () => { const refreshContinueWatching = useCallback(async () => { if (continueWatchingRef.current) { try { - const hasContent = await continueWatchingRef.current.refresh(); - setHasContinueWatching(hasContent); - + const hasContent = await continueWatchingRef.current.refresh(); + setHasContinueWatching(hasContent); + } catch (error) { if (__DEV__) console.error('[HomeScreen] Error refreshing continue watching:', error); setHasContinueWatching(false); @@ -555,19 +573,31 @@ const HomeScreen = () => { } }, []); + // Use refs to track state for event listeners without triggering re-effects + const catalogsLengthRef = useRef(catalogs.length); + const catalogsLoadingRef = useRef(catalogsLoading); + + useEffect(() => { + catalogsLengthRef.current = catalogs.length; + }, [catalogs.length]); + + useEffect(() => { + catalogsLoadingRef.current = catalogsLoading; + }, [catalogsLoading]); + useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { // Only refresh continue watching section on focus refreshContinueWatching(); // Don't reload catalogs unless they haven't been loaded yet - // Catalogs will be refreshed through context updates when addons change - if (catalogs.length === 0 && !catalogsLoading) { + // Uses refs to avoid re-binding the listener on every state change + if (catalogsLengthRef.current === 0 && !catalogsLoadingRef.current) { loadCatalogsProgressively(); } }); return unsubscribe; - }, [navigation, refreshContinueWatching, loadCatalogsProgressively, catalogs.length, catalogsLoading]); + }, [navigation, refreshContinueWatching, loadCatalogsProgressively]); // Memoize the loading screen to prevent unnecessary re-renders const renderLoadingScreen = useMemo(() => { @@ -603,7 +633,7 @@ const HomeScreen = () => { // Only show a limited number of catalogs initially for performance const catalogsToShow = catalogs.slice(0, visibleCatalogCount); - + catalogsToShow.forEach((catalog, index) => { if (catalog) { data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` }); @@ -637,7 +667,7 @@ const HomeScreen = () => { // Memoize individual section components to prevent re-renders const memoizedFeaturedContent = useMemo(() => { const heroStyleToUse = settings.heroStyle; - + // AppleTVHero is only available on mobile devices (not tablets) if (heroStyleToUse === 'appletv' && !isTablet) { return ( @@ -694,7 +724,7 @@ const HomeScreen = () => { const lastToggleRef = useRef(0); const scrollAnimationFrameRef = useRef(null); const isScrollingRef = useRef(false); - + const toggleHeader = useCallback((hide: boolean) => { const now = Date.now(); if (now - lastToggleRef.current < 120) return; // debounce @@ -783,26 +813,26 @@ const HomeScreen = () => { const handleScroll = useCallback((event: any) => { // Persist the event before using requestAnimationFrame to prevent event pooling issues event.persist(); - + // Cancel any pending animation frame if (scrollAnimationFrameRef.current !== null) { cancelAnimationFrame(scrollAnimationFrameRef.current); } - + // Capture scroll values immediately before async operation const scrollYValue = event.nativeEvent.contentOffset.y; - + // Update shared value for parallax (on UI thread) scrollY.value = scrollYValue; - + // Use requestAnimationFrame to throttle scroll handling scrollAnimationFrameRef.current = requestAnimationFrame(() => { const y = scrollYValue; const dy = y - lastScrollYRef.current; lastScrollYRef.current = y; - + isScrollingRef.current = Math.abs(dy) > 0; - + if (y <= 10) { toggleHeader(false); return; @@ -813,7 +843,7 @@ const HomeScreen = () => { } else if (dy < -6) { toggleHeader(false); // scrolling up } - + scrollAnimationFrameRef.current = null; }); }, [toggleHeader]); @@ -823,9 +853,9 @@ const HomeScreen = () => { const contentContainerStyle = useMemo(() => { const heroStyleToUse = settings.heroStyle; const isUsingAppleTVHero = heroStyleToUse === 'appletv' && !isTablet && showHeroSection; - + return StyleSheet.flatten([ - styles.scrollContent, + styles.scrollContent, { paddingTop: isUsingAppleTVHero ? 0 : stableInsetsTop } ]); }, [stableInsetsTop, settings.heroStyle, isTablet, showHeroSection]); @@ -833,9 +863,9 @@ const HomeScreen = () => { // Memoize the main content section const renderMainContent = useMemo(() => { if (isLoading) return null; - + return ( - + { const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters const LEFT_PADDING = 16; // Left padding const SPACING = 8; // Space between posters - + // Calculate available width for posters (reserve space for left padding) const availableWidth = screenWidth - LEFT_PADDING; - + // Try different numbers of full posters to find the best fit let bestLayout = { numFullPosters: 3, posterWidth: 120 }; - + for (let n = 3; n <= 6; n++) { // Calculate poster width needed for N full posters + 0.25 partial poster // Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding @@ -896,12 +926,12 @@ const calculatePosterLayout = (screenWidth: number) => { // We'll use minimal right padding (8px) to maximize space const usableWidth = availableWidth - 8; const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25); - + if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) { bestLayout = { numFullPosters: n, posterWidth }; } } - + return { numFullPosters: bestLayout.numFullPosters, posterWidth: bestLayout.posterWidth, @@ -966,7 +996,7 @@ const styles = StyleSheet.create({ }, placeholderPoster: { width: POSTER_WIDTH, - aspectRatio: 2/3, + aspectRatio: 2 / 3, borderRadius: 12, marginRight: 2, }, @@ -1203,7 +1233,7 @@ const styles = StyleSheet.create({ }, contentItem: { width: POSTER_WIDTH, - aspectRatio: 2/3, + aspectRatio: 2 / 3, margin: 0, borderRadius: 4, overflow: 'hidden',