diff --git a/src/components/home/HeroCarousel.tsx b/src/components/home/HeroCarousel.tsx new file mode 100644 index 00000000..ae5a08b5 --- /dev/null +++ b/src/components/home/HeroCarousel.tsx @@ -0,0 +1,187 @@ +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet, Dimensions, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, FlatList, StyleProp } from 'react-native'; +import Animated, { FadeIn, Easing } from 'react-native-reanimated'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Image as ExpoImage } from 'expo-image'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useNavigation } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import { StreamingContent } from '../../services/catalogService'; +import { useTheme } from '../../contexts/ThemeContext'; + +interface HeroCarouselProps { + items: StreamingContent[]; +} + +const { width } = Dimensions.get('window'); + +const CARD_WIDTH = Math.min(width * 0.88, 520); +const CARD_HEIGHT = Math.round(CARD_WIDTH * 9 / 16) + 160; // increased extra space for text/actions + +const HeroCarousel: React.FC = ({ items }) => { + const navigation = useNavigation>(); + const { currentTheme } = useTheme(); + + const data = useMemo(() => (items && items.length ? items.slice(0, 10) : []), [items]); + + if (data.length === 0) { + return null; + } + + return ( + + + item.id} + horizontal + showsHorizontalScrollIndicator={false} + snapToInterval={CARD_WIDTH + 16} + decelerationRate="fast" + contentContainerStyle={{ paddingHorizontal: (width - CARD_WIDTH) / 2 }} + renderItem={({ item }) => ( + + navigation.navigate('Metadata', { id: item.id, type: item.type })} + > + }> + + + + + + + {item.name} + + {item.genres && ( + + {item.genres.slice(0, 3).join(' • ')} + + )} + + navigation.navigate('Streams', { id: item.id, type: item.type })} + activeOpacity={0.85} + > + + Play + + navigation.navigate('Metadata', { id: item.id, type: item.type })} + activeOpacity={0.8} + > + + Info + + + + + + + )} + /> + + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingVertical: 12, + }, + card: { + width: CARD_WIDTH, + height: CARD_HEIGHT, + borderRadius: 16, + overflow: 'hidden', + elevation: 6, + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.3, + shadowRadius: 12, + }, + bannerContainer: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + banner: { + width: '100%', + height: '100%', + }, + bannerGradient: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + top: 0, + }, + info: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingHorizontal: 16, + paddingTop: 10, + paddingBottom: 12, + }, + title: { + fontSize: 18, + fontWeight: '800', + }, + genres: { + marginTop: 2, + fontSize: 13, + }, + actions: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + marginTop: 12, + }, + playButton: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 24, + }, + playText: { + fontWeight: '700', + marginLeft: 6, + fontSize: 14, + }, + secondaryButton: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 14, + paddingVertical: 9, + borderRadius: 22, + borderWidth: 1, + }, + secondaryText: { + fontWeight: '600', + marginLeft: 6, + fontSize: 14, + }, +}); + +export default React.memo(HeroCarousel); + + diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index e9a63523..5c8e2ad5 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -9,6 +9,7 @@ import { InteractionManager, AppState, } from 'react-native'; +import { useFocusEffect } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; @@ -1017,11 +1018,28 @@ const HeroSection: React.FC = memo(({ const subscription = AppState.addEventListener('change', handleAppStateChange); return () => subscription?.remove(); - }, []); + }, [setTrailerPlaying]); + + // Navigation focus effect to stop trailer when navigating away + useFocusEffect( + useCallback(() => { + // Screen is focused + logger.info('HeroSection', 'Screen focused'); + + return () => { + // Screen is unfocused - stop trailer playback + logger.info('HeroSection', 'Screen unfocused - stopping trailer'); + setTrailerPlaying(false); + }; + }, [setTrailerPlaying]) + ); // Memory management and cleanup useEffect(() => { return () => { + // Stop trailer playback when component unmounts + setTrailerPlaying(false); + // Reset animation values on unmount to prevent memory leaks try { imageOpacity.value = 1; @@ -1044,7 +1062,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, shimmerOpacity, trailerOpacity, thumbnailOpacity, actionButtonsOpacity, titleCardTranslateY, genreOpacity, watchProgressOpacity, buttonsOpacity, buttonsTranslateY, logoOpacity, heroOpacity, heroHeight, setTrailerPlaying]); // Development-only performance monitoring useEffect(() => { @@ -1100,6 +1118,7 @@ const HeroSection: React.FC = memo(({ {shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && !trailerPreloaded && ( = memo(({ opacity: trailerOpacity }]}> (({ const [duration, setDuration] = useState(0); const [position, setPosition] = useState(0); const [isFullscreen, setIsFullscreen] = useState(false); + const [isComponentMounted, setIsComponentMounted] = useState(true); // Animated values const controlsOpacity = useSharedValue(0); @@ -71,8 +74,65 @@ const TrailerPlayer = React.forwardRef(({ // Auto-hide controls after 3 seconds const hideControlsTimeout = useRef(null); + const appState = useRef(AppState.currentState); + + // Cleanup function to stop video and reset state + const cleanupVideo = useCallback(() => { + try { + if (videoRef.current) { + // Pause the video + setIsPlaying(false); + + // Seek to beginning to stop any background processing + videoRef.current.seek(0); + + // Clear any pending timeouts + if (hideControlsTimeout.current) { + clearTimeout(hideControlsTimeout.current); + hideControlsTimeout.current = null; + } + + logger.info('TrailerPlayer', 'Video cleanup completed'); + } + } catch (error) { + logger.error('TrailerPlayer', 'Error during video cleanup:', error); + } + }, []); + + // Handle app state changes to pause video when app goes to background + useEffect(() => { + const handleAppStateChange = (nextAppState: AppStateStatus) => { + if (appState.current === 'active' && nextAppState.match(/inactive|background/)) { + // App going to background - pause video + logger.info('TrailerPlayer', 'App going to background - pausing video'); + setIsPlaying(false); + } else if (appState.current.match(/inactive|background/) && nextAppState === 'active') { + // App coming to foreground - resume if it was playing + logger.info('TrailerPlayer', 'App coming to foreground'); + if (autoPlay && isComponentMounted) { + setIsPlaying(true); + } + } + appState.current = nextAppState; + }; + + const subscription = AppState.addEventListener('change', handleAppStateChange); + return () => subscription?.remove(); + }, [autoPlay, isComponentMounted]); + + // Component mount/unmount tracking + useEffect(() => { + setIsComponentMounted(true); + + return () => { + setIsComponentMounted(false); + cleanupVideo(); + }; + }, [cleanupVideo]); const showControlsWithTimeout = useCallback(() => { + if (!isComponentMounted) return; + setShowControls(true); controlsOpacity.value = withTiming(1, { duration: 200 }); @@ -83,12 +143,16 @@ const TrailerPlayer = React.forwardRef(({ // Set new timeout to hide controls hideControlsTimeout.current = setTimeout(() => { - setShowControls(false); - controlsOpacity.value = withTiming(0, { duration: 200 }); + if (isComponentMounted) { + setShowControls(false); + controlsOpacity.value = withTiming(0, { duration: 200 }); + } }, 3000); - }, [controlsOpacity]); + }, [controlsOpacity, isComponentMounted]); const handleVideoPress = useCallback(() => { + if (!isComponentMounted) return; + if (showControls) { // If controls are visible, toggle play/pause handlePlayPause(); @@ -96,14 +160,16 @@ const TrailerPlayer = React.forwardRef(({ // If controls are hidden, show them showControlsWithTimeout(); } - }, [showControls, showControlsWithTimeout]); + }, [showControls, showControlsWithTimeout, isComponentMounted]); const handlePlayPause = useCallback(async () => { try { - if (!videoRef.current) return; + if (!videoRef.current || !isComponentMounted) return; playButtonScale.value = withTiming(0.8, { duration: 100 }, () => { - playButtonScale.value = withTiming(1, { duration: 100 }); + if (isComponentMounted) { + playButtonScale.value = withTiming(1, { duration: 100 }); + } }); setIsPlaying(!isPlaying); @@ -112,46 +178,54 @@ const TrailerPlayer = React.forwardRef(({ } catch (error) { logger.error('TrailerPlayer', 'Error toggling playback:', error); } - }, [isPlaying, playButtonScale, showControlsWithTimeout]); + }, [isPlaying, playButtonScale, showControlsWithTimeout, isComponentMounted]); const handleMuteToggle = useCallback(async () => { try { - if (!videoRef.current) return; + if (!videoRef.current || !isComponentMounted) return; setIsMuted(!isMuted); showControlsWithTimeout(); } catch (error) { logger.error('TrailerPlayer', 'Error toggling mute:', error); } - }, [isMuted, showControlsWithTimeout]); + }, [isMuted, showControlsWithTimeout, isComponentMounted]); const handleLoadStart = useCallback(() => { + if (!isComponentMounted) return; + setIsLoading(true); setHasError(false); // Only show loading spinner if not hidden loadingOpacity.value = hideLoadingSpinner ? 0 : 1; onLoadStart?.(); logger.info('TrailerPlayer', 'Video load started'); - }, [loadingOpacity, onLoadStart, hideLoadingSpinner]); + }, [loadingOpacity, onLoadStart, hideLoadingSpinner, isComponentMounted]); const handleLoad = useCallback((data: OnLoadData) => { + if (!isComponentMounted) return; + setIsLoading(false); loadingOpacity.value = withTiming(0, { duration: 300 }); setDuration(data.duration * 1000); // Convert to milliseconds onLoad?.(); logger.info('TrailerPlayer', 'Video loaded successfully'); - }, [loadingOpacity, onLoad]); + }, [loadingOpacity, onLoad, isComponentMounted]); const handleError = useCallback((error: any) => { + if (!isComponentMounted) return; + setIsLoading(false); setHasError(true); loadingOpacity.value = withTiming(0, { duration: 300 }); const message = typeof error === 'string' ? error : (error?.errorString || error?.error?.string || error?.error?.message || JSON.stringify(error)); onError?.(message); logger.error('TrailerPlayer', 'Video error details:', error); - }, [loadingOpacity, onError]); + }, [loadingOpacity, onError, isComponentMounted]); const handleProgress = useCallback((data: OnProgressData) => { + if (!isComponentMounted) return; + setPosition(data.currentTime * 1000); // Convert to milliseconds onProgress?.(data); @@ -161,24 +235,30 @@ const TrailerPlayer = React.forwardRef(({ didJustFinish: false }); } - }, [onProgress, onPlaybackStatusUpdate]); + }, [onProgress, onPlaybackStatusUpdate, isComponentMounted]); // Sync internal muted state with prop useEffect(() => { - setIsMuted(muted); - }, [muted]); + if (isComponentMounted) { + setIsMuted(muted); + } + }, [muted, isComponentMounted]); // Sync internal playing state with autoPlay prop useEffect(() => { - setIsPlaying(autoPlay); - }, [autoPlay]); + if (isComponentMounted) { + setIsPlaying(autoPlay); + } + }, [autoPlay, isComponentMounted]); - // Cleanup timeout on unmount + // Cleanup timeout and animated values on unmount useEffect(() => { return () => { if (hideControlsTimeout.current) { clearTimeout(hideControlsTimeout.current); + hideControlsTimeout.current = null; } + // Reset all animated values to prevent memory leaks try { controlsOpacity.value = 0; @@ -187,13 +267,16 @@ const TrailerPlayer = React.forwardRef(({ } catch (error) { logger.error('TrailerPlayer', 'Error cleaning up animation values:', error); } + + // Ensure video is stopped + cleanupVideo(); }; - }, [controlsOpacity, loadingOpacity, playButtonScale]); + }, [controlsOpacity, loadingOpacity, playButtonScale, cleanupVideo]); // Forward the ref to the video element React.useImperativeHandle(ref, () => ({ presentFullscreenPlayer: () => { - if (videoRef.current) { + if (videoRef.current && isComponentMounted) { return videoRef.current.presentFullscreenPlayer(); } } @@ -265,8 +348,8 @@ const TrailerPlayer = React.forwardRef(({ onProgress={handleProgress} controls={false} onEnd={() => { - // Only loop if still considered playing - if (isPlaying) { + // Only loop if still considered playing and component is mounted + if (isPlaying && isComponentMounted) { videoRef.current?.seek(0); } }} diff --git a/src/hooks/useFeaturedContent.ts b/src/hooks/useFeaturedContent.ts index 2f4fe198..a3c03ba9 100644 --- a/src/hooks/useFeaturedContent.ts +++ b/src/hooks/useFeaturedContent.ts @@ -428,6 +428,7 @@ export function useFeaturedContent() { return { featuredContent, + allFeaturedContent, loading, isSaved, handleSaveToLibrary, diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 7b2b541e..1b8e27cf 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -39,6 +39,7 @@ export interface AppSettings { preferredPlayer: 'internal' | 'vlc' | 'infuse' | 'outplayer' | 'vidhub' | 'external'; showHeroSection: boolean; featuredContentSource: 'tmdb' | 'catalogs'; + heroStyle: 'legacy' | 'carousel'; selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code) @@ -80,6 +81,7 @@ export const DEFAULT_SETTINGS: AppSettings = { preferredPlayer: 'internal', showHeroSection: true, featuredContentSource: 'catalogs', + heroStyle: 'legacy', selectedHeroCatalogs: [], // Empty array means all catalogs are selected logoSourcePreference: 'metahub', // Default to Metahub as first source tmdbLanguagePreference: 'en', // Default to English diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 2687fa0c..14b6635d 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -46,6 +46,7 @@ import { useHomeCatalogs } from '../hooks/useHomeCatalogs'; import { useFeaturedContent } from '../hooks/useFeaturedContent'; import { useSettings, settingsEmitter } from '../hooks/useSettings'; import FeaturedContent from '../components/home/FeaturedContent'; +import HeroCarousel from '../components/home/HeroCarousel'; import CatalogSection from '../components/home/CatalogSection'; import { SkeletonFeatured } from '../components/home/SkeletonLoaders'; import LoadingSpinner from '../components/common/LoadingSpinner'; @@ -125,6 +126,7 @@ const HomeScreen = () => { const { featuredContent, + allFeaturedContent, loading: featuredLoading, isSaved, handleSaveToLibrary, @@ -606,14 +608,21 @@ const HomeScreen = () => { // Memoize individual section components to prevent re-renders const memoizedFeaturedContent = useMemo(() => ( - - ), [showHeroSection, featuredContentSource, featuredContent, isSaved, handleSaveToLibrary]); + settings.heroStyle === 'carousel' ? ( + + ) : ( + + ) + ), [settings.heroStyle, showHeroSection, featuredContentSource, featuredContent, allFeaturedContent, isSaved, handleSaveToLibrary]); const memoizedThisWeekSection = useMemo(() => , []); const memoizedContinueWatchingSection = useMemo(() => , []); diff --git a/src/screens/HomeScreenSettings.tsx b/src/screens/HomeScreenSettings.tsx index dc07580e..4edd9439 100644 --- a/src/screens/HomeScreenSettings.tsx +++ b/src/screens/HomeScreenSettings.tsx @@ -274,6 +274,31 @@ const HomeScreenSettings: React.FC = () => { {settings.showHeroSection && ( <> + + handleUpdateSetting('heroStyle', 'legacy')} + label="Legacy Hero (banner)" + /> + + + Original full-width banner with overlayed info and actions. + + + + + + handleUpdateSetting('heroStyle', 'carousel')} + label="New Card Carousel" + /> + + + A beautiful, swipeable carousel of featured cards with smooth animations. + + +