diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 6bbc838..f17d1e9 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -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 */} + { screenDimensions={screenDimensions} /> + {/* Parental Guide Overlay - Shows after controls first hide */} + + {/* Up Next Button */} { + 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; +}> = ({ item, opacity }) => { + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + + return ( + + {item.label} + ยท + {item.severity} + + ); +}; + +export const ParentalGuideOverlay: React.FC = ({ + imdbId, + type, + season, + episode, + shouldShow, +}) => { + const [warnings, setWarnings] = useState([]); + const [isVisible, setIsVisible] = useState(false); + const hasShownRef = useRef(false); + const hideTimeoutRef = useRef(null); + const fadeTimeoutRef = useRef(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 ( + + {/* Vertical line - animates height */} + + + {/* Warning items */} + + {warnings.map((item, index) => ( + + ))} + + + ); +}; + +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; diff --git a/src/services/parentalGuideService.ts b/src/services/parentalGuideService.ts new file mode 100644 index 0000000..459c208 --- /dev/null +++ b/src/services/parentalGuideService.ts @@ -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 = 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 { + 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(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 { + 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(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(); diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 378585e..4478848 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -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; }