import React, { useState, useEffect, useRef } from 'react'; import { View, Text, StyleSheet, Dimensions } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, withTiming, withDelay, Easing, SharedValue, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { parentalGuideService } from '../../../services/parentalGuideService'; import { logger } from '../../../utils/logger'; import { useTheme } from '../../../contexts/ThemeContext'; interface ParentalGuideOverlayProps { imdbId: string | undefined; type: 'movie' | 'series'; season?: number; episode?: number; shouldShow: boolean; } interface WarningItem { label: string; severity: string; } const formatLabel = (key: string): string => { const labels: Record = { nudity: 'Nudity', violence: 'Violence', profanity: 'Profanity', alcohol: 'Alcohol/Drugs', frightening: 'Frightening', }; return labels[key] || key; }; // Row height for calculating line animation const ROW_HEIGHT = 18; // Separate component for each warning item const WarningItemView: React.FC<{ item: WarningItem; opacity: SharedValue; fontSize: number; }> = ({ item, opacity, fontSize }) => { const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value, })); return ( {item.label} ยท {item.severity} ); }; export const ParentalGuideOverlay: React.FC = ({ imdbId, type, season, episode, shouldShow, }) => { const { currentTheme } = useTheme(); const insets = useSafeAreaInsets(); const screenWidth = Dimensions.get('window').width; const [warnings, setWarnings] = useState([]); const [isVisible, setIsVisible] = useState(false); const hasShownRef = useRef(false); const hideTimeoutRef = useRef(null); const fadeTimeoutRef = useRef(null); const prevShouldShowRef = useRef(false); // Animation values const lineHeight = useSharedValue(0); const containerOpacity = useSharedValue(0); const itemOpacity0 = useSharedValue(0); const itemOpacity1 = useSharedValue(0); const itemOpacity2 = useSharedValue(0); const itemOpacity3 = useSharedValue(0); const itemOpacity4 = useSharedValue(0); const itemOpacities = [itemOpacity0, itemOpacity1, itemOpacity2, itemOpacity3, itemOpacity4]; // Fetch parental guide data useEffect(() => { const fetchData = async () => { if (!imdbId) return; try { let data; if (type === 'movie') { data = await parentalGuideService.getMovieGuide(imdbId); } else if (type === 'series' && season && episode) { data = await parentalGuideService.getTVGuide(imdbId, season, episode); } if (data && data.parentalGuide) { const guide = data.parentalGuide; const items: WarningItem[] = []; Object.entries(guide).forEach(([key, severity]) => { if (severity && severity.toLowerCase() !== 'none') { items.push({ label: formatLabel(key), severity: severity, }); } }); const severityOrder = { severe: 0, moderate: 1, mild: 2, none: 3 }; items.sort((a, b) => { const orderA = severityOrder[a.severity.toLowerCase() as keyof typeof severityOrder] ?? 3; const orderB = severityOrder[b.severity.toLowerCase() as keyof typeof severityOrder] ?? 3; return orderA - orderB; }); setWarnings(items.slice(0, 5)); logger.log('[ParentalGuideOverlay] Loaded warnings:', items.length); } } catch (error) { logger.error('[ParentalGuideOverlay] Error fetching guide:', error); } }; fetchData(); }, [imdbId, type, season, episode]); // Handle show/hide based on shouldShow (controls visibility) useEffect(() => { // When controls are shown (shouldShow becomes false), immediately hide overlay if (!shouldShow && isVisible) { // Clear any pending timeouts if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } if (fadeTimeoutRef.current) { clearTimeout(fadeTimeoutRef.current); fadeTimeoutRef.current = null; } // Immediately hide overlay with quick fade out const count = warnings.length; // FADE OUT: Items fade out in reverse order (bottom to top) for (let i = count - 1; i >= 0; i--) { const reverseDelay = (count - 1 - i) * 40; itemOpacities[i].value = withDelay( reverseDelay, withTiming(0, { duration: 100 }) ); } // Line shrinks after items are gone const lineDelay = count * 40 + 50; lineHeight.value = withDelay(lineDelay, withTiming(0, { duration: 200, easing: Easing.in(Easing.cubic), })); // Container fades out last containerOpacity.value = withDelay(lineDelay + 100, withTiming(0, { duration: 150 })); // Set invisible after all animations complete fadeTimeoutRef.current = setTimeout(() => { setIsVisible(false); // Don't reset hasShownRef here - only reset on content change }, lineDelay + 300); } // When controls are hidden (shouldShow becomes true), show overlay if not already shown for this content // Only show if transitioning from false to true (controls just hidden) if (shouldShow && !prevShouldShowRef.current && warnings.length > 0 && !hasShownRef.current) { hasShownRef.current = true; setIsVisible(true); const count = warnings.length; // Line height = (row height * count) + (gap * (count - 1)) const gap = 2; // matches styles.itemsContainer gap const totalLineHeight = (count * ROW_HEIGHT) + ((count - 1) * gap); // Container fade in containerOpacity.value = withTiming(1, { duration: 300 }); // FADE IN: Line grows from top to bottom first lineHeight.value = withTiming(totalLineHeight, { duration: 400, easing: Easing.out(Easing.cubic), }); // Then each item fades in one by one (after line animation) for (let i = 0; i < count; i++) { itemOpacities[i].value = withDelay( 400 + i * 80, // Start after line, stagger each withTiming(1, { duration: 200 }) ); } // Auto-hide after 5 seconds hideTimeoutRef.current = setTimeout(() => { // FADE OUT: Items fade out in reverse order (bottom to top) for (let i = count - 1; i >= 0; i--) { const reverseDelay = (count - 1 - i) * 60; itemOpacities[i].value = withDelay( reverseDelay, withTiming(0, { duration: 150 }) ); } // Line shrinks after items are gone const lineDelay = count * 60 + 100; lineHeight.value = withDelay(lineDelay, withTiming(0, { duration: 300, easing: Easing.in(Easing.cubic), })); // Container fades out last containerOpacity.value = withDelay(lineDelay + 200, withTiming(0, { duration: 200 })); // Set invisible after all animations complete fadeTimeoutRef.current = setTimeout(() => { setIsVisible(false); // Don't reset hasShownRef - only reset on content change }, lineDelay + 500); }, 5000); } // Update previous shouldShow value prevShouldShowRef.current = shouldShow; }, [shouldShow, isVisible, warnings.length]); // Cleanup on unmount useEffect(() => { return () => { if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); if (fadeTimeoutRef.current) clearTimeout(fadeTimeoutRef.current); }; }, []); // Reset when content changes useEffect(() => { hasShownRef.current = false; prevShouldShowRef.current = false; setWarnings([]); setIsVisible(false); lineHeight.value = 0; containerOpacity.value = 0; for (let i = 0; i < 5; i++) { itemOpacities[i].value = 0; } if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } if (fadeTimeoutRef.current) { clearTimeout(fadeTimeoutRef.current); fadeTimeoutRef.current = null; } }, [imdbId, season, episode]); const containerStyle = useAnimatedStyle(() => ({ opacity: containerOpacity.value, })); const lineStyle = useAnimatedStyle(() => ({ height: lineHeight.value, })); if (!isVisible || warnings.length === 0) { return null; } // Responsive sizing const fontSize = Math.min(11, screenWidth * 0.014); const lineWidth = Math.min(3, screenWidth * 0.0038); const containerPadding = Math.min(20, screenWidth * 0.025); // Use left inset for landscape notches, top inset for portrait const safeLeftOffset = insets.left + containerPadding; const safeTopOffset = containerPadding; return ( {/* Vertical line - animates height */} {/* Warning items */} {warnings.map((item, index) => ( ))} ); }; const styles = StyleSheet.create({ container: { position: 'absolute', flexDirection: 'row', alignItems: 'flex-start', zIndex: 100, }, line: { borderRadius: 1, marginRight: 10, }, itemsContainer: { gap: 2, }, warningItem: { flexDirection: 'row', alignItems: 'center', height: ROW_HEIGHT, }, label: { color: 'rgba(255, 255, 255, 0.85)', fontSize: 11, fontWeight: '600', }, separator: { color: 'rgba(255, 255, 255, 0.4)', fontSize: 11, marginHorizontal: 5, }, severity: { color: 'rgba(255, 255, 255, 0.5)', fontSize: 11, fontWeight: '400', }, }); export default ParentalGuideOverlay;