parental guide overlay init

This commit is contained in:
tapframe 2025-12-25 14:09:09 +05:30
parent 9375fab06c
commit eee6f81fca
5 changed files with 486 additions and 8 deletions

View file

@ -39,6 +39,7 @@ import { EpisodesModal } from './modals/EpisodesModal';
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
import { ErrorModal } from './modals/ErrorModal';
import { CustomSubtitles } from './subtitles/CustomSubtitles';
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
// Android-specific components
import { VideoSurface } from './android/components/VideoSurface';
@ -658,6 +659,15 @@ const AndroidVideoPlayer: React.FC = () => {
cast={cast}
screenDimensions={playerState.screenDimensions}
/>
{/* Parental Guide Overlay - Shows after controls first hide */}
<ParentalGuideOverlay
imdbId={imdbId || (id?.startsWith('tt') ? id : undefined)}
type={type as 'movie' | 'series'}
season={season}
episode={episode}
shouldShow={playerState.isVideoLoaded && !playerState.showControls && !playerState.paused}
/>
</View>
<AudioTrackModal

View file

@ -17,6 +17,7 @@ import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
import { ErrorModal } from './modals/ErrorModal';
import CustomSubtitles from './subtitles/CustomSubtitles';
import ResumeOverlay from './modals/ResumeOverlay';
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components';
// Platform-specific components
@ -649,6 +650,15 @@ const KSPlayerCore: React.FC = () => {
screenDimensions={screenDimensions}
/>
{/* Parental Guide Overlay - Shows after controls first hide */}
<ParentalGuideOverlay
imdbId={imdbId || (id?.startsWith('tt') ? id : undefined)}
type={type as 'movie' | 'series'}
season={season}
episode={episode}
shouldShow={isVideoLoaded && !showControls && !paused}
/>
{/* Up Next Button */}
<UpNextButton
type={type}

View file

@ -0,0 +1,284 @@
import React, { useState, useEffect, useRef } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withDelay,
Easing,
SharedValue,
} from 'react-native-reanimated';
import { parentalGuideService } from '../../../services/parentalGuideService';
import { logger } from '../../../utils/logger';
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>;
}> = ({ item, opacity }) => {
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
return (
<Animated.View style={[styles.warningItem, animatedStyle]}>
<Text style={styles.label}>{item.label}</Text>
<Text style={styles.separator}>·</Text>
<Text style={styles.severity}>{item.severity}</Text>
</Animated.View>
);
};
export const ParentalGuideOverlay: React.FC<ParentalGuideOverlayProps> = ({
imdbId,
type,
season,
episode,
shouldShow,
}) => {
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);
// 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]);
// Trigger animation when shouldShow becomes true
useEffect(() => {
if (shouldShow && 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);
}, lineDelay + 500);
}, 5000);
}
}, [shouldShow, 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;
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;
}
return (
<Animated.View style={[styles.container, containerStyle]} pointerEvents="none">
{/* Vertical line - animates height */}
<Animated.View style={[styles.line, lineStyle]} />
{/* Warning items */}
<View style={styles.itemsContainer}>
{warnings.map((item, index) => (
<WarningItemView
key={item.label}
item={item}
opacity={itemOpacities[index]}
/>
))}
</View>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 50,
left: 16,
flexDirection: 'row',
alignItems: 'flex-start',
zIndex: 100,
},
line: {
width: 2,
backgroundColor: 'rgba(255, 255, 255, 0.5)',
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;

View file

@ -0,0 +1,120 @@
import axios from 'axios';
import { logger } from '../utils/logger';
// Base URL for the parental guide API - configurable via env
const API_BASE_URL = process.env.EXPO_PUBLIC_PARENTAL_GUIDE_API_URL || 'https://parental.nuvioapp.space';
const TIMEOUT_MS = 5000;
export interface ParentalGuide {
nudity: 'None' | 'Mild' | 'Moderate' | 'Severe';
violence: 'None' | 'Mild' | 'Moderate' | 'Severe';
profanity: 'None' | 'Mild' | 'Moderate' | 'Severe';
alcohol: 'None' | 'Mild' | 'Moderate' | 'Severe';
frightening: 'None' | 'Mild' | 'Moderate' | 'Severe';
}
export interface ParentalGuideResponse {
imdbId: string;
parentalGuide: ParentalGuide;
hasData: boolean;
seriesId?: string;
season?: number;
episode?: number;
cached?: boolean;
}
class ParentalGuideService {
private static instance: ParentalGuideService;
private cache: Map<string, ParentalGuideResponse> = new Map();
private constructor() { }
public static getInstance(): ParentalGuideService {
if (!ParentalGuideService.instance) {
ParentalGuideService.instance = new ParentalGuideService();
}
return ParentalGuideService.instance;
}
/**
* Get parental guide for a movie
*/
async getMovieGuide(imdbId: string): Promise<ParentalGuideResponse | null> {
if (!imdbId || !imdbId.startsWith('tt')) {
logger.log('[ParentalGuide] Invalid IMDb ID:', imdbId);
return null;
}
const cacheKey = `movie:${imdbId}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!;
}
try {
const url = `${API_BASE_URL}/movie/${imdbId}`;
logger.log('[ParentalGuide] Fetching movie guide:', url);
const response = await axios.get<ParentalGuideResponse>(url, {
timeout: TIMEOUT_MS,
});
if (response.data && response.data.hasData) {
this.cache.set(cacheKey, response.data);
return response.data;
}
return null;
} catch (error: any) {
logger.error('[ParentalGuide] Failed to fetch movie guide:', error?.message || error);
return null;
}
}
/**
* Get parental guide for a TV episode
*/
async getTVGuide(imdbId: string, season: number, episode: number): Promise<ParentalGuideResponse | null> {
if (!imdbId || !imdbId.startsWith('tt')) {
logger.log('[ParentalGuide] Invalid IMDb ID:', imdbId);
return null;
}
if (!season || !episode || season < 0 || episode < 0) {
logger.log('[ParentalGuide] Invalid season/episode:', { season, episode });
return null;
}
const cacheKey = `tv:${imdbId}:${season}:${episode}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!;
}
try {
const url = `${API_BASE_URL}/tv/${imdbId}/${season}/${episode}`;
logger.log('[ParentalGuide] Fetching TV guide:', url);
const response = await axios.get<ParentalGuideResponse>(url, {
timeout: TIMEOUT_MS,
});
if (response.data && response.data.hasData) {
this.cache.set(cacheKey, response.data);
return response.data;
}
return null;
} catch (error: any) {
logger.error('[ParentalGuide] Failed to fetch TV guide:', error?.message || error);
return null;
}
}
/**
* Clear the cache
*/
clearCache(): void {
this.cache.clear();
}
}
export const parentalGuideService = ParentalGuideService.getInstance();

View file

@ -1691,34 +1691,85 @@ class StremioService {
await this.ensureInitialized();
// Collect from all installed addons that expose a subtitles resource
const addons = this.getInstalledAddons();
// The ID to check for prefix matching - use videoId for series (e.g., tt1234567:1:1), otherwise use id
const idForChecking = type === 'series' && videoId
? videoId.replace('series:', '')
: id;
const subtitleAddons = addons.filter(addon => {
if (!addon.resources) return false;
return addon.resources.some((resource: any) => {
// Check if addon has subtitles resource
const subtitlesResource = addon.resources.find((resource: any) => {
if (typeof resource === 'string') return resource === 'subtitles';
return resource && resource.name === 'subtitles';
});
if (!subtitlesResource) return false;
// Check type support - either from the resource object or addon-level types
let supportsType = true;
if (typeof subtitlesResource === 'object' && subtitlesResource.types) {
supportsType = subtitlesResource.types.includes(type);
} else if (addon.types) {
supportsType = addon.types.includes(type);
}
if (!supportsType) {
logger.log(`[getSubtitles] Addon ${addon.name} does not support type ${type}`);
return false;
}
// Check idPrefixes - either from the resource object or addon-level
let supportsIdPrefix = true;
let idPrefixes: string[] | undefined;
if (typeof subtitlesResource === 'object' && subtitlesResource.idPrefixes) {
idPrefixes = subtitlesResource.idPrefixes;
} else if (addon.idPrefixes) {
idPrefixes = addon.idPrefixes;
}
if (idPrefixes && idPrefixes.length > 0) {
supportsIdPrefix = idPrefixes.some(prefix => idForChecking.startsWith(prefix));
}
if (!supportsIdPrefix) {
logger.log(`[getSubtitles] Addon ${addon.name} does not support ID prefix for ${idForChecking} (requires: ${idPrefixes?.join(', ')})`);
return false;
}
return true;
});
if (subtitleAddons.length === 0) {
logger.warn('No subtitle-capable addons installed');
logger.warn('No subtitle-capable addons installed that support the requested type/id');
return [];
}
logger.log(`[getSubtitles] Found ${subtitleAddons.length} subtitle addons for ${type}/${id}: ${subtitleAddons.map(a => a.name).join(', ')}`);
const requests = subtitleAddons.map(async (addon) => {
if (!addon.url) return [] as Subtitle[];
try {
const { baseUrl } = this.getAddonBaseURL(addon.url || '');
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
let url = '';
if (type === 'series' && videoId) {
const episodeInfo = encodeURIComponent(videoId.replace('series:', ''));
url = `${baseUrl}/subtitles/series/${episodeInfo}.json`;
url = queryParams
? `${baseUrl}/subtitles/series/${episodeInfo}.json?${queryParams}`
: `${baseUrl}/subtitles/series/${episodeInfo}.json`;
} else {
const encodedId = encodeURIComponent(id);
url = `${baseUrl}/subtitles/${type}/${encodedId}.json`;
url = queryParams
? `${baseUrl}/subtitles/${type}/${encodedId}.json?${queryParams}`
: `${baseUrl}/subtitles/${type}/${encodedId}.json`;
}
logger.log(`Fetching subtitles from ${addon.name}: ${url}`);
logger.log(`[getSubtitles] Fetching subtitles from ${addon.name}: ${url}`);
const response = await this.retryRequest(async () => axios.get(url, { timeout: 10000 }));
if (response.data && Array.isArray(response.data.subtitles)) {
logger.log(`[getSubtitles] Got ${response.data.subtitles.length} subtitles from ${addon.name}`);
return response.data.subtitles.map((sub: any, index: number) => ({
// Ensure ID is always present per protocol (required field)
id: sub.id || `${addon.id}-${sub.lang || 'unknown'}-${index}`,
@ -1726,9 +1777,11 @@ class StremioService {
addon: addon.id,
addonName: addon.name,
})) as Subtitle[];
} else {
logger.log(`[getSubtitles] No subtitles array in response from ${addon.name}`);
}
} catch (error) {
logger.error(`Failed to fetch subtitles from ${addon.name}:`, error);
} catch (error: any) {
logger.error(`[getSubtitles] Failed to fetch subtitles from ${addon.name}:`, error?.message || error);
}
return [] as Subtitle[];
});
@ -1744,6 +1797,7 @@ class StremioService {
seen.add(key);
return true;
});
logger.log(`[getSubtitles] Total: ${deduped.length} unique subtitles from all addons`);
return deduped;
}