mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-03 16:59:08 +00:00
new hero section option
This commit is contained in:
parent
dfb856f441
commit
de36ec8186
7 changed files with 359 additions and 32 deletions
187
src/components/home/HeroCarousel.tsx
Normal file
187
src/components/home/HeroCarousel.tsx
Normal 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);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
InteractionManager,
|
InteractionManager,
|
||||||
AppState,
|
AppState,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
|
@ -1017,11 +1018,28 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
|
|
||||||
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||||
return () => subscription?.remove();
|
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
|
// Memory management and cleanup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
// Stop trailer playback when component unmounts
|
||||||
|
setTrailerPlaying(false);
|
||||||
|
|
||||||
// Reset animation values on unmount to prevent memory leaks
|
// Reset animation values on unmount to prevent memory leaks
|
||||||
try {
|
try {
|
||||||
imageOpacity.value = 1;
|
imageOpacity.value = 1;
|
||||||
|
|
@ -1044,7 +1062,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
|
|
||||||
interactionComplete.current = false;
|
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
|
// Development-only performance monitoring
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1100,6 +1118,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && !trailerPreloaded && (
|
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && !trailerPreloaded && (
|
||||||
<View style={[styles.absoluteFill, { opacity: 0, pointerEvents: 'none' }]}>
|
<View style={[styles.absoluteFill, { opacity: 0, pointerEvents: 'none' }]}>
|
||||||
<TrailerPlayer
|
<TrailerPlayer
|
||||||
|
key={`preload-${trailerUrl}`}
|
||||||
trailerUrl={trailerUrl}
|
trailerUrl={trailerUrl}
|
||||||
autoPlay={false}
|
autoPlay={false}
|
||||||
muted={true}
|
muted={true}
|
||||||
|
|
@ -1117,6 +1136,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
opacity: trailerOpacity
|
opacity: trailerOpacity
|
||||||
}]}>
|
}]}>
|
||||||
<TrailerPlayer
|
<TrailerPlayer
|
||||||
|
key={`visible-${trailerUrl}`}
|
||||||
ref={trailerVideoRef}
|
ref={trailerVideoRef}
|
||||||
trailerUrl={trailerUrl}
|
trailerUrl={trailerUrl}
|
||||||
autoPlay={globalTrailerPlaying}
|
autoPlay={globalTrailerPlaying}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import {
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Platform,
|
Platform,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
AppState,
|
||||||
|
AppStateStatus,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import Video, { VideoRef, OnLoadData, OnProgressData } from 'react-native-video';
|
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 [duration, setDuration] = useState(0);
|
||||||
const [position, setPosition] = useState(0);
|
const [position, setPosition] = useState(0);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [isComponentMounted, setIsComponentMounted] = useState(true);
|
||||||
|
|
||||||
// Animated values
|
// Animated values
|
||||||
const controlsOpacity = useSharedValue(0);
|
const controlsOpacity = useSharedValue(0);
|
||||||
|
|
@ -71,8 +74,65 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
||||||
|
|
||||||
// Auto-hide controls after 3 seconds
|
// Auto-hide controls after 3 seconds
|
||||||
const hideControlsTimeout = useRef<NodeJS.Timeout | null>(null);
|
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(() => {
|
const showControlsWithTimeout = useCallback(() => {
|
||||||
|
if (!isComponentMounted) return;
|
||||||
|
|
||||||
setShowControls(true);
|
setShowControls(true);
|
||||||
controlsOpacity.value = withTiming(1, { duration: 200 });
|
controlsOpacity.value = withTiming(1, { duration: 200 });
|
||||||
|
|
||||||
|
|
@ -83,12 +143,16 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
||||||
|
|
||||||
// Set new timeout to hide controls
|
// Set new timeout to hide controls
|
||||||
hideControlsTimeout.current = setTimeout(() => {
|
hideControlsTimeout.current = setTimeout(() => {
|
||||||
setShowControls(false);
|
if (isComponentMounted) {
|
||||||
controlsOpacity.value = withTiming(0, { duration: 200 });
|
setShowControls(false);
|
||||||
|
controlsOpacity.value = withTiming(0, { duration: 200 });
|
||||||
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}, [controlsOpacity]);
|
}, [controlsOpacity, isComponentMounted]);
|
||||||
|
|
||||||
const handleVideoPress = useCallback(() => {
|
const handleVideoPress = useCallback(() => {
|
||||||
|
if (!isComponentMounted) return;
|
||||||
|
|
||||||
if (showControls) {
|
if (showControls) {
|
||||||
// If controls are visible, toggle play/pause
|
// If controls are visible, toggle play/pause
|
||||||
handlePlayPause();
|
handlePlayPause();
|
||||||
|
|
@ -96,14 +160,16 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
||||||
// If controls are hidden, show them
|
// If controls are hidden, show them
|
||||||
showControlsWithTimeout();
|
showControlsWithTimeout();
|
||||||
}
|
}
|
||||||
}, [showControls, showControlsWithTimeout]);
|
}, [showControls, showControlsWithTimeout, isComponentMounted]);
|
||||||
|
|
||||||
const handlePlayPause = useCallback(async () => {
|
const handlePlayPause = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!videoRef.current) return;
|
if (!videoRef.current || !isComponentMounted) return;
|
||||||
|
|
||||||
playButtonScale.value = withTiming(0.8, { duration: 100 }, () => {
|
playButtonScale.value = withTiming(0.8, { duration: 100 }, () => {
|
||||||
playButtonScale.value = withTiming(1, { duration: 100 });
|
if (isComponentMounted) {
|
||||||
|
playButtonScale.value = withTiming(1, { duration: 100 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
|
|
@ -112,46 +178,54 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('TrailerPlayer', 'Error toggling playback:', error);
|
logger.error('TrailerPlayer', 'Error toggling playback:', error);
|
||||||
}
|
}
|
||||||
}, [isPlaying, playButtonScale, showControlsWithTimeout]);
|
}, [isPlaying, playButtonScale, showControlsWithTimeout, isComponentMounted]);
|
||||||
|
|
||||||
const handleMuteToggle = useCallback(async () => {
|
const handleMuteToggle = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!videoRef.current) return;
|
if (!videoRef.current || !isComponentMounted) return;
|
||||||
|
|
||||||
setIsMuted(!isMuted);
|
setIsMuted(!isMuted);
|
||||||
showControlsWithTimeout();
|
showControlsWithTimeout();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('TrailerPlayer', 'Error toggling mute:', error);
|
logger.error('TrailerPlayer', 'Error toggling mute:', error);
|
||||||
}
|
}
|
||||||
}, [isMuted, showControlsWithTimeout]);
|
}, [isMuted, showControlsWithTimeout, isComponentMounted]);
|
||||||
|
|
||||||
const handleLoadStart = useCallback(() => {
|
const handleLoadStart = useCallback(() => {
|
||||||
|
if (!isComponentMounted) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setHasError(false);
|
setHasError(false);
|
||||||
// Only show loading spinner if not hidden
|
// Only show loading spinner if not hidden
|
||||||
loadingOpacity.value = hideLoadingSpinner ? 0 : 1;
|
loadingOpacity.value = hideLoadingSpinner ? 0 : 1;
|
||||||
onLoadStart?.();
|
onLoadStart?.();
|
||||||
logger.info('TrailerPlayer', 'Video load started');
|
logger.info('TrailerPlayer', 'Video load started');
|
||||||
}, [loadingOpacity, onLoadStart, hideLoadingSpinner]);
|
}, [loadingOpacity, onLoadStart, hideLoadingSpinner, isComponentMounted]);
|
||||||
|
|
||||||
const handleLoad = useCallback((data: OnLoadData) => {
|
const handleLoad = useCallback((data: OnLoadData) => {
|
||||||
|
if (!isComponentMounted) return;
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
loadingOpacity.value = withTiming(0, { duration: 300 });
|
loadingOpacity.value = withTiming(0, { duration: 300 });
|
||||||
setDuration(data.duration * 1000); // Convert to milliseconds
|
setDuration(data.duration * 1000); // Convert to milliseconds
|
||||||
onLoad?.();
|
onLoad?.();
|
||||||
logger.info('TrailerPlayer', 'Video loaded successfully');
|
logger.info('TrailerPlayer', 'Video loaded successfully');
|
||||||
}, [loadingOpacity, onLoad]);
|
}, [loadingOpacity, onLoad, isComponentMounted]);
|
||||||
|
|
||||||
const handleError = useCallback((error: any) => {
|
const handleError = useCallback((error: any) => {
|
||||||
|
if (!isComponentMounted) return;
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
loadingOpacity.value = withTiming(0, { duration: 300 });
|
loadingOpacity.value = withTiming(0, { duration: 300 });
|
||||||
const message = typeof error === 'string' ? error : (error?.errorString || error?.error?.string || error?.error?.message || JSON.stringify(error));
|
const message = typeof error === 'string' ? error : (error?.errorString || error?.error?.string || error?.error?.message || JSON.stringify(error));
|
||||||
onError?.(message);
|
onError?.(message);
|
||||||
logger.error('TrailerPlayer', 'Video error details:', error);
|
logger.error('TrailerPlayer', 'Video error details:', error);
|
||||||
}, [loadingOpacity, onError]);
|
}, [loadingOpacity, onError, isComponentMounted]);
|
||||||
|
|
||||||
const handleProgress = useCallback((data: OnProgressData) => {
|
const handleProgress = useCallback((data: OnProgressData) => {
|
||||||
|
if (!isComponentMounted) return;
|
||||||
|
|
||||||
setPosition(data.currentTime * 1000); // Convert to milliseconds
|
setPosition(data.currentTime * 1000); // Convert to milliseconds
|
||||||
onProgress?.(data);
|
onProgress?.(data);
|
||||||
|
|
||||||
|
|
@ -161,24 +235,30 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
||||||
didJustFinish: false
|
didJustFinish: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [onProgress, onPlaybackStatusUpdate]);
|
}, [onProgress, onPlaybackStatusUpdate, isComponentMounted]);
|
||||||
|
|
||||||
// Sync internal muted state with prop
|
// Sync internal muted state with prop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMuted(muted);
|
if (isComponentMounted) {
|
||||||
}, [muted]);
|
setIsMuted(muted);
|
||||||
|
}
|
||||||
|
}, [muted, isComponentMounted]);
|
||||||
|
|
||||||
// Sync internal playing state with autoPlay prop
|
// Sync internal playing state with autoPlay prop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsPlaying(autoPlay);
|
if (isComponentMounted) {
|
||||||
}, [autoPlay]);
|
setIsPlaying(autoPlay);
|
||||||
|
}
|
||||||
|
}, [autoPlay, isComponentMounted]);
|
||||||
|
|
||||||
// Cleanup timeout on unmount
|
// Cleanup timeout and animated values on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (hideControlsTimeout.current) {
|
if (hideControlsTimeout.current) {
|
||||||
clearTimeout(hideControlsTimeout.current);
|
clearTimeout(hideControlsTimeout.current);
|
||||||
|
hideControlsTimeout.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset all animated values to prevent memory leaks
|
// Reset all animated values to prevent memory leaks
|
||||||
try {
|
try {
|
||||||
controlsOpacity.value = 0;
|
controlsOpacity.value = 0;
|
||||||
|
|
@ -187,13 +267,16 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('TrailerPlayer', 'Error cleaning up animation values:', 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
|
// Forward the ref to the video element
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
presentFullscreenPlayer: () => {
|
presentFullscreenPlayer: () => {
|
||||||
if (videoRef.current) {
|
if (videoRef.current && isComponentMounted) {
|
||||||
return videoRef.current.presentFullscreenPlayer();
|
return videoRef.current.presentFullscreenPlayer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -265,8 +348,8 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
||||||
onProgress={handleProgress}
|
onProgress={handleProgress}
|
||||||
controls={false}
|
controls={false}
|
||||||
onEnd={() => {
|
onEnd={() => {
|
||||||
// Only loop if still considered playing
|
// Only loop if still considered playing and component is mounted
|
||||||
if (isPlaying) {
|
if (isPlaying && isComponentMounted) {
|
||||||
videoRef.current?.seek(0);
|
videoRef.current?.seek(0);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -428,6 +428,7 @@ export function useFeaturedContent() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
featuredContent,
|
featuredContent,
|
||||||
|
allFeaturedContent,
|
||||||
loading,
|
loading,
|
||||||
isSaved,
|
isSaved,
|
||||||
handleSaveToLibrary,
|
handleSaveToLibrary,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export interface AppSettings {
|
||||||
preferredPlayer: 'internal' | 'vlc' | 'infuse' | 'outplayer' | 'vidhub' | 'external';
|
preferredPlayer: 'internal' | 'vlc' | 'infuse' | 'outplayer' | 'vidhub' | 'external';
|
||||||
showHeroSection: boolean;
|
showHeroSection: boolean;
|
||||||
featuredContentSource: 'tmdb' | 'catalogs';
|
featuredContentSource: 'tmdb' | 'catalogs';
|
||||||
|
heroStyle: 'legacy' | 'carousel';
|
||||||
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
|
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
|
||||||
logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos
|
logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos
|
||||||
tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code)
|
tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code)
|
||||||
|
|
@ -80,6 +81,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
preferredPlayer: 'internal',
|
preferredPlayer: 'internal',
|
||||||
showHeroSection: true,
|
showHeroSection: true,
|
||||||
featuredContentSource: 'catalogs',
|
featuredContentSource: 'catalogs',
|
||||||
|
heroStyle: 'legacy',
|
||||||
selectedHeroCatalogs: [], // Empty array means all catalogs are selected
|
selectedHeroCatalogs: [], // Empty array means all catalogs are selected
|
||||||
logoSourcePreference: 'metahub', // Default to Metahub as first source
|
logoSourcePreference: 'metahub', // Default to Metahub as first source
|
||||||
tmdbLanguagePreference: 'en', // Default to English
|
tmdbLanguagePreference: 'en', // Default to English
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import { useHomeCatalogs } from '../hooks/useHomeCatalogs';
|
||||||
import { useFeaturedContent } from '../hooks/useFeaturedContent';
|
import { useFeaturedContent } from '../hooks/useFeaturedContent';
|
||||||
import { useSettings, settingsEmitter } from '../hooks/useSettings';
|
import { useSettings, settingsEmitter } from '../hooks/useSettings';
|
||||||
import FeaturedContent from '../components/home/FeaturedContent';
|
import FeaturedContent from '../components/home/FeaturedContent';
|
||||||
|
import HeroCarousel from '../components/home/HeroCarousel';
|
||||||
import CatalogSection from '../components/home/CatalogSection';
|
import CatalogSection from '../components/home/CatalogSection';
|
||||||
import { SkeletonFeatured } from '../components/home/SkeletonLoaders';
|
import { SkeletonFeatured } from '../components/home/SkeletonLoaders';
|
||||||
import LoadingSpinner from '../components/common/LoadingSpinner';
|
import LoadingSpinner from '../components/common/LoadingSpinner';
|
||||||
|
|
@ -125,6 +126,7 @@ const HomeScreen = () => {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
featuredContent,
|
featuredContent,
|
||||||
|
allFeaturedContent,
|
||||||
loading: featuredLoading,
|
loading: featuredLoading,
|
||||||
isSaved,
|
isSaved,
|
||||||
handleSaveToLibrary,
|
handleSaveToLibrary,
|
||||||
|
|
@ -606,14 +608,21 @@ const HomeScreen = () => {
|
||||||
|
|
||||||
// Memoize individual section components to prevent re-renders
|
// Memoize individual section components to prevent re-renders
|
||||||
const memoizedFeaturedContent = useMemo(() => (
|
const memoizedFeaturedContent = useMemo(() => (
|
||||||
<FeaturedContent
|
settings.heroStyle === 'carousel' ? (
|
||||||
key={`featured-${showHeroSection}-${featuredContentSource}`}
|
<HeroCarousel
|
||||||
featuredContent={featuredContent}
|
key={`carousel-${featuredContentSource}`}
|
||||||
isSaved={isSaved}
|
items={allFeaturedContent || (featuredContent ? [featuredContent] : [])}
|
||||||
handleSaveToLibrary={handleSaveToLibrary}
|
/>
|
||||||
loading={featuredLoading}
|
) : (
|
||||||
/>
|
<FeaturedContent
|
||||||
), [showHeroSection, featuredContentSource, featuredContent, isSaved, handleSaveToLibrary]);
|
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 memoizedThisWeekSection = useMemo(() => <ThisWeekSection />, []);
|
||||||
const memoizedContinueWatchingSection = useMemo(() => <ContinueWatchingSection ref={continueWatchingRef} />, []);
|
const memoizedContinueWatchingSection = useMemo(() => <ContinueWatchingSection ref={continueWatchingRef} />, []);
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,31 @@ const HomeScreenSettings: React.FC = () => {
|
||||||
|
|
||||||
{settings.showHeroSection && (
|
{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}>
|
<View style={styles.radioCardContainer}>
|
||||||
<RadioOption
|
<RadioOption
|
||||||
selected={settings.featuredContentSource === 'tmdb'}
|
selected={settings.featuredContentSource === 'tmdb'}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue