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;
}