mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
parental guide overlay init
This commit is contained in:
parent
9375fab06c
commit
eee6f81fca
5 changed files with 486 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
284
src/components/player/overlays/ParentalGuideOverlay.tsx
Normal file
284
src/components/player/overlays/ParentalGuideOverlay.tsx
Normal 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;
|
||||
120
src/services/parentalGuideService.ts
Normal file
120
src/services/parentalGuideService.ts
Normal 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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue