diff --git a/local-scrapers-repo b/local-scrapers-repo index 0a040cc..c176aab 160000 --- a/local-scrapers-repo +++ b/local-scrapers-repo @@ -1 +1 @@ -Subproject commit 0a040cc12da805fa8b38411d128e607e86e0f919 +Subproject commit c176aabb4edd73a709ebdc097688e780b65b651a diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 0858441..ce31524 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -650,6 +650,7 @@ export const SeriesContent: React.FC = ({ (settings?.episodeLayoutStyle === 'horizontal') ? ( // Horizontal Layout (Netflix-style) ( @@ -679,6 +680,7 @@ export const SeriesContent: React.FC = ({ ) : ( // Vertical Layout (Traditional) ( diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index aa110cc..ea0b673 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -784,16 +784,34 @@ const AndroidVideoPlayer: React.FC = () => { } disableImmersiveMode(); - // For series, hard reset to a single Streams route to avoid stacking multiple modals/pages - if (type === 'series' && id && episodeId) { - (navigation as any).reset({ - index: 0, - routes: [ - { name: 'Streams', params: { id, type: 'series', episodeId } } - ] - }); + if (Platform.OS === 'ios') { + // iOS: rebuild stack so Streams is presented as modal above Metadata + if (type === 'series' && id && episodeId) { + (navigation as any).reset({ + index: 2, + routes: [ + { name: 'MainTabs' }, + { name: 'Metadata', params: { id, type } }, + { name: 'Streams', params: { id, type: 'series', episodeId, fromPlayer: true } } + ] + }); + } else if ((navigation as any).canGoBack()) { + (navigation as any).goBack(); + } else { + (navigation as any).navigate('MainTabs'); + } } else { - navigation.goBack(); + // Android: hard reset to avoid stacking multiple pages/modals + if (type === 'series' && id && episodeId) { + (navigation as any).reset({ + index: 0, + routes: [ + { name: 'Streams', params: { id, type: 'series', episodeId, fromPlayer: true } } + ] + }); + } else { + (navigation as any).goBack(); + } } }).catch(() => { // Fallback: still try to restore portrait then navigate @@ -804,16 +822,34 @@ const AndroidVideoPlayer: React.FC = () => { } disableImmersiveMode(); - // For series, hard reset to a single Streams route to avoid stacking multiple modals/pages - if (type === 'series' && id && episodeId) { - (navigation as any).reset({ - index: 0, - routes: [ - { name: 'Streams', params: { id, type: 'series', episodeId } } - ] - }); + if (Platform.OS === 'ios') { + // iOS: rebuild stack so Streams is presented as modal above Metadata + if (type === 'series' && id && episodeId) { + (navigation as any).reset({ + index: 2, + routes: [ + { name: 'MainTabs' }, + { name: 'Metadata', params: { id, type } }, + { name: 'Streams', params: { id, type: 'series', episodeId, fromPlayer: true } } + ] + }); + } else if ((navigation as any).canGoBack()) { + (navigation as any).goBack(); + } else { + (navigation as any).navigate('MainTabs'); + } } else { - navigation.goBack(); + // Android: hard reset to avoid stacking multiple pages/modals + if (type === 'series' && id && episodeId) { + (navigation as any).reset({ + index: 0, + routes: [ + { name: 'Streams', params: { id, type: 'series', episodeId, fromPlayer: true } } + ] + }); + } else { + (navigation as any).goBack(); + } } }); diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index 480b186..5461cde 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -828,19 +828,37 @@ const VideoPlayer: React.FC = () => { // Navigate back with proper handling for fullscreen modal try { - // For series, hard reset to a single Streams route to avoid stacking multiple modals/pages - if (type === 'series' && id && episodeId) { - (navigation as any).reset({ - index: 0, - routes: [ - { name: 'Streams', params: { id, type: 'series', episodeId } } - ] - }); - } else if (navigation.canGoBack()) { - navigation.goBack(); + // On iOS, ensure Streams shows the CURRENT episode as modal by navigating directly + if (Platform.OS === 'ios') { + if (type === 'series' && id && episodeId) { + // Ensure modal by restoring MainTabs -> Metadata -> Streams + (navigation as any).reset({ + index: 2, + routes: [ + { name: 'MainTabs' }, + { name: 'Metadata', params: { id, type } }, + { name: 'Streams', params: { id, type: 'series', episodeId, fromPlayer: true } } + ] + }); + } else if (navigation.canGoBack()) { + navigation.goBack(); + } else { + navigation.navigate('MainTabs'); + } } else { - // Fallback: navigate to main tabs if can't go back - navigation.navigate('MainTabs'); + // Android: hard reset to avoid stacking multiple pages/modals + if (type === 'series' && id && episodeId) { + (navigation as any).reset({ + index: 0, + routes: [ + { name: 'Streams', params: { id, type: 'series', episodeId, fromPlayer: true } } + ] + }); + } else if (navigation.canGoBack()) { + navigation.goBack(); + } else { + navigation.navigate('MainTabs'); + } } logger.log('[VideoPlayer] Navigation completed'); } catch (navError) { diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 60f3f9c..62b2d2d 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -65,6 +65,7 @@ export type RootStackParamList = { type: string; episodeId?: string; episodeThumbnail?: string; + fromPlayer?: boolean; }; VideoPlayer: { id: string; diff --git a/src/screens/PluginsScreen.tsx b/src/screens/PluginsScreen.tsx index 1e26d30..ea0598c 100644 --- a/src/screens/PluginsScreen.tsx +++ b/src/screens/PluginsScreen.tsx @@ -368,6 +368,21 @@ const PluginsScreen: React.FC = () => { const [isLoading, setIsLoading] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [hasRepository, setHasRepository] = useState(false); + const [showboxCookie, setShowboxCookie] = useState(''); + const [showboxRegion, setShowboxRegion] = useState(''); + const regionOptions = [ + { value: 'USA7', label: 'US East' }, + { value: 'USA6', label: 'US West' }, + { value: 'USA5', label: 'US Middle' }, + { value: 'UK3', label: 'United Kingdom' }, + { value: 'CA1', label: 'Canada' }, + { value: 'FR1', label: 'France' }, + { value: 'DE2', label: 'Germany' }, + { value: 'HK1', label: 'Hong Kong' }, + { value: 'IN1', label: 'India' }, + { value: 'AU1', label: 'Australia' }, + { value: 'SZ', label: 'China' }, + ]; useEffect(() => { loadScrapers(); @@ -378,6 +393,13 @@ const PluginsScreen: React.FC = () => { try { const scrapers = await localScraperService.getAvailableScrapers(); setInstalledScrapers(scrapers); + // preload showbox settings if present + const sb = scrapers.find(s => s.id === 'showboxog'); + if (sb) { + const s = await localScraperService.getScraperSettings('showboxog'); + setShowboxCookie(s.cookie || ''); + setShowboxRegion(s.region || ''); + } } catch (error) { logger.error('[ScraperSettings] Failed to load scrapers:', error); } @@ -706,66 +728,117 @@ const PluginsScreen: React.FC = () => { ) : ( - {installedScrapers.map((scraper) => { - // Check if scraper is actually installed (has cached code) - const isInstalled = localScraperService.getInstalledScrapers().then(installed => - installed.some(s => s.id === scraper.id) - ); - - return ( - - {scraper.logo ? ( - - ) : ( - - )} - - - {scraper.name} - {scraper.manifestEnabled === false ? ( - - Disabled - - ) : scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android') ? ( - - Platform Disabled - - ) : !scraper.enabled && ( - - Available - - )} + {installedScrapers.map((scraper) => { + return ( + + + {scraper.logo ? ( + + ) : ( + + )} + + + {scraper.name} + {scraper.manifestEnabled === false ? ( + + Disabled + + ) : scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android') ? ( + + Platform Disabled + + ) : !scraper.enabled && ( + + Available + + )} + + {scraper.description} + + v{scraper.version} + + + {scraper.supportedTypes && Array.isArray(scraper.supportedTypes) ? scraper.supportedTypes.join(', ') : 'Unknown'} + + {scraper.contentLanguage && Array.isArray(scraper.contentLanguage) && scraper.contentLanguage.length > 0 && ( + <> + + + {scraper.contentLanguage.map(lang => lang.toUpperCase()).join(', ')} + + + )} + - {scraper.description} - - v{scraper.version} - - - {scraper.supportedTypes && Array.isArray(scraper.supportedTypes) ? scraper.supportedTypes.join(', ') : 'Unknown'} - - {scraper.contentLanguage && Array.isArray(scraper.contentLanguage) && scraper.contentLanguage.length > 0 && ( - <> - - - {scraper.contentLanguage.map(lang => lang.toUpperCase()).join(', ')} - - - )} - + handleToggleScraper(scraper.id, enabled)} + trackColor={{ false: colors.elevation3, true: colors.primary }} + thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'} + disabled={!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))} + style={{ opacity: (!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))) ? 0.5 : 1 }} + /> + + {scraper.id === 'showboxog' && settings.enableLocalScrapers && ( + + ShowBox Cookie + + Region + + {regionOptions.map(opt => { + const selected = showboxRegion === opt.value; + return ( + setShowboxRegion(opt.value)} + > + + {opt.label} + + + ); + })} + + + { + await localScraperService.setScraperSettings('showboxog', { cookie: showboxCookie, region: showboxRegion }); + Alert.alert('Saved', 'ShowBox settings updated'); + }} + > + Save + + { + setShowboxCookie(''); + setShowboxRegion(''); + await localScraperService.setScraperSettings('showboxog', {}); + }} + > + Clear + + + + )} - handleToggleScraper(scraper.id, enabled)} - trackColor={{ false: colors.elevation3, true: colors.primary }} - thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'} - disabled={!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))} - style={{ opacity: (!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))) ? 0.5 : 1 }} - /> - ); })} diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 1dfed3a..1739886 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -353,7 +353,7 @@ export const StreamsScreen = () => { const insets = useSafeAreaInsets(); const route = useRoute>(); const navigation = useNavigation(); - const { id, type, episodeId, episodeThumbnail } = route.params; + const { id, type, episodeId, episodeThumbnail, fromPlayer } = route.params; const { settings } = useSettings(); const { currentTheme } = useTheme(); const { colors } = currentTheme; @@ -565,17 +565,20 @@ export const StreamsScreen = () => { // Reset autoplay state when content changes setAutoplayTriggered(false); - if (settings.autoplayBestStream) { + if (settings.autoplayBestStream && !fromPlayer) { setIsAutoplayWaiting(true); logger.log('🔄 Autoplay enabled, waiting for best stream...'); } else { setIsAutoplayWaiting(false); + if (fromPlayer) { + logger.log('🚫 Autoplay disabled: returning from player'); + } } } }; checkProviders(); - }, [type, id, episodeId, settings.autoplayBestStream]); + }, [type, id, episodeId, settings.autoplayBestStream, fromPlayer]); React.useEffect(() => { // Trigger entrance animations @@ -1432,7 +1435,7 @@ export const StreamsScreen = () => { StyleSheet.create({ top: 0, left: 0, right: 0, - zIndex: 2, + zIndex: 9999, pointerEvents: 'box-none', }, backButton: { @@ -1670,7 +1673,7 @@ const createStyles = (colors: any) => StyleSheet.create({ alignItems: 'center', gap: 8, padding: 14, - paddingTop: Platform.OS === 'android' ? 45 : 15, + paddingTop: 0, }, backButtonText: { color: colors.highEmphasis, diff --git a/src/services/localScraperService.ts b/src/services/localScraperService.ts index ce04c86..5988ae4 100644 --- a/src/services/localScraperService.ts +++ b/src/services/localScraperService.ts @@ -61,6 +61,7 @@ class LocalScraperService { private repositoryUrl: string = ''; private repositoryName: string = ''; private initialized: boolean = false; + private scraperSettingsCache: Record | null = null; private constructor() { this.initialize(); @@ -409,6 +410,38 @@ class LocalScraperService { return Array.from(this.installedScrapers.values()); } + // Per-scraper settings storage + async getScraperSettings(scraperId: string): Promise> { + await this.ensureInitialized(); + try { + if (!this.scraperSettingsCache) { + const raw = await AsyncStorage.getItem(this.SCRAPER_SETTINGS_KEY); + this.scraperSettingsCache = raw ? JSON.parse(raw) : {}; + } + const cache = this.scraperSettingsCache || {}; + return cache[scraperId] || {}; + } catch (error) { + logger.warn('[LocalScraperService] Failed to get scraper settings for', scraperId, error); + return {}; + } + } + + async setScraperSettings(scraperId: string, settings: Record): Promise { + await this.ensureInitialized(); + try { + if (!this.scraperSettingsCache) { + const raw = await AsyncStorage.getItem(this.SCRAPER_SETTINGS_KEY); + this.scraperSettingsCache = raw ? JSON.parse(raw) : {}; + } + const cache = this.scraperSettingsCache || {}; + cache[scraperId] = settings || {}; + this.scraperSettingsCache = cache; + await AsyncStorage.setItem(this.SCRAPER_SETTINGS_KEY, JSON.stringify(cache)); + } catch (error) { + logger.error('[LocalScraperService] Failed to set scraper settings for', scraperId, error); + } + } + // Get available scrapers from manifest.json (for display in settings) async getAvailableScrapers(): Promise { if (!this.repositoryUrl) { @@ -568,12 +601,17 @@ class LocalScraperService { logger.log('[LocalScraperService] Executing scraper:', scraper.name); + // Load per-scraper settings + const scraperSettings = await this.getScraperSettings(scraper.id); + // Create a sandboxed execution environment const results = await this.executeSandboxed(code, { tmdbId, mediaType: type, season, - episode + episode, + scraperId: scraper.id, + settings: scraperSettings }); // Convert results to Nuvio Stream format @@ -602,6 +640,11 @@ class LocalScraperService { const settings = settingsData ? JSON.parse(settingsData) : {}; const urlValidationEnabled = settings.enableScraperUrlValidation ?? true; + // Load per-scraper settings for this run + const allScraperSettingsRaw = await AsyncStorage.getItem(this.SCRAPER_SETTINGS_KEY); + const allScraperSettings = allScraperSettingsRaw ? JSON.parse(allScraperSettingsRaw) : {}; + const perScraperSettings = (params && params.scraperId && allScraperSettings[params.scraperId]) ? allScraperSettings[params.scraperId] : (params?.settings || {}); + // Create a limited global context const moduleExports = {}; const moduleObj = { exports: moduleExports }; @@ -683,7 +726,10 @@ class LocalScraperService { exports: moduleExports, global: {}, // Empty global object // URL validation setting - URL_VALIDATION_ENABLED: urlValidationEnabled + URL_VALIDATION_ENABLED: urlValidationEnabled, + // Expose per-scraper settings to the plugin code + SCRAPER_SETTINGS: perScraperSettings, + SCRAPER_ID: params?.scraperId }; // Execute the scraper code without timeout