NuvioStreaming/src/components/player/overlays/ParentalGuideOverlay.tsx
2026-01-11 20:45:32 +05:30

344 lines
12 KiB
TypeScript

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<string, string> = {
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<number>;
fontSize: number;
}> = ({ item, opacity, fontSize }) => {
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
return (
<Animated.View style={[styles.warningItem, animatedStyle]}>
<Text style={[styles.label, { fontSize }]}>{item.label}</Text>
<Text style={[styles.separator, { fontSize }]}>·</Text>
<Text style={[styles.severity, { fontSize }]}>{item.severity}</Text>
</Animated.View>
);
};
export const ParentalGuideOverlay: React.FC<ParentalGuideOverlayProps> = ({
imdbId,
type,
season,
episode,
shouldShow,
}) => {
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const screenWidth = Dimensions.get('window').width;
const [warnings, setWarnings] = useState<WarningItem[]>([]);
const [isVisible, setIsVisible] = useState(false);
const hasShownRef = useRef(false);
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const fadeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const prevShouldShowRef = useRef<boolean>(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 (
<Animated.View style={[styles.container, { left: safeLeftOffset, top: safeTopOffset }]} pointerEvents="none">
{/* Vertical line - animates height */}
<Animated.View style={[styles.line, lineStyle, { backgroundColor: currentTheme.colors.primary, width: lineWidth }]} />
{/* Warning items */}
<View style={styles.itemsContainer}>
{warnings.map((item, index) => (
<WarningItemView
key={item.label}
item={item}
opacity={itemOpacities[index]}
fontSize={fontSize}
/>
))}
</View>
</Animated.View>
);
};
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;