new hero section option

This commit is contained in:
tapframe 2025-09-03 13:29:00 +05:30
parent dfb856f441
commit de36ec8186
7 changed files with 359 additions and 32 deletions

View file

@ -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<HeroCarouselProps> = ({ items }) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const data = useMemo(() => (items && items.length ? items.slice(0, 10) : []), [items]);
if (data.length === 0) {
return null;
}
return (
<Animated.View entering={FadeIn.duration(350).easing(Easing.out(Easing.cubic))}>
<View style={styles.container as ViewStyle}>
<FlatList
data={data}
keyExtractor={(item) => item.id}
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={CARD_WIDTH + 16}
decelerationRate="fast"
contentContainerStyle={{ paddingHorizontal: (width - CARD_WIDTH) / 2 }}
renderItem={({ item }) => (
<View style={{ width: CARD_WIDTH + 16 }}>
<TouchableOpacity
activeOpacity={0.9}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }] as StyleProp<ViewStyle>}>
<View style={styles.bannerContainer as ViewStyle}>
<ExpoImage
source={{ uri: item.banner || item.poster }}
style={styles.banner as ImageStyle}
contentFit="cover"
transition={300}
cachePolicy="memory-disk"
/>
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.7)"]}
locations={[0.55, 1]}
style={styles.bannerGradient as ViewStyle}
/>
</View>
<View style={styles.info as ViewStyle}>
<Text style={[styles.title as TextStyle, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1}>
{item.name}
</Text>
{item.genres && (
<Text style={[styles.genres as TextStyle, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={1}>
{item.genres.slice(0, 3).join(' • ')}
</Text>
)}
<View style={styles.actions as ViewStyle}>
<TouchableOpacity
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
onPress={() => navigation.navigate('Streams', { id: item.id, type: item.type })}
activeOpacity={0.85}
>
<MaterialIcons name="play-arrow" size={22} color={currentTheme.colors.black} />
<Text style={[styles.playText as TextStyle, { color: currentTheme.colors.black }]}>Play</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.secondaryButton as ViewStyle, { borderColor: 'rgba(255,255,255,0.25)' }]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
activeOpacity={0.8}
>
<MaterialIcons name="info-outline" size={18} color={currentTheme.colors.white} />
<Text style={[styles.secondaryText as TextStyle, { color: currentTheme.colors.white }]}>Info</Text>
</TouchableOpacity>
</View>
</View>
</View>
</TouchableOpacity>
</View>
)}
/>
</View>
</Animated.View>
);
};
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);

View file

@ -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<HeroSectionProps> = 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<HeroSectionProps> = 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<HeroSectionProps> = memo(({
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && !trailerPreloaded && (
<View style={[styles.absoluteFill, { opacity: 0, pointerEvents: 'none' }]}>
<TrailerPlayer
key={`preload-${trailerUrl}`}
trailerUrl={trailerUrl}
autoPlay={false}
muted={true}
@ -1117,6 +1136,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
opacity: trailerOpacity
}]}>
<TrailerPlayer
key={`visible-${trailerUrl}`}
ref={trailerVideoRef}
trailerUrl={trailerUrl}
autoPlay={globalTrailerPlaying}

View file

@ -6,6 +6,8 @@ import {
Dimensions,
Platform,
ActivityIndicator,
AppState,
AppStateStatus,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import Video, { VideoRef, OnLoadData, OnProgressData } from 'react-native-video';
@ -63,6 +65,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
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<any, TrailerPlayerProps>(({
// Auto-hide controls after 3 seconds
const hideControlsTimeout = useRef<NodeJS.Timeout | null>(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<any, TrailerPlayerProps>(({
// 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<any, TrailerPlayerProps>(({
// 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<any, TrailerPlayerProps>(({
} 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<any, TrailerPlayerProps>(({
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<any, TrailerPlayerProps>(({
} 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<any, TrailerPlayerProps>(({
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);
}
}}

View file

@ -428,6 +428,7 @@ export function useFeaturedContent() {
return {
featuredContent,
allFeaturedContent,
loading,
isSaved,
handleSaveToLibrary,

View file

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

View file

@ -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(() => (
<FeaturedContent
key={`featured-${showHeroSection}-${featuredContentSource}`}
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
loading={featuredLoading}
/>
), [showHeroSection, featuredContentSource, featuredContent, isSaved, handleSaveToLibrary]);
settings.heroStyle === 'carousel' ? (
<HeroCarousel
key={`carousel-${featuredContentSource}`}
items={allFeaturedContent || (featuredContent ? [featuredContent] : [])}
/>
) : (
<FeaturedContent
key={`featured-${showHeroSection}-${featuredContentSource}`}
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
loading={featuredLoading}
/>
)
), [settings.heroStyle, showHeroSection, featuredContentSource, featuredContent, allFeaturedContent, isSaved, handleSaveToLibrary]);
const memoizedThisWeekSection = useMemo(() => <ThisWeekSection />, []);
const memoizedContinueWatchingSection = useMemo(() => <ContinueWatchingSection ref={continueWatchingRef} />, []);

View file

@ -274,6 +274,31 @@ const HomeScreenSettings: React.FC = () => {
{settings.showHeroSection && (
<>
<View style={styles.radioCardContainer}>
<RadioOption
selected={settings.heroStyle === 'legacy'}
onPress={() => handleUpdateSetting('heroStyle', 'legacy')}
label="Legacy Hero (banner)"
/>
<View style={styles.radioDescription}>
<Text style={[styles.radioDescriptionText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
Original full-width banner with overlayed info and actions.
</Text>
</View>
</View>
<View style={styles.radioCardContainer}>
<RadioOption
selected={settings.heroStyle === 'carousel'}
onPress={() => handleUpdateSetting('heroStyle', 'carousel')}
label="New Card Carousel"
/>
<View style={styles.radioDescription}>
<Text style={[styles.radioDescriptionText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
A beautiful, swipeable carousel of featured cards with smooth animations.
</Text>
</View>
</View>
<View style={styles.radioCardContainer}>
<RadioOption
selected={settings.featuredContentSource === 'tmdb'}