diff --git a/libmpv-android b/libmpv-android index db3b10e6..1a94da42 160000 --- a/libmpv-android +++ b/libmpv-android @@ -1 +1 @@ -Subproject commit db3b10e64353349d0d72619ca7d779829e36fe4d +Subproject commit 1a94da42094b524b94a28902ae43c27e3286460d diff --git a/mpv-android b/mpv-android index 118cd1ed..a31e9a0d 160000 --- a/mpv-android +++ b/mpv-android @@ -1 +1 @@ -Subproject commit 118cd1ed3d498265e44230e5dbb015bdd59f9dad +Subproject commit a31e9a0d270066deb41fe330ed34ddeb0e38f0ab diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index e720f745..73b586cc 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -107,13 +107,13 @@ interface HeroSectionProps { } // Ultra-optimized ActionButtons Component - minimal re-renders -const ActionButtons = memo(({ - handleShowStreams, - toggleLibrary, - inLibrary, - type, - id, - navigation, +const ActionButtons = memo(({ + handleShowStreams, + toggleLibrary, + inLibrary, + type, + id, + navigation, playButtonText, animatedStyle, isWatched, @@ -150,21 +150,21 @@ const ActionButtons = memo(({ }) => { const { currentTheme } = useTheme(); const { showSaved, showTraktSaved, showRemoved, showTraktRemoved, showSuccess, showInfo } = useToast(); - + // Performance optimization: Cache theme colors const themeColors = useMemo(() => ({ white: currentTheme.colors.white, black: '#000', primary: currentTheme.colors.primary }), [currentTheme.colors.white, currentTheme.colors.primary]); - + // Optimized navigation handler with useCallback const handleRatingsPress = useCallback(async () => { // Early return if no ID if (!id) return; - + let finalTmdbId: number | null = null; - + if (id.startsWith('tmdb:')) { const numericPart = id.split(':')[1]; const parsedId = parseInt(numericPart, 10); @@ -187,7 +187,7 @@ const ActionButtons = memo(({ finalTmdbId = parsedId; } } - + if (finalTmdbId !== null) { // Use requestAnimationFrame for smoother navigation requestAnimationFrame(() => { @@ -199,15 +199,15 @@ const ActionButtons = memo(({ // Enhanced save handler that combines local library + Trakt watchlist const handleSaveAction = useCallback(async () => { const wasInLibrary = inLibrary; - + // Always toggle local library first toggleLibrary(); - + // If authenticated, also toggle Trakt watchlist if (isAuthenticated && onToggleWatchlist) { await onToggleWatchlist(); } - + // Show appropriate toast if (isAuthenticated) { if (wasInLibrary) { @@ -227,12 +227,12 @@ const ActionButtons = memo(({ // Enhanced collection handler with toast notifications const handleCollectionAction = useCallback(async () => { const wasInCollection = isInCollection; - + // Toggle collection if (onToggleCollection) { await onToggleCollection(); } - + // Show appropriate toast if (wasInCollection) { showInfo('Removed from Collection', 'Removed from your Trakt collection'); @@ -295,10 +295,10 @@ const ActionButtons = memo(({ // For watched episodes, check if next episode exists const nextEpisode = episodeNum + 1; const currentSeasonEpisodes = groupedEpisodes[seasonNum] || []; - const nextEpisodeExists = currentSeasonEpisodes.some(ep => + const nextEpisodeExists = currentSeasonEpisodes.some(ep => ep.episode_number === nextEpisode ); - + if (nextEpisodeExists) { // Show the NEXT episode number only if it exists const seasonStr = seasonNum.toString().padStart(2, '0'); @@ -311,10 +311,10 @@ const ActionButtons = memo(({ } else { // For non-watched episodes, check if current episode exists const currentSeasonEpisodes = groupedEpisodes[seasonNum] || []; - const currentEpisodeExists = currentSeasonEpisodes.some(ep => + const currentEpisodeExists = currentSeasonEpisodes.some(ep => ep.episode_number === episodeNum ); - + if (currentEpisodeExists) { // Current episode exists, use original button text return playButtonText; @@ -336,141 +336,141 @@ const ActionButtons = memo(({ // Count additional buttons (excluding Play and Save) - AI Chat no longer counted const hasTraktCollection = isAuthenticated; const hasRatings = type === 'series'; - + // Count additional buttons (AI Chat removed - now in top right corner) const additionalButtonCount = (hasTraktCollection ? 1 : 0) + (hasRatings ? 1 : 0); - + return ( {/* Single Row Layout - Play, Save, and optionally Collection/Ratings */} - - { - if (isWatched) { - return type === 'movie' ? 'replay' : 'play-arrow'; - } - return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow'; - })()} - size={isTablet ? 28 : 24} - color={isWatched && type === 'movie' ? "#fff" : "#000"} - /> - {finalPlayButtonText} - + + { + if (isWatched) { + return type === 'movie' ? 'replay' : 'play-arrow'; + } + return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow'; + })()} + size={isTablet ? 28 : 24} + color={isWatched && type === 'movie' ? "#fff" : "#000"} + /> + {finalPlayButtonText} + + + {Platform.OS === 'ios' ? ( + GlassViewComp && liquidGlassAvailable ? ( + + ) : ( + + ) + ) : ( + + )} + + + {inLibrary ? 'Saved' : 'Save'} + + + + {/* Trakt Collection Button */} + {hasTraktCollection && ( {Platform.OS === 'ios' ? ( GlassViewComp && liquidGlassAvailable ? ( ) : ( - + ) ) : ( - + )} - - {inLibrary ? 'Saved' : 'Save'} - + )} - {/* Trakt Collection Button */} - {hasTraktCollection && ( - - {Platform.OS === 'ios' ? ( - GlassViewComp && liquidGlassAvailable ? ( - - ) : ( - - ) + {/* Ratings Button (for series) */} + {hasRatings && ( + + {Platform.OS === 'ios' ? ( + GlassViewComp && liquidGlassAvailable ? ( + ) : ( - - )} - - - )} - - {/* Ratings Button (for series) */} - {hasRatings && ( - - {Platform.OS === 'ios' ? ( - GlassViewComp && liquidGlassAvailable ? ( - - ) : ( - - ) - ) : ( - - )} - - - )} + + ) + ) : ( + + )} + + + )} ); }); // Enhanced WatchProgress Component with Trakt integration and watched status -const WatchProgressDisplay = memo(({ - watchProgress, - type, - getEpisodeDetails, +const WatchProgressDisplay = memo(({ + watchProgress, + type, + getEpisodeDetails, animatedStyle, isWatched, isTrailerPlaying, trailerMuted, trailerReady }: { - watchProgress: { - currentTime: number; - duration: number; - lastUpdated: number; + watchProgress: { + currentTime: number; + duration: number; + lastUpdated: number; episodeId?: string; traktSynced?: boolean; traktProgress?: number; @@ -485,11 +485,11 @@ const WatchProgressDisplay = memo(({ }) => { const { currentTheme } = useTheme(); const { isAuthenticated: isTraktAuthenticated, forceSyncTraktProgress } = useTraktContext(); - + // State to trigger refresh after manual sync const [refreshTrigger, setRefreshTrigger] = useState(0); const [isSyncing, setIsSyncing] = useState(false); - + // Animated values for enhanced effects const completionGlow = useSharedValue(0); const celebrationScale = useSharedValue(1); @@ -498,7 +498,7 @@ const WatchProgressDisplay = memo(({ const progressBoxScale = useSharedValue(0.8); const progressBoxTranslateY = useSharedValue(20); const syncRotation = useSharedValue(0); - + // Animate the sync icon when syncing useEffect(() => { if (isSyncing) { @@ -511,7 +511,7 @@ const WatchProgressDisplay = memo(({ syncRotation.value = 0; } }, [isSyncing, syncRotation]); - + // Handle manual Trakt sync const handleTraktSync = useMemo(() => async () => { if (isTraktAuthenticated && forceSyncTraktProgress) { @@ -520,7 +520,7 @@ const WatchProgressDisplay = memo(({ try { const success = await forceSyncTraktProgress(); logger.log(`[HeroSection] Manual Trakt sync ${success ? 'successful' : 'failed'}`); - + // Force component to re-render after a short delay to update sync status if (success) { setTimeout(() => { @@ -541,7 +541,7 @@ const WatchProgressDisplay = memo(({ const syncIconStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${syncRotation.value}deg` }], })); - + // Memoized progress calculation with Trakt integration const progressData = useMemo(() => { // If content is fully watched, show watched status instead of progress @@ -553,16 +553,16 @@ const WatchProgressDisplay = memo(({ episodeInfo = ` â€ĸ S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`; } } - - const watchedDate = watchProgress?.lastUpdated + + const watchedDate = watchProgress?.lastUpdated ? new Date(watchProgress.lastUpdated).toLocaleDateString('en-US') : new Date().toLocaleDateString('en-US'); - + // Determine if watched via Trakt or local - const watchedViaTrakt = isTraktAuthenticated && - watchProgress?.traktProgress !== undefined && + const watchedViaTrakt = isTraktAuthenticated && + watchProgress?.traktProgress !== undefined && watchProgress.traktProgress >= 95; - + return { progressPercent: 100, formattedTime: watchedDate, @@ -579,7 +579,7 @@ const WatchProgressDisplay = memo(({ // Determine which progress to show - prioritize Trakt if available and authenticated let progressPercent; let isUsingTraktProgress = false; - + if (isTraktAuthenticated && watchProgress.traktProgress !== undefined) { progressPercent = watchProgress.traktProgress; isUsingTraktProgress = true; @@ -599,7 +599,7 @@ const WatchProgressDisplay = memo(({ // Enhanced display text with Trakt integration let displayText = progressPercent >= 85 ? 'Watched' : `${Math.round(progressPercent)}% watched`; let syncStatus = ''; - + // Show Trakt sync status if user is authenticated if (isTraktAuthenticated) { if (isUsingTraktProgress) { @@ -610,8 +610,8 @@ const WatchProgressDisplay = memo(({ } else if (watchProgress.traktSynced) { syncStatus = ' â€ĸ Synced with Trakt'; // If we have specific Trakt progress that differs from local, mention it - if (watchProgress.traktProgress !== undefined && - Math.abs(progressPercent - watchProgress.traktProgress) > 5) { + if (watchProgress.traktProgress !== undefined && + Math.abs(progressPercent - watchProgress.traktProgress) > 5) { displayText = `${Math.round(progressPercent)}% watched (${Math.round(watchProgress.traktProgress)}% on Trakt)`; } } else { @@ -638,7 +638,7 @@ const WatchProgressDisplay = memo(({ progressBoxOpacity.value = withTiming(1, { duration: 400 }); progressBoxScale.value = withTiming(1, { duration: 400 }); progressBoxTranslateY.value = withTiming(0, { duration: 400 }); - + if (progressData.isWatched || (progressData.progressPercent && progressData.progressPercent >= 85)) { // Celebration animation sequence celebrationScale.value = withRepeat( @@ -646,7 +646,7 @@ const WatchProgressDisplay = memo(({ 2, true ); - + // Glow effect completionGlow.value = withRepeat( withTiming(1, { duration: 1500 }), @@ -712,34 +712,34 @@ const WatchProgressDisplay = memo(({ ) : ( )} - + {/* Enhanced progress bar with glow effects */} - + {/* Background glow for completed content */} {isCompleted && ( )} - - - + } + ]} + /> + {/* Shimmer effect for active progress */} {!isCompleted && progressData.progressPercent > 0 && ( @@ -768,46 +768,46 @@ const WatchProgressDisplay = memo(({ {progressData.episodeInfo} )} - + {/* Trakt sync status with enhanced styling */} {progressData.syncStatus && ( - - {progressData.syncStatus} - - + {progressData.syncStatus} + + {/* Enhanced manual Trakt sync button - moved inline */} - {isTraktAuthenticated && forceSyncTraktProgress && ( - - - - + > + + + - + )} - )} - + )} + ); @@ -896,12 +896,12 @@ const HeroSection: React.FC = memo(({ // Guards to avoid repeated auto-starts const startedOnFocusRef = useRef(false); const startedOnReadyRef = useRef(false); - + // Animation values for trailer unmute effects const actionButtonsOpacity = useSharedValue(1); const titleCardTranslateY = useSharedValue(0); const genreOpacity = useSharedValue(1); - + // Ultra-optimized theme colors with stable references const themeColors = useMemo(() => ({ black: currentTheme.colors.black, @@ -932,7 +932,7 @@ const HeroSection: React.FC = memo(({ setTrailerPreloaded(true); } setTrailerReady(true); - + // Smooth transition: fade out thumbnail, fade in trailer thumbnailOpacity.value = withTiming(0, { duration: 500 }); trailerOpacity.value = withTiming(1, { duration: 500 }); @@ -948,7 +948,7 @@ const HeroSection: React.FC = memo(({ try { const y = (scrollY as any).value || 0; const pauseThreshold = heroHeight.value * 0.7; - + if (y < pauseThreshold) { startedOnReadyRef.current = true; logger.info('HeroSection', 'Trailer ready - auto-starting playback'); @@ -987,7 +987,7 @@ const HeroSection: React.FC = memo(({ setTrailerError(true); setTrailerReady(false); setTrailerPlaying(false); - + // Fade back to thumbnail trailerOpacity.value = withTiming(0, { duration: 300 }); thumbnailOpacity.value = withTiming(1, { duration: 300 }); @@ -997,11 +997,11 @@ const HeroSection: React.FC = memo(({ const handleTrailerEnd = useCallback(async () => { logger.info('HeroSection', 'Trailer ended - transitioning back to thumbnail'); setTrailerPlaying(false); - + // Reset trailer state to prevent auto-restart setTrailerReady(false); setTrailerPreloaded(false); - + // If trailer is in fullscreen, dismiss it first try { if (trailerVideoRef.current) { @@ -1011,22 +1011,22 @@ const HeroSection: React.FC = memo(({ } catch (error) { logger.warn('HeroSection', 'Error dismissing fullscreen player:', error); } - + // Smooth fade transition: trailer out, thumbnail in trailerOpacity.value = withTiming(0, { duration: 500 }); thumbnailOpacity.value = withTiming(1, { duration: 500 }); - + // Show UI elements again actionButtonsOpacity.value = withTiming(1, { duration: 500 }); genreOpacity.value = withTiming(1, { duration: 500 }); titleCardTranslateY.value = withTiming(0, { duration: 500 }); watchProgressOpacity.value = withTiming(1, { duration: 500 }); }, [trailerOpacity, thumbnailOpacity, actionButtonsOpacity, genreOpacity, titleCardTranslateY, watchProgressOpacity, setTrailerPlaying]); - + // Memoized image source - const imageSource = useMemo(() => + const imageSource = useMemo(() => bannerImage || metadata.banner || metadata.poster - , [bannerImage, metadata.banner, metadata.poster]); + , [bannerImage, metadata.banner, metadata.poster]); // Use the logo provided by metadata (already enriched by useMetadataAssets based on settings) const logoUri = useMemo(() => { @@ -1048,13 +1048,13 @@ const HeroSection: React.FC = memo(({ useEffect(() => { // Check if metadata logo has actually changed from what we last processed const currentMetadataLogo = metadata?.logo; - + if (currentMetadataLogo !== lastSyncedLogoRef.current) { lastSyncedLogoRef.current = currentMetadataLogo; // Reset text fallback and timers on logo updates if (logoWaitTimerRef.current) { - try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {} + try { clearTimeout(logoWaitTimerRef.current); } catch (_e) { } logoWaitTimerRef.current = null; } @@ -1076,10 +1076,10 @@ const HeroSection: React.FC = memo(({ }, 600); } } - + return () => { if (logoWaitTimerRef.current) { - try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {} + try { clearTimeout(logoWaitTimerRef.current); } catch (_e) { } logoWaitTimerRef.current = null; } }; @@ -1109,7 +1109,7 @@ const HeroSection: React.FC = memo(({ } // If logo loaded successfully before, keep showing it even if it fails later }, [logoHasLoadedSuccessfully, stableLogoUri, metadata, logoLoadOpacity]); - + // Performance optimization: Lazy loading setup useEffect(() => { const timer = InteractionManager.runAfterInteractions(() => { @@ -1118,7 +1118,7 @@ const HeroSection: React.FC = memo(({ setShouldLoadSecondaryData(true); } }); - + return () => timer.cancel(); }, []); @@ -1128,25 +1128,25 @@ const HeroSection: React.FC = memo(({ let timerId: any = null; const fetchTrailer = async () => { if (!metadata?.name || !metadata?.year || !settings?.showTrailers || !isFocused) return; - + // If we expect TMDB ID but don't have it yet, wait a bit more if (!metadata?.tmdbId && metadata?.id?.startsWith('tmdb:')) { logger.info('HeroSection', `Waiting for TMDB ID for ${metadata.name}`); return; } - + setTrailerLoading(true); setTrailerError(false); setTrailerReady(false); setTrailerPreloaded(false); - + try { // Use requestIdleCallback or setTimeout to prevent blocking main thread const fetchWithDelay = () => { // Extract TMDB ID if available const tmdbIdString = tmdbId ? String(tmdbId) : undefined; const contentType = type === 'series' ? 'tv' : 'movie'; - + // Debug logging to see what we have logger.info('HeroSection', `Trailer request for ${metadata.name}:`, { hasTmdbId: !!tmdbId, @@ -1155,7 +1155,7 @@ const HeroSection: React.FC = memo(({ metadataKeys: Object.keys(metadata || {}), metadataId: metadata?.id }); - + TrailerService.getTrailerUrl(metadata.name, metadata.year, tmdbIdString, contentType) .then(url => { if (url) { @@ -1190,12 +1190,12 @@ const HeroSection: React.FC = memo(({ fetchTrailer(); return () => { alive = false; - try { if (timerId) clearTimeout(timerId); } catch (_e) {} + try { if (timerId) clearTimeout(timerId); } catch (_e) { } }; }, [metadata?.name, metadata?.year, tmdbId, settings?.showTrailers, isFocused]); // Shimmer animation removed - + // Optimized loading state reset when image source changes useEffect(() => { if (imageSource) { @@ -1203,19 +1203,19 @@ const HeroSection: React.FC = memo(({ imageLoadOpacity.value = 0; } }, [imageSource]); - + // Optimized image handlers with useCallback const handleImageError = useCallback(() => { if (!shouldLoadSecondaryData) return; - + runOnUI(() => { imageOpacity.value = withTiming(0.6, { duration: 150 }); imageLoadOpacity.value = withTiming(0, { duration: 150 }); })(); - + setImageError(true); setImageLoaded(false); - + // Three-level fallback: TMDB → addon banner → poster if (bannerImage !== metadata.banner && metadata.banner) { // Try addon banner if not already on it and it exists @@ -1231,7 +1231,7 @@ const HeroSection: React.FC = memo(({ imageOpacity.value = withTiming(1, { duration: 150 }); imageLoadOpacity.value = withTiming(1, { duration: 400 }); })(); - + setImageError(false); setImageLoaded(true); }, []); @@ -1245,10 +1245,10 @@ const HeroSection: React.FC = memo(({ const logoAnimatedStyle = useAnimatedStyle(() => { // Determine if progress bar should be shown const hasProgress = watchProgress && watchProgress.duration > 0; - + // Scale down logo when progress bar is present const logoScale = hasProgress ? 0.85 : 1; - + return { opacity: logoOpacity.value, transform: [ @@ -1271,22 +1271,22 @@ const HeroSection: React.FC = memo(({ const backdropImageStyle = useAnimatedStyle(() => { 'worklet'; const scrollYValue = scrollY.value; - + // Pre-calculated constants for better performance const DEFAULT_ZOOM = 1.1; const SCROLL_UP_MULTIPLIER = 0.002; const SCROLL_DOWN_MULTIPLIER = 0.0001; const MAX_SCALE = 1.4; const PARALLAX_FACTOR = 0.3; - + // Optimized scale calculation with minimal branching const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER; const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER; const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE); - + // Single parallax calculation const parallaxOffset = scrollYValue * PARALLAX_FACTOR; - + return { opacity: imageOpacity.value * imageLoadOpacity.value, transform: [ @@ -1299,7 +1299,7 @@ const HeroSection: React.FC = memo(({ // Simplified buttons animation const buttonsAnimatedStyle = useAnimatedStyle(() => ({ opacity: buttonsOpacity.value * actionButtonsOpacity.value, - transform: [{ + transform: [{ translateY: interpolate( buttonsTranslateY.value, [0, 20], @@ -1323,22 +1323,22 @@ const HeroSection: React.FC = memo(({ const trailerParallaxStyle = useAnimatedStyle(() => { 'worklet'; const scrollYValue = scrollY.value; - + // Pre-calculated constants for better performance const DEFAULT_ZOOM = 1.0; const SCROLL_UP_MULTIPLIER = 0.0015; const SCROLL_DOWN_MULTIPLIER = 0.0001; const MAX_SCALE = 1.25; const PARALLAX_FACTOR = 0.2; - + // Optimized scale calculation with minimal branching const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER; const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER; const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE); - + // Single parallax calculation const parallaxOffset = scrollYValue * PARALLAX_FACTOR; - + return { transform: [ { scale }, @@ -1394,14 +1394,14 @@ const HeroSection: React.FC = memo(({ // Calculate if content is watched (>=85% progress) - check both local and Trakt progress const isWatched = useMemo(() => { if (!watchProgress) return false; - + // Check Trakt progress first if available and user is authenticated if (isTraktAuthenticated && watchProgress.traktProgress !== undefined) { const traktWatched = watchProgress.traktProgress >= 95; // Removed excessive logging for Trakt progress return traktWatched; } - + // Fall back to local progress if (watchProgress.duration === 0) return false; const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; @@ -1457,7 +1457,7 @@ const HeroSection: React.FC = memo(({ } }, 50); } - + return () => { // Stop trailer when leaving this screen to prevent background playback/heat logger.info('HeroSection', 'Screen unfocused - stopping trailer playback'); @@ -1490,7 +1490,7 @@ const HeroSection: React.FC = memo(({ setTrailerUrl(null); trailerOpacity.value = 0; thumbnailOpacity.value = 1; - } catch (_e) {} + } catch (_e) { } } }, [isFocused, setTrailerPlaying]); @@ -1499,7 +1499,7 @@ const HeroSection: React.FC = memo(({ 'worklet'; try { if (!scrollGuardEnabledSV.value || isFocusedSV.value === 0) return; - + // Pre-calculate thresholds for better performance const pauseThreshold = heroHeight.value * 0.7; const resumeThreshold = heroHeight.value * 0.4; @@ -1528,7 +1528,7 @@ const HeroSection: React.FC = memo(({ // Don't stop trailer playback when component unmounts // Let the new hero section (if any) take control of trailer state // This prevents the trailer from stopping when navigating between screens - + // Reset animation values on unmount to prevent memory leaks try { imageOpacity.value = 1; @@ -1548,7 +1548,7 @@ const HeroSection: React.FC = memo(({ } catch (error) { logger.error('HeroSection', 'Error cleaning up animation values:', error); } - + interactionComplete.current = false; }; }, [imageOpacity, imageLoadOpacity, trailerOpacity, thumbnailOpacity, actionButtonsOpacity, titleCardTranslateY, genreOpacity, watchProgressOpacity, buttonsOpacity, buttonsTranslateY, logoOpacity, heroOpacity, heroHeight]); @@ -1574,46 +1574,46 @@ const HeroSection: React.FC = memo(({ {/* Optimized Background */} - - {/* Shimmer loading effect removed */} - - {/* Background thumbnail image - always rendered when available with parallax */} - {shouldLoadSecondaryData && imageSource && !loadingBanner && ( - - - - )} - - {/* Hidden preload trailer player - loads in background */} - {shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && !trailerPreloaded && ( - - - - )} - - {/* Visible trailer player - rendered on top with fade transition and parallax */} - {shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && trailerPreloaded && ( - - + + + )} + + {/* Hidden preload trailer player - loads in background */} + {shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && !trailerPreloaded && ( + + + + )} + + {/* Visible trailer player - rendered on top with fade transition and parallax */} + {shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && trailerPreloaded && ( + + = memo(({ } }} /> - - )} + + )} - {/* Trailer control buttons (unmute and fullscreen) */} - {settings?.showTrailers && trailerReady && trailerUrl && ( - = 768 ? 32 : 16, - zIndex: 1000, - opacity: trailerOpacity, - flexDirection: 'row', - gap: 8, - }}> - {/* Fullscreen button */} - e.stopPropagation()} - onPressOut={(e) => e.stopPropagation()} - style={{ - padding: 8, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - borderRadius: 20, - }} - > - - + {/* Trailer control buttons (unmute and fullscreen) */} + {settings?.showTrailers && trailerReady && trailerUrl && ( + = 768 ? 32 : 16, + zIndex: 1000, + opacity: trailerOpacity, + flexDirection: 'row', + gap: 8, + }}> + {/* Fullscreen button */} + e.stopPropagation()} + onPressOut={(e) => e.stopPropagation()} + style={{ + padding: 8, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + borderRadius: 20, + }} + > + + - {/* Unmute button */} - { - logger.info('HeroSection', 'Mute toggle button pressed, current muted state:', trailerMuted); - updateSetting('trailerMuted', !trailerMuted); - if (trailerMuted) { - // When unmuting, hide action buttons, genre, title card, and watch progress - actionButtonsOpacity.value = withTiming(0, { duration: 300 }); - genreOpacity.value = withTiming(0, { duration: 300 }); - titleCardTranslateY.value = withTiming(100, { duration: 300 }); // Increased from 60 to 120 for further down movement - watchProgressOpacity.value = withTiming(0, { duration: 300 }); - } else { - // When muting, show action buttons, genre, title card, and watch progress - actionButtonsOpacity.value = withTiming(1, { duration: 300 }); - genreOpacity.value = withTiming(1, { duration: 300 }); - titleCardTranslateY.value = withTiming(0, { duration: 300 }); - watchProgressOpacity.value = withTiming(1, { duration: 300 }); - } - }} - activeOpacity={0.7} - onPressIn={(e) => e.stopPropagation()} - onPressOut={(e) => e.stopPropagation()} - style={{ - padding: 8, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - borderRadius: 20, - }} - > - - + {/* Unmute button */} + { + logger.info('HeroSection', 'Mute toggle button pressed, current muted state:', trailerMuted); + updateSetting('trailerMuted', !trailerMuted); + if (trailerMuted) { + // When unmuting, hide action buttons, genre, title card, and watch progress + actionButtonsOpacity.value = withTiming(0, { duration: 300 }); + genreOpacity.value = withTiming(0, { duration: 300 }); + titleCardTranslateY.value = withTiming(100, { duration: 300 }); // Increased from 60 to 120 for further down movement + watchProgressOpacity.value = withTiming(0, { duration: 300 }); + } else { + // When muting, show action buttons, genre, title card, and watch progress + actionButtonsOpacity.value = withTiming(1, { duration: 300 }); + genreOpacity.value = withTiming(1, { duration: 300 }); + titleCardTranslateY.value = withTiming(0, { duration: 300 }); + watchProgressOpacity.value = withTiming(1, { duration: 300 }); + } + }} + activeOpacity={0.7} + onPressIn={(e) => e.stopPropagation()} + onPressOut={(e) => e.stopPropagation()} + style={{ + padding: 8, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + borderRadius: 20, + }} + > + + - {/* AI Chat button */} - {settings?.aiChatEnabled && ( + {/* AI Chat button */} + {settings?.aiChatEnabled && ( + { + // Extract episode info if it's a series + let episodeData = null; + if (type === 'series' && watchProgress && watchProgress.episodeId) { + const parts = watchProgress.episodeId.split(':'); + if (parts.length >= 3) { + episodeData = { + seasonNumber: parseInt(parts[1], 10), + episodeNumber: parseInt(parts[2], 10) + }; + } + } + + navigation.navigate('AIChat', { + contentId: id, + contentType: type, + episodeId: episodeData && watchProgress ? watchProgress.episodeId : undefined, + seasonNumber: episodeData?.seasonNumber, + episodeNumber: episodeData?.episodeNumber, + title: metadata?.name || metadata?.title || 'Unknown' + }); + }} + activeOpacity={0.7} + onPressIn={(e) => e.stopPropagation()} + onPressOut={(e) => e.stopPropagation()} + style={{ + padding: 8, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + borderRadius: 20, + }} + > + + + )} + + )} + + {/* AI Chat button (when trailers are disabled) */} + {settings?.aiChatEnabled && !(settings?.showTrailers && trailerReady && trailerUrl) && ( + = 768 ? 32 : 16, + zIndex: 1000, + }}> { // Extract episode info if it's a series @@ -1726,8 +1776,6 @@ const HeroSection: React.FC = memo(({ }); }} activeOpacity={0.7} - onPressIn={(e) => e.stopPropagation()} - onPressOut={(e) => e.stopPropagation()} style={{ padding: 8, backgroundColor: 'rgba(0, 0, 0, 0.5)', @@ -1740,164 +1788,116 @@ const HeroSection: React.FC = memo(({ color="white" /> - )} - - )} + + )} - {/* AI Chat button (when trailers are disabled) */} - {settings?.aiChatEnabled && !(settings?.showTrailers && trailerReady && trailerUrl) && ( - = 768 ? 32 : 16, - zIndex: 1000, - }}> - { - // Extract episode info if it's a series - let episodeData = null; - if (type === 'series' && watchProgress && watchProgress.episodeId) { - const parts = watchProgress.episodeId.split(':'); - if (parts.length >= 3) { - episodeData = { - seasonNumber: parseInt(parts[1], 10), - episodeNumber: parseInt(parts[2], 10) - }; - } - } - - navigation.navigate('AIChat', { - contentId: id, - contentType: type, - episodeId: episodeData && watchProgress ? watchProgress.episodeId : undefined, - seasonNumber: episodeData?.seasonNumber, - episodeNumber: episodeData?.episodeNumber, - title: metadata?.name || metadata?.title || 'Unknown' - }); - }} - activeOpacity={0.7} - style={{ - padding: 8, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - borderRadius: 20, - }} - > + + - )} - - - - - - - {/* Ultra-light Gradient with subtle dynamic background blend */} - - {/* Enhanced bottom fade with stronger gradient */} + {/* Ultra-light Gradient with subtle dynamic background blend */} - - {/* Optimized Title/Logo - Show logo immediately when available */} - - - {metadata?.logo ? ( - - ) : shouldShowTextFallback ? ( - - {metadata.name} - - ) : ( - // Reserve space to prevent layout jump while waiting briefly for logo - - )} - - - - {/* Enhanced Watch Progress with Trakt integration */} - + {/* Enhanced bottom fade with stronger gradient */} + - - {/* Optimized genre display with lazy loading; no fixed blank space */} - {shouldLoadSecondaryData && genreElements && ( - - {genreElements} + + {/* Optimized Title/Logo - Show logo immediately when available */} + + + {metadata?.logo ? ( + + ) : shouldShowTextFallback ? ( + + {metadata.name} + + ) : ( + // Reserve space to prevent layout jump while waiting briefly for logo + + )} + - )} + + {/* Enhanced Watch Progress with Trakt integration */} + + + {/* Optimized genre display with lazy loading; no fixed blank space */} + {shouldLoadSecondaryData && genreElements && ( + + {genreElements} + + )} - {/* Optimized Action Buttons */} - - - + {/* Optimized Action Buttons */} + + + ); @@ -2429,7 +2429,7 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - + // Tablet-specific styles tabletActionButtons: { flexDirection: 'column', @@ -2531,27 +2531,27 @@ const styles = StyleSheet.create({ alignSelf: 'center', }, tabletProgressGlassBackground: { - width: width * 0.7, - maxWidth: 700, - backgroundColor: 'rgba(255,255,255,0.08)', - borderRadius: 16, - padding: 12, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.1)', - overflow: 'hidden', - alignSelf: 'center', - }, - tabletWatchProgressMainText: { - fontSize: 14, - fontWeight: '600', - textAlign: 'center', - }, - tabletWatchProgressSubText: { - fontSize: 12, - textAlign: 'center', - opacity: 0.8, - marginBottom: 1, - }, + width: width * 0.7, + maxWidth: 700, + backgroundColor: 'rgba(255,255,255,0.08)', + borderRadius: 16, + padding: 12, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + overflow: 'hidden', + alignSelf: 'center', + }, + tabletWatchProgressMainText: { + fontSize: 14, + fontWeight: '600', + textAlign: 'center', + }, + tabletWatchProgressSubText: { + fontSize: 12, + textAlign: 'center', + opacity: 0.8, + marginBottom: 1, + }, }); export default HeroSection; diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index ac7192ee..b40428df 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -1569,6 +1569,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setScraperStatuses(initialStatuses); setActiveFetchingScrapers(initialActiveFetching); console.log('🔍 [loadStreams] Initialized activeFetchingScrapers:', initialActiveFetching); + + // If no scrapers are available, stop loading immediately + if (initialStatuses.length === 0) { + setLoadingStreams(false); + } } catch (error) { if (__DEV__) console.error('Failed to initialize scraper tracking:', error); } @@ -1701,6 +1706,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setScraperStatuses(initialStatuses); setActiveFetchingScrapers(initialActiveFetching); console.log('🔍 [loadEpisodeStreams] Initialized activeFetchingScrapers:', initialActiveFetching); + + // If no scrapers are available, stop loading immediately + if (initialStatuses.length === 0) { + setLoadingEpisodeStreams(false); + } } catch (error) { if (__DEV__) console.error('Failed to initialize episode scraper tracking:', error); } @@ -1715,6 +1725,37 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const { isCollection: detectedCollection, addon: collectionAddon } = stremioService.isCollectionContent(id); isCollection = detectedCollection; + + // Parse season and episode numbers robustly + let showIdStr = id; + let seasonNum = ''; + let episodeNum = ''; + + try { + // Handle various episode ID formats + // 1. Internal format: "series:showId:season:episode" + // 2. Stremio/IMDb format: "tt12345:1:1" + // 3. TMDB format: "tmdb:123:1:1" + + const cleanEpisodeId = episodeId.replace(/^series:/, ''); + const parts = cleanEpisodeId.split(':'); + + if (parts.length >= 3) { + episodeNum = parts.pop() || ''; + seasonNum = parts.pop() || ''; + showIdStr = parts.join(':'); + } else if (parts.length === 2) { + // Edge case: maybe just id:episode? unlikely but safe fallback + episodeNum = parts[1]; + seasonNum = '1'; // Default + showIdStr = parts[0]; + } + + if (__DEV__) console.log(`🔍 [loadEpisodeStreams] Parsed ID: show=${showIdStr}, s=${seasonNum}, e=${episodeNum}`); + } catch (e) { + if (__DEV__) console.warn('âš ī¸ [loadEpisodeStreams] Failed to parse episode ID:', episodeId); + } + if (isCollection && collectionAddon) { if (__DEV__) console.log(`đŸŽŦ [loadEpisodeStreams] Detected collection from addon: ${collectionAddon.name}, treating episodes as individual movies`); @@ -1728,9 +1769,15 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat stremioEpisodeId = episodeId; // Use the IMDb ID directly for Stremio addons if (__DEV__) console.log('✅ [loadEpisodeStreams] Collection movie - using IMDb ID:', episodeId, 'TMDB ID:', tmdbId); } else { - // Fallback: try to parse as TMDB ID - tmdbId = episodeId; - stremioEpisodeId = episodeId; + // Fallback: try to verify if it's a tmdb id + const isTmdb = episodeId.startsWith('tmdb:') || !isNaN(Number(episodeId)); + if (isTmdb) { + const cleanId = episodeId.replace('tmdb:', ''); + tmdbId = cleanId; + stremioEpisodeId = episodeId; + } else { + stremioEpisodeId = episodeId; + } if (__DEV__) console.log('âš ī¸ [loadEpisodeStreams] Collection movie - using episodeId as-is:', episodeId); } } else if (id.startsWith('tmdb:')) { @@ -1739,13 +1786,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Try to get IMDb ID from metadata first, then convert if needed if (metadata?.imdb_id) { - // Replace the series ID in episodeId with the IMDb ID - const [, season, episode] = episodeId.split(':'); - stremioEpisodeId = `${metadata.imdb_id}:${season}:${episode}`; + // Use format: imdb_id:season:episode + stremioEpisodeId = `${metadata.imdb_id}:${seasonNum}:${episodeNum}`; if (__DEV__) console.log('✅ [loadEpisodeStreams] Using IMDb ID from metadata for Stremio episode:', stremioEpisodeId); } else if (imdbId) { - const [, season, episode] = episodeId.split(':'); - stremioEpisodeId = `${imdbId}:${season}:${episode}`; + stremioEpisodeId = `${imdbId}:${seasonNum}:${episodeNum}`; if (__DEV__) console.log('✅ [loadEpisodeStreams] Using stored IMDb ID for Stremio episode:', stremioEpisodeId); } else { // Convert TMDB ID to IMDb ID for Stremio addons @@ -1753,14 +1798,17 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT); if (externalIds?.imdb_id) { - const [, season, episode] = episodeId.split(':'); - stremioEpisodeId = `${externalIds.imdb_id}:${season}:${episode}`; + stremioEpisodeId = `${externalIds.imdb_id}:${seasonNum}:${episodeNum}`; if (__DEV__) console.log('✅ [loadEpisodeStreams] Converted TMDB to IMDb ID for Stremio episode:', stremioEpisodeId); } else { - if (__DEV__) console.log('âš ī¸ [loadEpisodeStreams] No IMDb ID found for TMDB ID, using original episode ID:', stremioEpisodeId); + // Fallback to TMDB format if conversions fail + // e.g. tmdb:123:1:1 + stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`; + if (__DEV__) console.log('âš ī¸ [loadEpisodeStreams] No IMDb ID found for TMDB ID, using TMDB episode ID:', stremioEpisodeId); } } catch (error) { - if (__DEV__) console.log('âš ī¸ [loadEpisodeStreams] Failed to convert TMDB to IMDb, using original episode ID:', error); + stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`; + if (__DEV__) console.log('âš ī¸ [loadEpisodeStreams] Failed to convert TMDB to IMDb, using TMDB episode ID:', error); } } } else if (id.startsWith('tt')) { @@ -1772,20 +1820,18 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (__DEV__) console.log('📝 [loadEpisodeStreams] TMDB enrichment disabled, skipping IMDB to TMDB conversion'); } if (__DEV__) console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId); - // Normalize episode id to 'tt:season:episode' format for addons that expect tt prefix - const parts = episodeId.split(':'); - if (parts.length === 3 && parts[0] === 'series') { - stremioEpisodeId = `${id}:${parts[1]}:${parts[2]}`; - if (__DEV__) console.log('🔧 [loadEpisodeStreams] Normalized episode ID for addons:', stremioEpisodeId); - } + + // Ensure consistent format + stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`; + if (__DEV__) console.log('🔧 [loadEpisodeStreams] Normalized episode ID for addons:', stremioEpisodeId); } else { tmdbId = id; + stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`; if (__DEV__) console.log('â„šī¸ [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId); } // Extract episode info from the episodeId for logging - const [, season, episode] = episodeId.split(':'); - const episodeQuery = `?s=${season}&e=${episode}`; + const episodeQuery = `?s=${seasonNum}&e=${episodeNum}`; if (__DEV__) console.log(`â„šī¸ [loadEpisodeStreams] Episode query: ${episodeQuery}`); if (__DEV__) console.log('🔄 [loadEpisodeStreams] Starting stream requests'); diff --git a/src/screens/streams/useStreamsScreen.ts b/src/screens/streams/useStreamsScreen.ts index 7ab79bd0..c1b05897 100644 --- a/src/screens/streams/useStreamsScreen.ts +++ b/src/screens/streams/useStreamsScreen.ts @@ -57,7 +57,7 @@ export const useStreamsScreen = () => { // Dimension tracking const [dimensions, setDimensions] = useState(Dimensions.get('window')); const prevDimensionsRef = useRef({ width: dimensions.width, height: dimensions.height }); - + const deviceWidth = dimensions.width; const isTablet = useMemo(() => deviceWidth >= TABLET_BREAKPOINT, [deviceWidth]); @@ -119,7 +119,7 @@ export const useStreamsScreen = () => { } = useMetadata({ id, type }); // Get banner image - const setMetadataStub = useCallback(() => {}, []); + const setMetadataStub = useCallback(() => { }, []); const memoizedSettings = useMemo( () => settings, [settings.logoSourcePreference, settings.tmdbLanguagePreference, settings.enrichMetadataWithTMDB] @@ -183,7 +183,7 @@ export const useStreamsScreen = () => { try { setAlertTitle(title); setAlertMessage(message); - setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); + setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]); setAlertVisible(true); } catch (error) { console.warn('[StreamsScreen] Error showing alert:', error); @@ -390,7 +390,7 @@ export const useStreamsScreen = () => { if (!videoType && /xprime/i.test(providerId)) { videoType = 'm3u8'; } - } catch {} + } catch { } const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; @@ -438,7 +438,7 @@ export const useStreamsScreen = () => { /format=mkv\b/i.test(lowerUrl) || /container=mkv\b/i.test(lowerUrl); const isHttp = lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://'); - + if (!isMkvByPath && isHttp) { try { const mkvDetected = await Promise.race([ @@ -609,14 +609,14 @@ export const useStreamsScreen = () => { useEffect(() => { // Build a unique key for the current content const currentKey = `${id}:${type}:${episodeId || ''}`; - + // Reset refs if content changed if (lastLoadedIdRef.current !== currentKey) { hasDoneInitialLoadRef.current = false; isLoadingStreamsRef.current = false; lastLoadedIdRef.current = currentKey; } - + // Only proceed if we haven't done the initial load for this content if (hasDoneInitialLoadRef.current) return; @@ -803,18 +803,18 @@ export const useStreamsScreen = () => { const sortedEntries = filteredEntries.sort(([addonIdA], [addonIdB]) => { const isAddonA = installedAddons.some(addon => addon.id === addonIdA); const isAddonB = installedAddons.some(addon => addon.id === addonIdB); - + // Addons always come before plugins if (isAddonA && !isAddonB) return -1; if (!isAddonA && isAddonB) return 1; - + // Both are addons - sort by installation order if (isAddonA && isAddonB) { const indexA = installedAddons.findIndex(addon => addon.id === addonIdA); const indexB = installedAddons.findIndex(addon => addon.id === addonIdB); return indexA - indexB; } - + // Both are plugins - sort by response order const responseIndexA = addonResponseOrder.indexOf(addonIdA); const responseIndexB = addonResponseOrder.indexOf(addonIdB); @@ -1021,8 +1021,9 @@ export const useStreamsScreen = () => { Object.keys(streams).length === 0 || Object.values(streams).every(provider => !provider.streams || provider.streams.length === 0); const loadElapsed = streamsLoadStart ? Date.now() - streamsLoadStart : 0; - const showInitialLoading = streamsEmpty && (streamsLoadStart === null || loadElapsed < 10000); - const showStillFetching = streamsEmpty && loadElapsed >= 10000; + const isActuallyLoading = isLoading || activeFetchingScrapers.length > 0; + const showInitialLoading = streamsEmpty && isActuallyLoading && (streamsLoadStart === null || loadElapsed < 10000); + const showStillFetching = streamsEmpty && isActuallyLoading && loadElapsed >= 10000; return { // Route params @@ -1031,19 +1032,19 @@ export const useStreamsScreen = () => { episodeId, episodeThumbnail, fromPlayer, - + // Theme currentTheme, colors, settings, - + // Navigation navigation, handleBack, - + // Tablet isTablet, - + // Alert alertVisible, alertTitle, @@ -1051,14 +1052,14 @@ export const useStreamsScreen = () => { alertActions, openAlert, closeAlert, - + // Metadata metadata, imdbId, bannerImage, currentEpisode, groupedEpisodes, - + // Streams streams, groupedStreams, @@ -1068,7 +1069,7 @@ export const useStreamsScreen = () => { selectedProvider, handleProviderChange, handleStreamPress, - + // Loading states isLoading, loadingStreams, @@ -1079,19 +1080,19 @@ export const useStreamsScreen = () => { showStillFetching, showNoSourcesError, hasStremioStreamProviders, - + // Autoplay isAutoplayWaiting, autoplayTriggered, - + // Scrapers activeFetchingScrapers, scraperLogos, - + // Movie movieLogoError, setMovieLogoError, - + // Episode episodeImage, effectiveEpisodeVote, @@ -1099,7 +1100,7 @@ export const useStreamsScreen = () => { hasIMDbRating, tmdbEpisodeOverride, selectedEpisode, - + // Backdrop mobileBackdropSource, gradientColors,