Parental Overlay Localization Patch

This commit is contained in:
cyberalby2 2026-03-06 22:45:30 +01:00
parent 6bfade4a17
commit db9d12491d
3 changed files with 343 additions and 279 deletions

View file

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

View file

@ -1531,6 +1531,17 @@
"library_desc": "Save favorites, track your progress, and sync with Trakt to keep everything organized across devices.", "library_desc": "Save favorites, track your progress, and sync with Trakt to keep everything organized across devices.",
"swipe_to_continue": "Swipe to continue", "swipe_to_continue": "Swipe to continue",
"skip": "Skip", "skip": "Skip",
"get_started":"Get Started" "get_started": "Get Started"
},
"overlay_screen": {
"nudity": "Nudity",
"violence": "Violence",
"profanity": "Profanity",
"alcohol": "Alcohol/Drugs",
"frightening": "Frightening",
"severe":"severe",
"moderate":"moderate",
"mild":"mild",
"none":"none"
} }
} }

View file

@ -1389,7 +1389,7 @@
"theme": { "theme": {
"title": "Temi App", "title": "Temi App",
"select_theme": "SELEZIONA TEMA", "select_theme": "SELEZIONA TEMA",
"create_custom": "Crea Tema Personalizado", "create_custom": "Crea Tema Personalizzato",
"options": "OPZIONI", "options": "OPZIONI",
"use_dominant_color": "Usa colore dominante dall'artwork", "use_dominant_color": "Usa colore dominante dall'artwork",
"categories": { "categories": {
@ -1532,5 +1532,16 @@
"swipe_to_continue": "Scorri per proseguire", "swipe_to_continue": "Scorri per proseguire",
"skip": "Salta", "skip": "Salta",
"get_started": "Inizia" "get_started": "Inizia"
},
"overlay_screen": {
"nudity": "Nudità",
"violence": "Violenza",
"profanity": "Volgarità",
"alcohol_drugs": "Alcol/Droga",
"frightening": "Paura",
"severe": "severa",
"moderate": "moderata",
"mild": "lieve",
"none": "nessuno"
} }
} }