mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-22 01:01:56 +00:00
ui fix
This commit is contained in:
parent
e8ec05bd51
commit
71e3498876
3 changed files with 238 additions and 111 deletions
|
|
@ -10,8 +10,8 @@ import {
|
||||||
Platform,
|
Platform,
|
||||||
Alert,
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
|
import { useTrailer } from '../../contexts/TrailerContext';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import TrailerService from '../../services/trailerService';
|
import TrailerService from '../../services/trailerService';
|
||||||
import Video, { VideoRef, OnLoadData, OnProgressData } from 'react-native-video';
|
import Video, { VideoRef, OnLoadData, OnProgressData } from 'react-native-video';
|
||||||
|
|
@ -62,11 +62,13 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
||||||
contentTitle
|
contentTitle
|
||||||
}) => {
|
}) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
const { pauseTrailer, resumeTrailer } = useTrailer();
|
||||||
const videoRef = React.useRef<VideoRef>(null);
|
const videoRef = React.useRef<VideoRef>(null);
|
||||||
const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
|
const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
|
||||||
// Load trailer when modal opens or trailer changes
|
// Load trailer when modal opens or trailer changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -78,15 +80,25 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
setRetryCount(0);
|
||||||
}
|
}
|
||||||
}, [visible, trailer]);
|
}, [visible, trailer]);
|
||||||
|
|
||||||
const loadTrailer = useCallback(async () => {
|
const loadTrailer = useCallback(async () => {
|
||||||
if (!trailer) return;
|
if (!trailer) return;
|
||||||
|
|
||||||
|
// Pause hero section trailer when modal opens
|
||||||
|
try {
|
||||||
|
pauseTrailer();
|
||||||
|
logger.info('TrailerModal', 'Paused hero section trailer');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('TrailerModal', 'Error pausing hero trailer:', error);
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setTrailerUrl(null);
|
setTrailerUrl(null);
|
||||||
|
setRetryCount(0); // Reset retry count when starting fresh load
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const youtubeUrl = `https://www.youtube.com/watch?v=${trailer.key}`;
|
const youtubeUrl = `https://www.youtube.com/watch?v=${trailer.key}`;
|
||||||
|
|
@ -110,6 +122,7 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load trailer';
|
const errorMessage = err instanceof Error ? err.message : 'Failed to load trailer';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
|
setLoading(false);
|
||||||
logger.error('TrailerModal', 'Error loading trailer:', err);
|
logger.error('TrailerModal', 'Error loading trailer:', err);
|
||||||
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
|
|
@ -117,21 +130,62 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
||||||
'This trailer could not be loaded at this time. Please try again later.',
|
'This trailer could not be loaded at this time. Please try again later.',
|
||||||
[{ text: 'OK', style: 'default' }]
|
[{ text: 'OK', style: 'default' }]
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [trailer, contentTitle]);
|
}, [trailer, contentTitle, pauseTrailer]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
|
||||||
|
// Resume hero section trailer when modal closes
|
||||||
|
try {
|
||||||
|
resumeTrailer();
|
||||||
|
logger.info('TrailerModal', 'Resumed hero section trailer');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('TrailerModal', 'Error resuming hero trailer:', error);
|
||||||
|
}
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose, resumeTrailer]);
|
||||||
|
|
||||||
const handleTrailerError = useCallback(() => {
|
const handleTrailerError = useCallback(() => {
|
||||||
setError('Failed to play trailer');
|
setError('Failed to play trailer');
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle video playback errors with retry logic
|
||||||
|
const handleVideoError = useCallback((error: any) => {
|
||||||
|
logger.error('TrailerModal', 'Video error:', error);
|
||||||
|
|
||||||
|
// Check if this is a permission/network error that might benefit from retry
|
||||||
|
const errorCode = error?.error?.code;
|
||||||
|
const isRetryableError = errorCode === -1102 || errorCode === -1009 || errorCode === -1005;
|
||||||
|
|
||||||
|
if (isRetryableError && retryCount < 2) {
|
||||||
|
// Silent retry - increment count and try again
|
||||||
|
logger.info('TrailerModal', `Retrying video load (attempt ${retryCount + 1}/2)`);
|
||||||
|
setRetryCount(prev => prev + 1);
|
||||||
|
|
||||||
|
// Small delay before retry to avoid rapid-fire attempts
|
||||||
|
setTimeout(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
// Force video to reload by changing the source briefly
|
||||||
|
setTrailerUrl(null);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (trailerUrl) {
|
||||||
|
setTrailerUrl(trailerUrl);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After 2 retries or for non-retryable errors, show the error
|
||||||
|
logger.error('TrailerModal', 'Video error after retries or non-retryable:', error);
|
||||||
|
setError('Unable to play trailer. Please try again.');
|
||||||
|
setLoading(false);
|
||||||
|
}, [retryCount, trailerUrl]);
|
||||||
|
|
||||||
const handleTrailerEnd = useCallback(() => {
|
const handleTrailerEnd = useCallback(() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -158,13 +212,6 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
||||||
{/* Enhanced Header */}
|
{/* Enhanced Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.headerLeft}>
|
<View style={styles.headerLeft}>
|
||||||
<View style={[styles.headerIconContainer, { backgroundColor: currentTheme.colors.primary + '20' }]}>
|
|
||||||
<MaterialIcons
|
|
||||||
name="play-circle-fill"
|
|
||||||
size={20}
|
|
||||||
color={currentTheme.colors.primary}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.headerTextContainer}>
|
<View style={styles.headerTextContainer}>
|
||||||
<Text
|
<Text
|
||||||
style={[styles.title, { color: currentTheme.colors.highEmphasis }]}
|
style={[styles.title, { color: currentTheme.colors.highEmphasis }]}
|
||||||
|
|
@ -184,11 +231,9 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
||||||
style={[styles.closeButton, { backgroundColor: 'rgba(255,255,255,0.1)' }]}
|
style={[styles.closeButton, { backgroundColor: 'rgba(255,255,255,0.1)' }]}
|
||||||
hitSlop={{ top: 10, left: 10, right: 10, bottom: 10 }}
|
hitSlop={{ top: 10, left: 10, right: 10, bottom: 10 }}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<Text style={[styles.closeButtonText, { color: currentTheme.colors.highEmphasis }]}>
|
||||||
name="close"
|
Close
|
||||||
size={20}
|
</Text>
|
||||||
color={currentTheme.colors.highEmphasis}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -205,11 +250,6 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
||||||
|
|
||||||
{error && !loading && (
|
{error && !loading && (
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<MaterialIcons
|
|
||||||
name="error-outline"
|
|
||||||
size={48}
|
|
||||||
color={currentTheme.colors.error || '#FF6B6B'}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.errorText, { color: currentTheme.colors.textMuted }]}>
|
<Text style={[styles.errorText, { color: currentTheme.colors.textMuted }]}>
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -222,7 +262,8 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{trailerUrl && !loading && !error && (
|
{/* Render the Video as soon as we have a URL; keep spinner overlay until onLoad */}
|
||||||
|
{trailerUrl && !error && (
|
||||||
<View style={styles.playerWrapper}>
|
<View style={styles.playerWrapper}>
|
||||||
<Video
|
<Video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
|
|
@ -238,11 +279,11 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
||||||
ignoreSilentSwitch="ignore"
|
ignoreSilentSwitch="ignore"
|
||||||
onLoad={(data: OnLoadData) => {
|
onLoad={(data: OnLoadData) => {
|
||||||
logger.info('TrailerModal', 'Trailer loaded successfully', data);
|
logger.info('TrailerModal', 'Trailer loaded successfully', data);
|
||||||
|
setLoading(false);
|
||||||
|
setError(null);
|
||||||
|
setIsPlaying(true);
|
||||||
}}
|
}}
|
||||||
onError={(error) => {
|
onError={handleVideoError}
|
||||||
logger.error('TrailerModal', 'Video error:', error);
|
|
||||||
handleTrailerError();
|
|
||||||
}}
|
|
||||||
onEnd={() => {
|
onEnd={() => {
|
||||||
logger.info('TrailerModal', 'Trailer ended');
|
logger.info('TrailerModal', 'Trailer ended');
|
||||||
handleTrailerEnd();
|
handleTrailerEnd();
|
||||||
|
|
@ -252,6 +293,7 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
||||||
}}
|
}}
|
||||||
onLoadStart={() => {
|
onLoadStart={() => {
|
||||||
logger.info('TrailerModal', 'Video load started');
|
logger.info('TrailerModal', 'Video load started');
|
||||||
|
setLoading(true);
|
||||||
}}
|
}}
|
||||||
onReadyForDisplay={() => {
|
onReadyForDisplay={() => {
|
||||||
logger.info('TrailerModal', 'Video ready for display');
|
logger.info('TrailerModal', 'Video ready for display');
|
||||||
|
|
@ -264,11 +306,6 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
||||||
{/* Enhanced Footer */}
|
{/* Enhanced Footer */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<View style={styles.footerContent}>
|
<View style={styles.footerContent}>
|
||||||
<MaterialIcons
|
|
||||||
name="movie"
|
|
||||||
size={16}
|
|
||||||
color={currentTheme.colors.textMuted}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.footerText, { color: currentTheme.colors.textMuted }]}>
|
<Text style={[styles.footerText, { color: currentTheme.colors.textMuted }]}>
|
||||||
{contentTitle}
|
{contentTitle}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -317,14 +354,6 @@ const styles = StyleSheet.create({
|
||||||
headerLeft: {
|
headerLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
headerIconContainer: {
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: 10,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
},
|
||||||
headerTextContainer: {
|
headerTextContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
@ -347,12 +376,16 @@ const styles = StyleSheet.create({
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
closeButton: {
|
closeButton: {
|
||||||
width: 32,
|
paddingHorizontal: 12,
|
||||||
height: 32,
|
paddingVertical: 6,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
closeButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
playerContainer: {
|
playerContainer: {
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
backgroundColor: '#000',
|
backgroundColor: '#000',
|
||||||
|
|
@ -420,7 +453,6 @@ const styles = StyleSheet.create({
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
gap: 6,
|
|
||||||
},
|
},
|
||||||
footerText: {
|
footerText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, memo } from 'react';
|
import React, { useState, useEffect, useCallback, memo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -14,9 +14,12 @@ import {
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import FastImage from '@d11/react-native-fast-image';
|
import FastImage from '@d11/react-native-fast-image';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
|
import { useSettings } from '../../hooks/useSettings';
|
||||||
|
import { useTrailer } from '../../contexts/TrailerContext';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import TrailerService from '../../services/trailerService';
|
import TrailerService from '../../services/trailerService';
|
||||||
import TrailerModal from './TrailerModal';
|
import TrailerModal from './TrailerModal';
|
||||||
|
import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const isTablet = width >= 768;
|
const isTablet = width >= 768;
|
||||||
|
|
@ -50,6 +53,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
contentTitle
|
contentTitle
|
||||||
}) => {
|
}) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { pauseTrailer } = useTrailer();
|
||||||
const [trailers, setTrailers] = useState<CategorizedTrailers>({});
|
const [trailers, setTrailers] = useState<CategorizedTrailers>({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -57,11 +62,72 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('Trailer');
|
const [selectedCategory, setSelectedCategory] = useState<string>('Trailer');
|
||||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||||
|
const [backendAvailable, setBackendAvailable] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
// Smooth reveal animation after trailers are fetched
|
||||||
|
const sectionOpacitySV = useSharedValue(0);
|
||||||
|
const sectionTranslateYSV = useSharedValue(8);
|
||||||
|
const hasAnimatedRef = useRef(false);
|
||||||
|
|
||||||
|
const sectionAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: sectionOpacitySV.value,
|
||||||
|
transform: [{ translateY: sectionTranslateYSV.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Reset animation state before a new fetch starts
|
||||||
|
const resetSectionAnimation = useCallback(() => {
|
||||||
|
hasAnimatedRef.current = false;
|
||||||
|
sectionOpacitySV.value = 0;
|
||||||
|
sectionTranslateYSV.value = 8;
|
||||||
|
}, [sectionOpacitySV, sectionTranslateYSV]);
|
||||||
|
|
||||||
|
// Trigger animation once, 500ms after trailers are available
|
||||||
|
const triggerSectionAnimation = useCallback(() => {
|
||||||
|
if (hasAnimatedRef.current) return;
|
||||||
|
hasAnimatedRef.current = true;
|
||||||
|
sectionOpacitySV.value = withDelay(500, withTiming(1, { duration: 400 }));
|
||||||
|
sectionTranslateYSV.value = withDelay(500, withTiming(0, { duration: 400 }));
|
||||||
|
}, [sectionOpacitySV, sectionTranslateYSV]);
|
||||||
|
|
||||||
|
// Check if trailer service backend is available
|
||||||
|
const checkBackendAvailability = useCallback(async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const serverStatus = TrailerService.getServerStatus();
|
||||||
|
const healthUrl = `${serverStatus.localUrl.replace('/trailer', '/health')}`;
|
||||||
|
|
||||||
|
const response = await fetch(healthUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(3000), // 3 second timeout
|
||||||
|
});
|
||||||
|
const isAvailable = response.ok;
|
||||||
|
logger.info('TrailersSection', `Backend availability check: ${isAvailable ? 'AVAILABLE' : 'UNAVAILABLE'}`);
|
||||||
|
return isAvailable;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('TrailersSection', 'Backend availability check failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Fetch trailers from TMDB
|
// Fetch trailers from TMDB
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tmdbId) return;
|
if (!tmdbId) return;
|
||||||
|
|
||||||
|
const initializeTrailers = async () => {
|
||||||
|
resetSectionAnimation();
|
||||||
|
// First check if backend is available
|
||||||
|
const available = await checkBackendAvailability();
|
||||||
|
setBackendAvailable(available);
|
||||||
|
|
||||||
|
if (!available) {
|
||||||
|
logger.warn('TrailersSection', 'Trailer service backend is not available - skipping trailer loading');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend is available, proceed with fetching trailers
|
||||||
|
await fetchTrailers();
|
||||||
|
};
|
||||||
|
|
||||||
const fetchTrailers = async () => {
|
const fetchTrailers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -76,8 +142,14 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
|
|
||||||
const basicResponse = await fetch(basicEndpoint);
|
const basicResponse = await fetch(basicEndpoint);
|
||||||
if (!basicResponse.ok) {
|
if (!basicResponse.ok) {
|
||||||
logger.error('TrailersSection', `TMDB ID ${tmdbId} not found: ${basicResponse.status}`);
|
if (basicResponse.status === 404) {
|
||||||
setError(`Content not found (TMDB ID: ${tmdbId})`);
|
// 404 on basic endpoint means TMDB ID doesn't exist - this is normal
|
||||||
|
logger.info('TrailersSection', `TMDB ID ${tmdbId} not found in TMDB (404) - skipping trailers`);
|
||||||
|
setTrailers({}); // Empty trailers - section won't render
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('TrailersSection', `TMDB basic endpoint failed: ${basicResponse.status} ${basicResponse.statusText}`);
|
||||||
|
setError(`Failed to verify content: ${basicResponse.status}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,6 +184,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
} else {
|
} else {
|
||||||
logger.info('TrailersSection', `Categorized ${totalVideos} videos into ${Object.keys(categorized).length} categories`);
|
logger.info('TrailersSection', `Categorized ${totalVideos} videos into ${Object.keys(categorized).length} categories`);
|
||||||
setTrailers(categorized);
|
setTrailers(categorized);
|
||||||
|
// Trigger smooth reveal after 1.5s since we have content
|
||||||
|
triggerSectionAnimation();
|
||||||
|
|
||||||
// Auto-select the first available category, preferring "Trailer"
|
// Auto-select the first available category, preferring "Trailer"
|
||||||
const availableCategories = Object.keys(categorized);
|
const availableCategories = Object.keys(categorized);
|
||||||
|
|
@ -128,8 +202,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchTrailers();
|
initializeTrailers();
|
||||||
}, [tmdbId, type]);
|
}, [tmdbId, type, checkBackendAvailability]);
|
||||||
|
|
||||||
// Categorize trailers by type
|
// Categorize trailers by type
|
||||||
const categorizeTrailers = (videos: any[]): CategorizedTrailers => {
|
const categorizeTrailers = (videos: any[]): CategorizedTrailers => {
|
||||||
|
|
@ -145,18 +219,53 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
categories[category].push(video);
|
categories[category].push(video);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort within each category by published date (newest first)
|
// Sort within each category: official trailers first, then by published date (newest first)
|
||||||
Object.keys(categories).forEach(category => {
|
Object.keys(categories).forEach(category => {
|
||||||
categories[category].sort((a, b) =>
|
categories[category].sort((a, b) => {
|
||||||
new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
|
// Official trailers come first
|
||||||
);
|
if (a.official && !b.official) return -1;
|
||||||
|
if (!a.official && b.official) return 1;
|
||||||
|
|
||||||
|
// If both are official or both are not, sort by published date (newest first)
|
||||||
|
return new Date(b.published_at).getTime() - new Date(a.published_at).getTime();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return categories;
|
// Sort categories: "Trailer" category first, then categories with official trailers, then alphabetically
|
||||||
|
const sortedCategories = Object.keys(categories).sort((a, b) => {
|
||||||
|
// "Trailer" category always comes first
|
||||||
|
if (a === 'Trailer') return -1;
|
||||||
|
if (b === 'Trailer') return 1;
|
||||||
|
|
||||||
|
const aHasOfficial = categories[a].some(trailer => trailer.official);
|
||||||
|
const bHasOfficial = categories[b].some(trailer => trailer.official);
|
||||||
|
|
||||||
|
// Categories with official trailers come first (after Trailer)
|
||||||
|
if (aHasOfficial && !bHasOfficial) return -1;
|
||||||
|
if (!aHasOfficial && bHasOfficial) return 1;
|
||||||
|
|
||||||
|
// If both have or don't have official trailers, sort alphabetically
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new object with sorted categories
|
||||||
|
const sortedCategoriesObj: CategorizedTrailers = {};
|
||||||
|
sortedCategories.forEach(category => {
|
||||||
|
sortedCategoriesObj[category] = categories[category];
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortedCategoriesObj;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle trailer selection
|
// Handle trailer selection
|
||||||
const handleTrailerPress = (trailer: TrailerVideo) => {
|
const handleTrailerPress = (trailer: TrailerVideo) => {
|
||||||
|
// Pause hero section trailer when modal opens
|
||||||
|
try {
|
||||||
|
pauseTrailer();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('TrailersSection', 'Error pausing hero trailer:', error);
|
||||||
|
}
|
||||||
|
|
||||||
setSelectedTrailer(trailer);
|
setSelectedTrailer(trailer);
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
@ -165,6 +274,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
const handleModalClose = () => {
|
const handleModalClose = () => {
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
setSelectedTrailer(null);
|
setSelectedTrailer(null);
|
||||||
|
// Note: Hero trailer will resume automatically when modal closes
|
||||||
|
// The HeroSection component handles resuming based on scroll position
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle category selection
|
// Handle category selection
|
||||||
|
|
@ -228,42 +339,22 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
return null; // Don't show if no TMDB ID
|
return null; // Don't show if no TMDB ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't render if backend availability is still being checked or backend is unavailable
|
||||||
|
if (backendAvailable === null || backendAvailable === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't render if TMDB enrichment is disabled
|
||||||
|
if (!settings?.enrichMetadataWithTMDB) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return null;
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
|
|
||||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
|
||||||
Trailers
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.loadingContainer}>
|
|
||||||
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
|
||||||
<Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>
|
|
||||||
Loading trailers...
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return null;
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
|
|
||||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
|
||||||
Trailers
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.errorContainer}>
|
|
||||||
<MaterialIcons name="error-outline" size={24} color={currentTheme.colors.error || '#FF6B6B'} />
|
|
||||||
<Text style={[styles.errorText, { color: currentTheme.colors.textMuted }]}>
|
|
||||||
{error}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const trailerCategories = Object.keys(trailers);
|
const trailerCategories = Object.keys(trailers);
|
||||||
|
|
@ -293,7 +384,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<Animated.View style={[styles.container, sectionAnimatedStyle]}>
|
||||||
{/* Enhanced Header with Category Selector */}
|
{/* Enhanced Header with Category Selector */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||||
|
|
@ -303,7 +394,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
{/* Category Selector - Right Aligned */}
|
{/* Category Selector - Right Aligned */}
|
||||||
{trailerCategories.length > 0 && selectedCategory && (
|
{trailerCategories.length > 0 && selectedCategory && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.categorySelector, { borderColor: currentTheme.colors.primary + '40' }]}
|
style={[styles.categorySelector, { borderColor: 'rgba(255,255,255,0.6)' }]}
|
||||||
onPress={toggleDropdown}
|
onPress={toggleDropdown}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
|
|
@ -317,7 +408,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name={dropdownVisible ? "expand-less" : "expand-more"}
|
name={dropdownVisible ? "expand-less" : "expand-more"}
|
||||||
size={18}
|
size={18}
|
||||||
color={currentTheme.colors.primary}
|
color="rgba(255,255,255,0.7)"
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
@ -342,11 +433,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
{trailerCategories.map(category => (
|
{trailerCategories.map(category => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={category}
|
key={category}
|
||||||
style={[
|
style={styles.dropdownItem}
|
||||||
styles.dropdownItem,
|
|
||||||
selectedCategory === category && styles.dropdownItemSelected,
|
|
||||||
selectedCategory === category && { backgroundColor: currentTheme.colors.primary + '10' }
|
|
||||||
]}
|
|
||||||
onPress={() => handleCategorySelect(category)}
|
onPress={() => handleCategorySelect(category)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
|
|
@ -360,24 +447,16 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
color={currentTheme.colors.primary}
|
color={currentTheme.colors.primary}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.dropdownItemText,
|
styles.dropdownItemText,
|
||||||
{ color: currentTheme.colors.highEmphasis },
|
{ color: currentTheme.colors.highEmphasis }
|
||||||
selectedCategory === category && { color: currentTheme.colors.primary, fontWeight: '600' }
|
]}>
|
||||||
]}>
|
|
||||||
{formatTrailerType(category)}
|
{formatTrailerType(category)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.dropdownItemCount, { color: currentTheme.colors.textMuted }]}>
|
<Text style={[styles.dropdownItemCount, { color: currentTheme.colors.textMuted }]}>
|
||||||
{trailers[category].length}
|
{trailers[category].length}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{selectedCategory === category && (
|
|
||||||
<MaterialIcons
|
|
||||||
name="check"
|
|
||||||
size={20}
|
|
||||||
color={currentTheme.colors.primary}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -457,7 +536,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
trailer={selectedTrailer}
|
trailer={selectedTrailer}
|
||||||
contentTitle={contentTitle}
|
contentTitle={contentTitle}
|
||||||
/>
|
/>
|
||||||
</View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -471,8 +550,9 @@ const styles = StyleSheet.create({
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'flex-start',
|
||||||
marginBottom: 20,
|
marginBottom: 0,
|
||||||
|
gap: 12,
|
||||||
},
|
},
|
||||||
headerTitle: {
|
headerTitle: {
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
|
|
@ -527,9 +607,6 @@ const styles = StyleSheet.create({
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: 'rgba(255,255,255,0.05)',
|
borderBottomColor: 'rgba(255,255,255,0.05)',
|
||||||
},
|
},
|
||||||
dropdownItemSelected: {
|
|
||||||
borderBottomColor: 'transparent',
|
|
||||||
},
|
|
||||||
dropdownItemContent: {
|
dropdownItemContent: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import Animated, {
|
||||||
runOnJS,
|
runOnJS,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
|
import { useTrailer } from '../../contexts/TrailerContext';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
@ -57,6 +58,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
||||||
hideControls = false,
|
hideControls = false,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
const { isTrailerPlaying: globalTrailerPlaying } = useTrailer();
|
||||||
const videoRef = useRef<VideoRef>(null);
|
const videoRef = useRef<VideoRef>(null);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
@ -146,6 +148,22 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
||||||
}
|
}
|
||||||
}, [autoPlay, isComponentMounted]);
|
}, [autoPlay, isComponentMounted]);
|
||||||
|
|
||||||
|
// Respond to global trailer state changes (e.g., when modal opens)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isComponentMounted) {
|
||||||
|
// If global trailer is paused, pause this trailer too
|
||||||
|
if (!globalTrailerPlaying && isPlaying) {
|
||||||
|
logger.info('TrailerPlayer', 'Global trailer paused - pausing this trailer');
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
// If global trailer is resumed and autoPlay is enabled, resume this trailer
|
||||||
|
else if (globalTrailerPlaying && !isPlaying && autoPlay) {
|
||||||
|
logger.info('TrailerPlayer', 'Global trailer resumed - resuming this trailer');
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [globalTrailerPlaying, isPlaying, autoPlay, isComponentMounted]);
|
||||||
|
|
||||||
const showControlsWithTimeout = useCallback(() => {
|
const showControlsWithTimeout = useCallback(() => {
|
||||||
if (!isComponentMounted) return;
|
if (!isComponentMounted) return;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue