diff --git a/src/components/common/AgeRatingBadge.tsx b/src/components/common/AgeRatingBadge.tsx new file mode 100644 index 00000000..d22847ba --- /dev/null +++ b/src/components/common/AgeRatingBadge.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; + +export type AgeRating = + | 'NC-17' + | 'TV-Y' + | 'TV-Y7' + | 'G' + | 'TV-G' + | 'PG' + | 'TV-PG' + | 'PG-13' + | 'TV-14' + | 'R' + | 'TV-MA'; + +interface AgeRatingBadgeProps { + rating: AgeRating | string; +} + +const AgeRatingBadge: React.FC = ({ rating }) => { + if (!rating) return null; + + return ( + + {rating} + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#575757', + paddingHorizontal: 6, + paddingVertical: 3, + borderRadius: 2, + alignItems: 'center', + justifyContent: 'center', + }, + text: { + color: '#E6E6E6', + fontSize: 10, + fontWeight: '600', + letterSpacing: 0.6, + textTransform: 'uppercase', + }, +}); + +export default AgeRatingBadge; diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index d1e2f681..5121c1a1 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -9,12 +9,12 @@ import { ViewStyle, TextStyle, StatusBar, + Image, } from 'react-native'; -import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { NavigationProp, useNavigation, useIsFocused } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; import { LinearGradient } from 'expo-linear-gradient'; import FastImage from '@d11/react-native-fast-image'; -import { SvgUri } from 'react-native-svg'; import { MaterialIcons, Entypo } from '@expo/vector-icons'; import Animated, { FadeIn, @@ -26,7 +26,7 @@ import Animated, { withDelay, runOnJS, interpolate, - Extrapolate, + Extrapolation, } from 'react-native-reanimated'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { StreamingContent } from '../../services/catalogService'; @@ -88,6 +88,7 @@ const AppleTVHero: React.FC = ({ onRetry, }) => { const navigation = useNavigation>(); + const isFocused = useIsFocused(); const { currentTheme } = useTheme(); const insets = useSafeAreaInsets(); const { settings, updateSetting } = useSettings(); @@ -105,6 +106,7 @@ const AppleTVHero: React.FC = ({ const [bannerLoaded, setBannerLoaded] = useState>({}); const [logoLoaded, setLogoLoaded] = useState>({}); const [logoError, setLogoError] = useState>({}); + const [logoHeights, setLogoHeights] = useState>({}); const autoPlayTimerRef = useRef(null); const lastInteractionRef = useRef(Date.now()); @@ -114,6 +116,7 @@ const AppleTVHero: React.FC = ({ const [trailerError, setTrailerError] = useState(false); const [trailerReady, setTrailerReady] = useState(false); const [trailerPreloaded, setTrailerPreloaded] = useState(false); + const [trailerShouldBePaused, setTrailerShouldBePaused] = useState(false); const trailerVideoRef = useRef(null); // Use ref to avoid re-fetching trailer when trailerMuted changes @@ -162,8 +165,53 @@ const AppleTVHero: React.FC = ({ setBannerLoaded({}); setLogoLoaded({}); setLogoError({}); + setLogoHeights({}); }, [items.length]); + // Stop trailer when screen loses focus + useEffect(() => { + if (!isFocused) { + // Pause this screen's trailer + setTrailerShouldBePaused(true); + setTrailerPlaying(false); + + // Fade out trailer + trailerOpacity.value = withTiming(0, { duration: 300 }); + thumbnailOpacity.value = withTiming(1, { duration: 300 }); + + logger.info('[AppleTVHero] Screen lost focus - pausing trailer'); + } else { + // Screen gained focus - allow trailer to resume if it was ready + setTrailerShouldBePaused(false); + + // If trailer was ready and loaded, restore the video opacity + if (trailerReady && trailerUrl) { + logger.info('[AppleTVHero] Screen gained focus - restoring trailer'); + thumbnailOpacity.value = withTiming(0, { duration: 800 }); + trailerOpacity.value = withTiming(1, { duration: 800 }); + setTrailerPlaying(true); + } + } + }, [isFocused, setTrailerPlaying, trailerOpacity, thumbnailOpacity, trailerReady, trailerUrl]); + + // Listen to navigation events to stop trailer when navigating to other screens + useEffect(() => { + const unsubscribe = navigation.addListener('blur', () => { + // Screen is blurred (navigated away) + setTrailerPlaying(false); + trailerOpacity.value = withTiming(0, { duration: 300 }); + thumbnailOpacity.value = withTiming(1, { duration: 300 }); + logger.info('[AppleTVHero] Navigation blur event - stopping trailer'); + }); + + return () => { + unsubscribe(); + // Stop trailer when component unmounts + setTrailerPlaying(false); + logger.info('[AppleTVHero] Component unmounting - stopping trailer'); + }; + }, [navigation, setTrailerPlaying, trailerOpacity, thumbnailOpacity]); + // Fetch trailer URL when current item changes useEffect(() => { let alive = true; @@ -342,12 +390,12 @@ const AppleTVHero: React.FC = ({ dragProgress.value = 0; setNextIndex(currentIndex); - // Quick logo fade + // Faster logo fade logoOpacity.value = 0; logoOpacity.value = withDelay( - 150, + 80, withTiming(1, { - duration: 400, + duration: 250, easing: Easing.out(Easing.cubic), }) ); @@ -385,8 +433,8 @@ const AppleTVHero: React.FC = ({ }) .onUpdate((event) => { const translationX = event.translationX; - // Use smaller width multiplier for easier drag - const progress = Math.abs(translationX) / (width * 0.6); + // Use larger width multiplier for smoother visual feedback on small swipes + const progress = Math.abs(translationX) / (width * 1.2); // Update drag progress (0 to 1) with eased curve dragProgress.value = Math.min(progress, 1); @@ -412,20 +460,31 @@ const AppleTVHero: React.FC = ({ .onEnd((event) => { const velocity = event.velocityX; const translationX = event.translationX; - const swipeThreshold = width * 0.15; // Smaller threshold - easier to swipe + const swipeThreshold = width * 0.05; // Very small threshold - minimal swipe needed - if (Math.abs(translationX) > swipeThreshold || Math.abs(velocity) > 600) { - // Complete the swipe - instant navigation - if (translationX > 0) { - runOnJS(goToPrevious)(); - } else { - runOnJS(goToNext)(); - } + if (Math.abs(translationX) > swipeThreshold || Math.abs(velocity) > 300) { + // Complete the swipe - animate to full opacity before navigation + dragProgress.value = withTiming( + 1, + { + duration: 300, + easing: Easing.out(Easing.cubic), + }, + (finished) => { + if (finished) { + if (translationX > 0) { + runOnJS(goToPrevious)(); + } else { + runOnJS(goToNext)(); + } + } + } + ); } else { - // Cancel the swipe - animate back with ease + // Cancel the swipe - animate back with smooth ease dragProgress.value = withTiming(0, { - duration: 250, - easing: Easing.bezier(0.4, 0.0, 0.2, 1), // Material design ease curve + duration: 450, + easing: Easing.bezier(0.25, 0.1, 0.25, 1), // Custom ease-out for buttery smooth return }); } }), @@ -434,21 +493,28 @@ const AppleTVHero: React.FC = ({ // Animated styles for next image only - smooth crossfade + slide during drag const nextImageStyle = useAnimatedStyle(() => { - // Enhanced 4-point curve for smoother crossfade + // Silky-smooth 10-point ease curve for cinematic crossfade const opacity = interpolate( dragProgress.value, - [0, 0.3, 0.7, 1], - [0, 0.4, 0.8, 1], - Extrapolate.CLAMP + [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.85, 1], + [0, 0.05, 0.12, 0.22, 0.35, 0.5, 0.65, 0.78, 0.92, 1], + Extrapolation.CLAMP ); - // Smoother slide effect with ease-out curve - const slideDistance = 20; // Subtle 20px movement + // Ultra-subtle slide effect with smooth ease-out curve + const slideDistance = 6; // Even more subtle 6px movement const slideProgress = interpolate( dragProgress.value, - [0, 0.4, 0.8, 1], // 4-point for smoother acceleration - [-slideDistance * dragDirection.value, -slideDistance * 0.5 * dragDirection.value, -slideDistance * 0.15 * dragDirection.value, 0], - Extrapolate.CLAMP + [0, 0.2, 0.4, 0.6, 0.8, 1], // 6-point for ultra-smooth acceleration + [ + -slideDistance * dragDirection.value, + -slideDistance * 0.8 * dragDirection.value, + -slideDistance * 0.6 * dragDirection.value, + -slideDistance * 0.35 * dragDirection.value, + -slideDistance * 0.12 * dragDirection.value, + 0 + ], + Extrapolation.CLAMP ); return { @@ -463,7 +529,7 @@ const AppleTVHero: React.FC = ({ dragProgress.value, [0, 0.2, 0.4], [1, 0.5, 0], - Extrapolate.CLAMP + Extrapolation.CLAMP ); return { @@ -560,6 +626,8 @@ const AppleTVHero: React.FC = ({ hideLoadingSpinner={true} onLoad={handleTrailerPreloaded} onError={handleTrailerError} + contentType={currentItem.type as 'movie' | 'series'} + paused={true} /> )} @@ -581,6 +649,8 @@ const AppleTVHero: React.FC = ({ onLoad={handleTrailerReady} onError={handleTrailerError} onEnd={handleTrailerEnd} + contentType={currentItem.type as 'movie' | 'series'} + paused={trailerShouldBePaused} onPlaybackStatusUpdate={(status) => { if (status.isLoaded && !trailerReady) { handleTrailerReady(); @@ -683,34 +753,28 @@ const AppleTVHero: React.FC = ({ style={logoAnimatedStyle} > {currentItem.logo && !logoError[currentIndex] ? ( - - {currentItem.logo.toLowerCase().endsWith('.svg') ? ( - setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))} - onError={() => { - setLogoError((prev) => ({ ...prev, [currentIndex]: true })); - logger.warn('[AppleTVHero] SVG Logo load failed:', currentItem.logo); - }} - /> - ) : ( - setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))} - onError={() => { - setLogoError((prev) => ({ ...prev, [currentIndex]: true })); - logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo); - }} - /> - )} + { + const { height } = event.nativeEvent.layout; + setLogoHeights((prev) => ({ ...prev, [currentIndex]: height })); + }} + > + setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))} + onError={() => { + setLogoError((prev) => ({ ...prev, [currentIndex]: true })); + logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo); + }} + /> ) : ( @@ -734,11 +798,6 @@ const AppleTVHero: React.FC = ({ {currentItem.genres[0]} )} - {currentItem.certification && ( - - {currentItem.certification} - - )} @@ -837,8 +896,7 @@ const styles = StyleSheet.create({ }, logoContainer: { width: width * 0.6, - height: 120, - marginBottom: 20, + height: 100, alignItems: 'center', justifyContent: 'center', }, @@ -847,7 +905,7 @@ const styles = StyleSheet.create({ height: '100%', }, titleContainer: { - marginBottom: 20, + marginBottom: 8, alignItems: 'center', justifyContent: 'center', }, @@ -861,7 +919,7 @@ const styles = StyleSheet.create({ textShadowRadius: 4, }, metadataContainer: { - marginBottom: 24, + marginBottom: 20, alignItems: 'center', justifyContent: 'center', }, @@ -869,10 +927,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - backgroundColor: 'rgba(255,255,255,0.15)', - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, gap: 8, }, metadataText: { @@ -884,18 +938,6 @@ const styles = StyleSheet.create({ color: 'rgba(255,255,255,0.6)', fontSize: 14, }, - ratingBadge: { - backgroundColor: 'rgba(255,255,255,0.25)', - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 4, - marginLeft: 4, - }, - ratingText: { - color: '#fff', - fontSize: 12, - fontWeight: '700', - }, buttonsContainer: { flexDirection: 'row', alignItems: 'center', diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx index 3c7c0fe5..7855dd94 100644 --- a/src/components/metadata/MetadataDetails.tsx +++ b/src/components/metadata/MetadataDetails.tsx @@ -21,6 +21,7 @@ import Animated, { import { useTheme } from '../../contexts/ThemeContext'; import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen'; import { getAgeRatingColor } from '../../utils/ageRatingColors'; +import AgeRatingBadge from '../common/AgeRatingBadge'; // Enhanced responsive breakpoints for Metadata Details const BREAKPOINTS = { @@ -215,14 +216,7 @@ function formatRuntime(runtime: string): string { )} {metadata.certification && ( - {metadata.certification} + )} {metadata.imdbRating && !isMDBEnabled && ( diff --git a/src/components/video/TrailerPlayer.tsx b/src/components/video/TrailerPlayer.tsx index 6dafac8e..c39b5858 100644 --- a/src/components/video/TrailerPlayer.tsx +++ b/src/components/video/TrailerPlayer.tsx @@ -40,6 +40,8 @@ interface TrailerPlayerProps { hideLoadingSpinner?: boolean; onFullscreenToggle?: () => void; hideControls?: boolean; + contentType?: 'movie' | 'series'; + paused?: boolean; // External control to pause/play } const TrailerPlayer = React.forwardRef(({ @@ -56,6 +58,8 @@ const TrailerPlayer = React.forwardRef(({ hideLoadingSpinner = false, onFullscreenToggle, hideControls = false, + contentType = 'movie', + paused, }, ref) => { const { currentTheme } = useTheme(); const { isTrailerPlaying: globalTrailerPlaying } = useTrailer(); @@ -142,27 +146,42 @@ const TrailerPlayer = React.forwardRef(({ }, [cleanupVideo]); // Handle autoPlay prop changes to keep internal state synchronized + // But only if no external paused prop is provided useEffect(() => { - if (isComponentMounted) { + if (isComponentMounted && paused === undefined) { setIsPlaying(autoPlay); } - }, [autoPlay, isComponentMounted]); + }, [autoPlay, isComponentMounted, paused]); - // Respond to global trailer state changes (e.g., when modal opens) + // Handle muted prop changes to keep internal state synchronized useEffect(() => { if (isComponentMounted) { - // If global trailer is paused, pause this trailer too - if (!globalTrailerPlaying && isPlaying) { + setIsMuted(muted); + } + }, [muted, isComponentMounted]); + + // Handle external paused prop to override playing state (highest priority) + useEffect(() => { + if (paused !== undefined) { + setIsPlaying(!paused); + logger.info('TrailerPlayer', `External paused prop changed: ${paused}, setting isPlaying to ${!paused}`); + } + }, [paused]); + + // Respond to global trailer state changes (e.g., when modal opens) + // Only apply if no external paused prop is controlling this + useEffect(() => { + if (isComponentMounted && paused === undefined) { + // Always sync with global trailer state when pausing + // This ensures all trailers pause when one screen loses focus + if (!globalTrailerPlaying) { 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); - } + // Don't automatically resume from global state + // Each trailer should manage its own resume logic based on its screen focus } - }, [globalTrailerPlaying, isPlaying, autoPlay, isComponentMounted]); + }, [globalTrailerPlaying, isComponentMounted, paused]); const showControlsWithTimeout = useCallback(() => { if (!isComponentMounted) return; @@ -360,8 +379,11 @@ const TrailerPlayer = React.forwardRef(({ } return { uri: trailerUrl } as any; })()} - style={styles.video} - resizeMode={isFullscreen ? 'contain' : 'cover'} + style={[ + styles.video, + contentType === 'movie' && styles.movieVideoScale, + ]} + resizeMode="cover" paused={!isPlaying} repeat={false} muted={isMuted} @@ -491,6 +513,9 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', }, + movieVideoScale: { + transform: [{ scale: 1.30 }], // Custom scale for movies to crop black bars + }, videoOverlay: { position: 'absolute', top: 0, diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 7dcd7632..0fba6733 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -873,6 +873,7 @@ const MainTabs = () => { options={{ title: 'Home', tabBarIcon: () => ({ sfSymbol: 'house' }), + freezeOnBlur: true, }} /> { tabBarIcon: ({ color, size, focused }) => ( ), + freezeOnBlur: true, }} /> { const { settings } = useSettings(); const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes const { showInfo } = useToast(); + const { setTrailerPlaying } = useTrailer(); const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection); const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource); const refreshTimeoutRef = useRef(null); @@ -411,9 +413,11 @@ const HomeScreen = () => { ScreenOrientation.unlockAsync().catch(() => {}); return () => { - // Keep translucent when unfocusing to prevent layout shifts + // Stop trailer when screen loses focus (navigating to other screens) + setTrailerPlaying(false); + logger.info('[HomeScreen] Screen blur - stopping trailer'); }; - }, []) + }, [setTrailerPlaying]) ); // Handle app state changes for smart cache management