import React, { useState, useEffect, useMemo } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Switch, TextInput, ScrollView, RefreshControl, StatusBar, Platform, ActivityIndicator, Modal, Dimensions, Animated, Image, } from 'react-native'; import CustomAlert from '../components/CustomAlert'; import FastImage from '@d11/react-native-fast-image'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useNavigation } from '@react-navigation/native'; import { useSettings } from '../hooks/useSettings'; import { localScraperService, pluginService, ScraperInfo, RepositoryInfo } from '../services/pluginService'; import { logger } from '../utils/logger'; import { useTheme } from '../contexts/ThemeContext'; const { width: screenWidth } = Dimensions.get('window'); const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; // Create a styles creator function that accepts the theme colors const createStyles = (colors: any) => StyleSheet.create({ container: { flex: 1, backgroundColor: colors.darkBackground, }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, }, backButton: { flexDirection: 'row', alignItems: 'center', padding: 8, }, headerActions: { flexDirection: 'row', alignItems: 'center', }, headerButton: { padding: 8, marginLeft: 8, }, backText: { fontSize: 17, color: colors.primary, marginLeft: 8, }, headerTitle: { fontSize: 34, fontWeight: 'bold', color: colors.text, paddingHorizontal: 16, marginBottom: 24, }, scrollView: { flex: 1, }, section: { backgroundColor: colors.elevation1, marginBottom: 16, borderRadius: 12, padding: 16, }, sectionTitle: { fontSize: 20, fontWeight: '600', color: colors.white, marginBottom: 8, }, sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, }, sectionDescription: { fontSize: 14, color: colors.mediumGray, marginBottom: 16, lineHeight: 20, }, emptyContainer: { backgroundColor: colors.elevation2, borderRadius: 12, padding: 32, alignItems: 'center', justifyContent: 'center', marginBottom: 24, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 2, }, emptyText: { marginTop: 8, color: colors.mediumGray, fontSize: 15, }, scraperItem: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.elevation2, padding: 12, marginBottom: 8, borderRadius: 8, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 2, elevation: 1, }, scraperLogo: { width: 40, height: 40, marginRight: 12, borderRadius: 6, backgroundColor: colors.elevation3, }, scraperInfo: { flex: 1, }, scraperName: { fontSize: 15, fontWeight: '600', color: colors.white, marginBottom: 2, }, scraperDescription: { fontSize: 13, color: colors.mediumGray, marginBottom: 4, lineHeight: 18, }, scraperMeta: { flexDirection: 'row', alignItems: 'center', }, scraperVersion: { fontSize: 12, color: colors.mediumGray, }, scraperDot: { fontSize: 12, color: colors.mediumGray, marginHorizontal: 8, }, scraperTypes: { fontSize: 12, color: colors.mediumGray, }, scraperLanguage: { fontSize: 12, color: colors.mediumGray, }, settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, }, settingInfo: { flex: 1, marginRight: 16, }, settingTitle: { fontSize: 17, fontWeight: '600', color: colors.white, marginBottom: 2, }, settingDescription: { fontSize: 14, color: colors.mediumEmphasis, lineHeight: 20, }, textInput: { backgroundColor: colors.darkBackground, borderRadius: 8, padding: 12, color: colors.white, marginBottom: 16, fontSize: 15, }, button: { backgroundColor: 'transparent', paddingVertical: 12, paddingHorizontal: 20, borderRadius: 8, borderWidth: 1, borderColor: colors.elevation3, alignItems: 'center', justifyContent: 'center', minHeight: 44, }, primaryButton: { backgroundColor: colors.primary, borderColor: colors.primary, }, secondaryButton: { backgroundColor: 'transparent', borderColor: colors.elevation3, }, buttonText: { fontSize: 15, fontWeight: '500', color: colors.white, textAlign: 'center', }, secondaryButtonText: { fontSize: 15, fontWeight: '500', color: colors.white, textAlign: 'center', }, clearButton: { backgroundColor: '#ff3b30', paddingVertical: 8, paddingHorizontal: 12, borderRadius: 8, }, clearButtonText: { fontSize: 14, fontWeight: '600', color: colors.white, }, currentRepoContainer: { backgroundColor: colors.elevation1, borderRadius: 8, padding: 12, marginBottom: 16, }, currentRepoLabel: { fontSize: 14, fontWeight: '600', color: colors.primary, marginBottom: 4, }, currentRepoUrl: { fontSize: 14, color: colors.white, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', lineHeight: 18, }, urlHint: { fontSize: 12, color: colors.mediumGray, marginBottom: 8, lineHeight: 16, }, defaultRepoButton: { backgroundColor: colors.elevation3, borderRadius: 6, paddingVertical: 8, paddingHorizontal: 12, marginBottom: 16, alignItems: 'center', }, defaultRepoButtonText: { color: colors.primary, fontSize: 14, fontWeight: '500', }, buttonRow: { flexDirection: 'row', gap: 12, marginTop: 8, }, infoText: { fontSize: 14, color: colors.mediumEmphasis, lineHeight: 20, }, content: { flex: 1, }, emptyState: { alignItems: 'center', paddingVertical: 32, }, emptyStateTitle: { fontSize: 18, fontWeight: '600', color: colors.white, marginTop: 16, marginBottom: 8, }, emptyStateDescription: { fontSize: 14, color: colors.mediumGray, textAlign: 'center', lineHeight: 20, }, scrapersList: { gap: 12, }, scrapersContainer: { marginBottom: 24, }, inputContainer: { marginBottom: 16, }, lastSection: { borderBottomWidth: 0, }, disabledSection: { opacity: 0.5, }, disabledText: { color: colors.elevation3, }, disabledContainer: { opacity: 0.5, }, disabledInput: { backgroundColor: colors.elevation1, opacity: 0.5, }, disabledButton: { opacity: 0.5, }, disabledImage: { opacity: 0.3, }, availableIndicator: { backgroundColor: colors.primary, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, marginLeft: 8, }, availableIndicatorText: { color: colors.white, fontSize: 10, fontWeight: '600', }, qualityChipsContainer: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8, }, qualityChip: { backgroundColor: colors.elevation2, paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, borderWidth: 1, borderColor: colors.elevation3, }, qualityChipSelected: { backgroundColor: '#ff3b30', borderColor: '#ff3b30', }, qualityChipText: { color: colors.white, fontSize: 13, fontWeight: '500', }, qualityChipTextSelected: { color: colors.white, fontWeight: '600', }, // New styles for improved UX collapsibleSection: { backgroundColor: colors.darkBackground, marginBottom: 16, borderRadius: 12, overflow: 'hidden', }, collapsibleHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16, backgroundColor: colors.elevation2, }, collapsibleTitle: { fontSize: 18, fontWeight: '600', color: colors.white, }, collapsibleContent: { padding: 16, }, searchContainer: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.darkBackground, borderRadius: 12, marginBottom: 16, paddingHorizontal: 12, }, searchInput: { flex: 1, paddingVertical: 12, paddingHorizontal: 8, color: colors.white, fontSize: 16, }, filterContainer: { flexDirection: 'row', marginBottom: 16, gap: 8, }, filterChip: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, backgroundColor: colors.elevation2, borderWidth: 1, borderColor: colors.elevation3, }, filterChipSelected: { backgroundColor: colors.primary, borderColor: colors.primary, }, filterChipText: { color: colors.white, fontSize: 14, fontWeight: '500', }, filterChipTextSelected: { color: colors.white, fontWeight: '600', }, statusBadge: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 12, gap: 4, }, statusBadgeText: { color: 'white', fontSize: 11, fontWeight: '600', }, bulkActionsContainer: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 16, gap: 12, }, bulkActionButton: { flex: 1, paddingVertical: 12, paddingHorizontal: 16, borderRadius: 8, alignItems: 'center', justifyContent: 'center', minHeight: 44, borderWidth: 1, }, bulkActionButtonEnabled: { backgroundColor: 'transparent', borderColor: '#34C759', }, bulkActionButtonDisabled: { backgroundColor: 'transparent', borderColor: colors.elevation3, }, bulkActionButtonText: { color: colors.white, fontSize: 14, fontWeight: '500', }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center', }, modalContent: { backgroundColor: colors.darkBackground, borderRadius: 16, padding: 20, margin: 20, maxHeight: '70%', width: screenWidth - 40, borderWidth: 1, borderColor: colors.elevation3, }, modalTitle: { fontSize: 18, fontWeight: '600', color: colors.white, marginBottom: 8, }, modalText: { fontSize: 16, color: colors.mediumGray, lineHeight: 24, marginBottom: 16, }, modalButton: { backgroundColor: colors.primary, paddingVertical: 14, paddingHorizontal: 24, borderRadius: 8, alignItems: 'center', justifyContent: 'center', marginTop: 16, minHeight: 48, }, modalButtonText: { color: colors.white, fontSize: 16, fontWeight: '500', }, // Compact modal styles modalHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 6, gap: 6, }, compactTextInput: { backgroundColor: colors.darkBackground, borderRadius: 8, padding: 12, color: colors.white, fontSize: 15, borderWidth: 1, borderColor: colors.elevation3, marginBottom: 12, }, compactExamples: { flexDirection: 'row', gap: 8, marginBottom: 12, }, quickButton: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.elevation2, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 6, gap: 6, borderWidth: 1, borderColor: colors.elevation3, }, quickButtonText: { fontSize: 12, color: colors.white, fontWeight: '500', }, formatHint: { fontSize: 12, color: colors.mediumGray, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', marginBottom: 16, lineHeight: 16, }, compactActions: { flexDirection: 'row', gap: 8, }, compactButton: { flex: 1, paddingVertical: 10, paddingHorizontal: 16, borderRadius: 6, alignItems: 'center', justifyContent: 'center', minHeight: 40, }, cancelButton: { backgroundColor: colors.elevation2, borderWidth: 1, borderColor: colors.elevation3, }, cancelButtonText: { color: colors.white, fontSize: 14, fontWeight: '500', }, addButton: { backgroundColor: colors.primary, }, addButtonText: { color: colors.white, fontSize: 14, fontWeight: '600', }, quickSetupContainer: { backgroundColor: colors.elevation2, borderRadius: 12, padding: 16, marginBottom: 16, borderLeftWidth: 4, borderLeftColor: colors.primary, }, quickSetupTitle: { fontSize: 16, fontWeight: '600', color: colors.white, marginBottom: 8, }, quickSetupText: { fontSize: 14, color: colors.mediumGray, lineHeight: 20, marginBottom: 12, }, quickSetupButton: { backgroundColor: colors.primary, paddingVertical: 12, paddingHorizontal: 20, borderRadius: 8, alignItems: 'center', justifyContent: 'center', minHeight: 44, }, quickSetupButtonText: { color: colors.white, fontSize: 15, fontWeight: '500', }, scraperCard: { backgroundColor: colors.elevation2, borderRadius: 12, padding: 16, marginBottom: 12, borderWidth: 1, borderColor: colors.elevation3, minHeight: 120, }, scraperCardHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12, }, scraperCardInfo: { flex: 1, marginRight: 12, }, scraperCardMeta: { flexDirection: 'row', alignItems: 'center', marginTop: 8, gap: 8, flexWrap: 'wrap', }, scraperCardMetaItem: { flexDirection: 'row', alignItems: 'center', gap: 2, marginBottom: 4, }, scraperCardMetaText: { fontSize: 12, color: colors.mediumGray, }, emptyStateContainer: { alignItems: 'center', paddingVertical: 40, paddingHorizontal: 20, }, emptyStateIcon: { marginBottom: 16, }, // Repository management styles repositoriesList: { marginBottom: 16, }, repositoryItem: { backgroundColor: colors.elevation2, borderRadius: 12, padding: 16, marginBottom: 12, borderWidth: 1, borderColor: colors.elevation3, }, repositoryInfo: { flex: 1, marginBottom: 12, }, repositoryName: { fontSize: 16, fontWeight: '600', color: colors.white, marginRight: 8, }, repositoryDescription: { fontSize: 14, color: colors.mediumGray, marginBottom: 4, lineHeight: 18, }, repositoryUrl: { fontSize: 12, color: colors.mediumGray, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', marginBottom: 4, }, repositoryMeta: { fontSize: 12, color: colors.mediumGray, }, repositoryActions: { flexDirection: 'row', gap: 12, marginTop: 8, }, repositoryActionButton: { paddingVertical: 8, paddingHorizontal: 16, borderRadius: 6, borderWidth: 1, alignItems: 'center', justifyContent: 'center', minHeight: 36, }, repositoryActionButtonPrimary: { backgroundColor: 'transparent', borderColor: colors.primary, }, repositoryActionButtonSecondary: { backgroundColor: 'transparent', borderColor: colors.elevation3, }, repositoryActionButtonDanger: { backgroundColor: 'transparent', borderColor: '#ff3b30', }, repositoryActionButtonText: { fontSize: 13, fontWeight: '500', color: colors.white, }, }); // Helper component for collapsible sections const CollapsibleSection: React.FC<{ title: string; children: React.ReactNode; isExpanded: boolean; onToggle: () => void; colors: any; styles: any; }> = ({ title, children, isExpanded, onToggle, colors, styles }) => ( {title} {isExpanded && {children}} ); // Helper component for info tooltips const InfoTooltip: React.FC<{ text: string; colors: any }> = ({ text, colors }) => ( ); // Helper component for status badges const StatusBadge: React.FC<{ status: 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error' | 'limited'; colors: any; }> = ({ status, colors }) => { const getStatusConfig = () => { switch (status) { case 'enabled': return { color: '#34C759', text: 'Active' }; case 'disabled': return { color: colors.mediumGray, text: 'Disabled' }; case 'available': return { color: colors.primary, text: 'Available' }; case 'platform-disabled': return { color: '#FF9500', text: 'Platform Disabled' }; case 'limited': return { color: '#FF9500', text: 'Limited' }; case 'error': return { color: '#FF3B30', text: 'Error' }; default: return { color: colors.mediumGray, text: 'Unknown' }; } }; const config = getStatusConfig(); return ( {config.text} ); }; const PluginsScreen: React.FC = () => { const navigation = useNavigation(); const { settings, updateSetting } = useSettings(); const { currentTheme } = useTheme(); const colors = currentTheme.colors; const styles = createStyles(colors); // CustomAlert state const [alertVisible, setAlertVisible] = useState(false); const [alertTitle, setAlertTitle] = useState(''); const [alertMessage, setAlertMessage] = useState(''); const [alertActions, setAlertActions] = useState void; style?: object }>>([]); const openAlert = ( title: string, message: string, actions?: Array<{ label: string; onPress: () => void; style?: object }> ) => { setAlertTitle(title); setAlertMessage(message); setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); setAlertVisible(true); }; // Core state const [repositoryUrl, setRepositoryUrl] = useState(settings.scraperRepositoryUrl); const [installedScrapers, setInstalledScrapers] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [hasRepository, setHasRepository] = useState(false); const [showboxUiToken, setShowboxUiToken] = useState(''); const [showboxSavedToken, setShowboxSavedToken] = useState(''); const [showboxScraperId, setShowboxScraperId] = useState(null); const [showboxTokenVisible, setShowboxTokenVisible] = useState(false); // Multiple repositories state const [repositories, setRepositories] = useState([]); const [currentRepositoryId, setCurrentRepositoryId] = useState(''); const [showAddRepositoryModal, setShowAddRepositoryModal] = useState(false); const [newRepositoryUrl, setNewRepositoryUrl] = useState(''); const [switchingRepository, setSwitchingRepository] = useState(null); // New UX state const [searchQuery, setSearchQuery] = useState(''); const [selectedFilter, setSelectedFilter] = useState<'all' | 'movie' | 'tv'>('all'); const [expandedSections, setExpandedSections] = useState({ repository: true, scrapers: true, settings: false, quality: false, }); const [showHelpModal, setShowHelpModal] = useState(false); const [showRepositoryModal, setShowRepositoryModal] = useState(false); const [showScraperDetails, setShowScraperDetails] = useState(null); 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' }, ]; // Filtered scrapers based on search and filter const filteredScrapers = useMemo(() => { let filtered = installedScrapers; // Filter by search query if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); filtered = filtered.filter(scraper => scraper.name.toLowerCase().includes(query) || scraper.description.toLowerCase().includes(query) || scraper.id.toLowerCase().includes(query) ); } // Filter by type if (selectedFilter !== 'all') { filtered = filtered.filter(scraper => scraper.supportedTypes?.includes(selectedFilter as 'movie' | 'tv') ); } return filtered; }, [installedScrapers, searchQuery, selectedFilter]); // Helper functions const toggleSection = (section: keyof typeof expandedSections) => { setExpandedSections(prev => ({ ...prev, [section]: !prev[section] })); }; const getScraperStatus = (scraper: ScraperInfo): 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error' | 'limited' => { if (scraper.manifestEnabled === false) return 'disabled'; if (scraper.disabledPlatforms?.includes(Platform.OS as 'ios' | 'android')) return 'platform-disabled'; if (scraper.limited) return 'limited'; if (scraper.enabled) return 'enabled'; return 'available'; }; const handleBulkToggle = async (enabled: boolean) => { try { setIsRefreshing(true); const promises = filteredScrapers.map(scraper => pluginService.setScraperEnabled(scraper.id, enabled) ); await Promise.all(promises); await loadScrapers(); openAlert('Success', `${enabled ? 'Enabled' : 'Disabled'} ${filteredScrapers.length} scrapers`); } catch (error) { logger.error('[ScraperSettings] Failed to bulk toggle:', error); openAlert('Error', 'Failed to update scrapers'); } finally { setIsRefreshing(false); } }; const handleUrlChange = (url: string) => { setNewRepositoryUrl(url); }; const handleAddRepository = async () => { if (!newRepositoryUrl.trim()) { openAlert('Error', 'Please enter a valid repository URL'); return; } // Validate URL format const url = newRepositoryUrl.trim(); if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) { openAlert( 'Invalid URL Format', 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nor include manifest.json:\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch/manifest.json\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/master' ); return; } // Check if URL already includes manifest.json const isManifestUrl = url.includes('/manifest.json'); // Normalize URL - if it's a manifest URL, extract the base repository URL let normalizedUrl = url; if (isManifestUrl) { normalizedUrl = url.replace('/manifest.json', ''); logger.log('[PluginsScreen] Detected manifest URL, extracting base repository URL:', normalizedUrl); } // Additional validation for normalized URL if (!normalizedUrl.endsWith('/refs/heads/') && !normalizedUrl.includes('/refs/heads/')) { openAlert( 'Invalid Repository Structure', 'The URL should point to a GitHub repository branch.\n\nExpected format:\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch' ); return; } try { setIsLoading(true); const repoId = await pluginService.addRepository({ name: '', // Let the service fetch from manifest url: normalizedUrl, // Use normalized URL (without manifest.json) description: '', enabled: true }); await loadRepositories(); // Switch to the new repository and refresh it await pluginService.setCurrentRepository(repoId); await loadRepositories(); await loadScrapers(); setNewRepositoryUrl(''); setShowAddRepositoryModal(false); openAlert('Success', 'Repository added and refreshed successfully'); } catch (error) { logger.error('[PluginsScreen] Failed to add repository:', error); openAlert('Error', 'Failed to add repository'); } finally { setIsLoading(false); } }; const handleSwitchRepository = async (repoId: string) => { try { setSwitchingRepository(repoId); await pluginService.setCurrentRepository(repoId); await loadRepositories(); await loadScrapers(); openAlert('Success', 'Repository switched successfully'); } catch (error) { logger.error('[ScraperSettings] Failed to switch repository:', error); openAlert('Error', 'Failed to switch repository'); } finally { setSwitchingRepository(null); } }; const handleRemoveRepository = async (repoId: string) => { const repo = repositories.find(r => r.id === repoId); if (!repo) return; // Special handling for the last repository const isLastRepository = repositories.length === 1; const alertTitle = isLastRepository ? 'Remove Last Repository' : 'Remove Repository'; const alertMessage = isLastRepository ? `Are you sure you want to remove "${repo.name}"? This is your only repository, so you'll have no scrapers available until you add a new repository.` : `Are you sure you want to remove "${repo.name}"? This will also remove all scrapers from this repository.`; openAlert( alertTitle, alertMessage, [ { label: 'Cancel', onPress: () => {} }, { label: 'Remove', onPress: async () => { try { await pluginService.removeRepository(repoId); await loadRepositories(); await loadScrapers(); const successMessage = isLastRepository ? 'Repository removed successfully. You can add a new repository using the "Add Repository" button.' : 'Repository removed successfully'; openAlert('Success', successMessage); } catch (error) { logger.error('[ScraperSettings] Failed to remove repository:', error); openAlert('Error', error instanceof Error ? error.message : 'Failed to remove repository'); } }, }, ] ); }; useEffect(() => { loadScrapers(); loadRepositories(); }, []); const loadScrapers = async () => { try { const scrapers = await pluginService.getAvailableScrapers(); setInstalledScrapers(scrapers); // Detect ShowBox scraper dynamically and preload settings const sb = scrapers.find(s => { const id = (s.id || '').toLowerCase(); const name = (s.name || '').toLowerCase(); const filename = (s.filename || '').toLowerCase(); return id.includes('showbox') || name.includes('showbox') || filename.includes('showbox'); }); if (sb) { setShowboxScraperId(sb.id); const s = await pluginService.getScraperSettings(sb.id); setShowboxUiToken(s.uiToken || ''); setShowboxSavedToken(s.uiToken || ''); setShowboxTokenVisible(false); } else { setShowboxScraperId(null); setShowboxUiToken(''); setShowboxSavedToken(''); setShowboxTokenVisible(false); } } catch (error) { logger.error('[ScraperSettings] Failed to load scrapers:', error); } }; const loadRepositories = async () => { try { // First refresh repository names from manifests for existing repositories await pluginService.refreshRepositoryNamesFromManifests(); const repos = await pluginService.getRepositories(); setRepositories(repos); setHasRepository(repos.length > 0); const currentRepoId = pluginService.getCurrentRepositoryId(); setCurrentRepositoryId(currentRepoId); const currentRepo = repos.find(r => r.id === currentRepoId); if (currentRepo) { setRepositoryUrl(currentRepo.url); } } catch (error) { logger.error('[ScraperSettings] Failed to load repositories:', error); } }; const checkRepository = async () => { try { const repoUrl = await pluginService.getRepositoryUrl(); setHasRepository(!!repoUrl); if (repoUrl && repoUrl !== repositoryUrl) { setRepositoryUrl(repoUrl); } } catch (error) { logger.error('[ScraperSettings] Failed to check repository:', error); } }; const handleSaveRepository = async () => { if (!repositoryUrl.trim()) { openAlert('Error', 'Please enter a valid repository URL'); return; } // Validate URL format const url = repositoryUrl.trim(); if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) { openAlert( 'Invalid URL Format', 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/master' ); return; } try { setIsLoading(true); await pluginService.setRepositoryUrl(url); await updateSetting('scraperRepositoryUrl', url); setHasRepository(true); openAlert('Success', 'Repository URL saved successfully'); } catch (error) { logger.error('[ScraperSettings] Failed to save repository:', error); openAlert('Error', 'Failed to save repository URL'); } finally { setIsLoading(false); } }; const handleRefreshRepository = async () => { if (!repositoryUrl.trim()) { openAlert('Error', 'Please set a repository URL first'); return; } try { setIsRefreshing(true); logger.log('[PluginsScreen] Starting hard refresh of repository...'); // Force a complete hard refresh by clearing any cached data first await pluginService.refreshRepository(); // Load fresh scrapers from the updated repository await loadScrapers(); openAlert('Success', 'Repository refreshed successfully with latest files'); } catch (error) { logger.error('[PluginsScreen] Failed to refresh repository:', error); const errorMessage = error instanceof Error ? error.message : String(error); openAlert( 'Repository Error', `Failed to refresh repository: ${errorMessage}\n\nPlease ensure your URL is correct and follows this format:\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch` ); } finally { setIsRefreshing(false); } }; const handleToggleScraper = async (scraperId: string, enabled: boolean) => { try { if (enabled) { // If enabling a scraper, ensure it's installed first const installedScrapers = await pluginService.getInstalledScrapers(); const isInstalled = installedScrapers.some(scraper => scraper.id === scraperId); if (!isInstalled) { // Need to install the scraper first setIsRefreshing(true); await pluginService.refreshRepository(); setIsRefreshing(false); } } await pluginService.setScraperEnabled(scraperId, enabled); await loadScrapers(); } catch (error) { logger.error('[ScraperSettings] Failed to toggle scraper:', error); openAlert('Error', 'Failed to update scraper status'); setIsRefreshing(false); } }; const handleClearScrapers = () => { openAlert( 'Clear All Scrapers', 'Are you sure you want to remove all installed scrapers? This action cannot be undone.', [ { label: 'Cancel', onPress: () => {} }, { label: 'Clear', onPress: async () => { try { await pluginService.clearScrapers(); await loadScrapers(); openAlert('Success', 'All scrapers have been removed'); } catch (error) { logger.error('[ScraperSettings] Failed to clear scrapers:', error); openAlert('Error', 'Failed to clear scrapers'); } }, }, ] ); }; const handleClearCache = () => { openAlert( 'Clear Repository Cache', 'This will remove the saved repository URL and clear all cached scraper data. You will need to re-enter your repository URL.', [ { label: 'Cancel', onPress: () => {} }, { label: 'Clear Cache', onPress: async () => { try { await pluginService.clearScrapers(); await pluginService.setRepositoryUrl(''); await updateSetting('scraperRepositoryUrl', ''); setRepositoryUrl(''); setHasRepository(false); await loadScrapers(); openAlert('Success', 'Repository cache cleared successfully'); } catch (error) { logger.error('[ScraperSettings] Failed to clear cache:', error); openAlert('Error', 'Failed to clear repository cache'); } }, }, ] ); }; const handleUseDefaultRepo = () => { const defaultUrl = 'https://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/main'; setRepositoryUrl(defaultUrl); }; const handleToggleLocalScrapers = async (enabled: boolean) => { await updateSetting('enableLocalScrapers', enabled); // If enabling plugins, refresh repository and reload plugins if (enabled) { try { setIsRefreshing(true); logger.log('[PluginsScreen] Enabling plugins - refreshing repository...'); // Refresh repository to ensure plugins are available await pluginService.refreshRepository(); // Reload plugins to get the latest state await loadScrapers(); logger.log('[PluginsScreen] Plugins enabled and repository refreshed'); } catch (error) { logger.error('[PluginsScreen] Failed to refresh repository when enabling plugins:', error); // Don't show error to user as the toggle still succeeded } finally { setIsRefreshing(false); } } }; const handleToggleUrlValidation = async (enabled: boolean) => { await updateSetting('enableScraperUrlValidation', enabled); }; const handleToggleQualityExclusion = async (quality: string) => { const currentExcluded = settings.excludedQualities || []; const isExcluded = currentExcluded.includes(quality); let newExcluded: string[]; if (isExcluded) { // Remove from excluded list newExcluded = currentExcluded.filter(q => q !== quality); } else { // Add to excluded list newExcluded = [...currentExcluded, quality]; } await updateSetting('excludedQualities', newExcluded); }; const handleToggleLanguageExclusion = async (language: string) => { const currentExcluded = settings.excludedLanguages || []; const isExcluded = currentExcluded.includes(language); let newExcluded: string[]; if (isExcluded) { // Remove from excluded list newExcluded = currentExcluded.filter(l => l !== language); } else { // Add to excluded list newExcluded = [...currentExcluded, language]; } await updateSetting('excludedLanguages', newExcluded); }; // Define available quality options const qualityOptions = ['Auto', 'Adaptive', '2160p', '4K', '1080p', '720p', '360p', 'DV', 'HDR', 'REMUX', '480p', 'CAM', 'TS']; // Define available language options const languageOptions = ['Original', 'English', 'Spanish', 'Latin', 'French', 'German', 'Italian', 'Portuguese', 'Russian', 'Japanese', 'Korean', 'Chinese', 'Arabic', 'Hindi', 'Turkish', 'Dutch', 'Polish']; return ( {/* Header */} navigation.goBack()} > Settings {/* Help Button */} setShowHelpModal(true)} > Plugins { try { setIsRefreshing(true); logger.log('[PluginsScreen] Pull-to-refresh: Starting hard refresh...'); // Force hard refresh of repository await pluginService.refreshRepository(); await loadScrapers(); logger.log('[PluginsScreen] Pull-to-refresh completed'); } catch (error) { logger.error('[PluginsScreen] Pull-to-refresh failed:', error); } finally { setIsRefreshing(false); } }} /> } > {/* Quick Setup banner removed */} {/* Enable Plugins */} toggleSection('repository')} colors={colors} styles={styles} > Enable Plugins Allow the app to use installed plugins for finding streams {/* Repository Configuration */} toggleSection('repository')} colors={colors} styles={styles} > Manage multiple scraper repositories. Switch between repositories to access different sets of scrapers. {/* Current Repository */} {currentRepositoryId && ( Current Repository: {pluginService.getRepositoryName()} {repositoryUrl} )} {/* Repository List */} {repositories.length > 0 && ( Available Repositories {repositories.map((repo) => ( {repo.name} {repo.id === currentRepositoryId && ( Active )} {switchingRepository === repo.id && ( Switching... )} {repo.description && ( {repo.description} )} {repo.url} {repo.scraperCount || 0} scrapers • Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'} {repo.id !== currentRepositoryId && ( handleSwitchRepository(repo.id)} disabled={switchingRepository === repo.id} > {switchingRepository === repo.id ? ( ) : ( Switch )} )} handleRefreshRepository()} disabled={isRefreshing || switchingRepository !== null} > {isRefreshing ? ( ) : ( Refresh )} handleRemoveRepository(repo.id)} disabled={switchingRepository !== null} > Remove ))} )} {/* Add Repository Button */} setShowAddRepositoryModal(true)} disabled={!settings.enableLocalScrapers || switchingRepository !== null} > Add New Repository {/* Available Plugins */} toggleSection('scrapers')} colors={colors} styles={styles} > {installedScrapers.length > 0 && ( <> {/* Search and Filter */} {searchQuery.length > 0 && ( setSearchQuery('')}> )} {/* Filter Chips */} {['all', 'movie', 'tv'].map((filter) => ( setSelectedFilter(filter as any)} > {filter === 'all' ? 'All' : filter === 'movie' ? 'Movies' : 'TV Shows'} ))} {/* Bulk Actions */} {filteredScrapers.length > 0 && ( handleBulkToggle(true)} disabled={isRefreshing} > Enable All handleBulkToggle(false)} disabled={isRefreshing} > Disable All )} )} {filteredScrapers.length === 0 ? ( {searchQuery ? 'No Scrapers Found' : 'No Scrapers Available'} {searchQuery ? `No scrapers match "${searchQuery}". Try a different search term.` : 'Configure a repository above to view available scrapers.' } {searchQuery && ( setSearchQuery('')} > Clear Search )} ) : ( {filteredScrapers.map((scraper) => ( {scraper.logo ? ( (scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? ( ) : ( ) ) : ( )} {scraper.name} {scraper.description} 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'))} /> v{scraper.version} {scraper.supportedTypes?.join(', ') || 'Unknown'} {scraper.contentLanguage && scraper.contentLanguage.length > 0 && ( {scraper.contentLanguage.map(lang => lang.toUpperCase()).join(', ')} )} {scraper.supportsExternalPlayer === false && ( No external player )} {/* ShowBox Settings - only visible when ShowBox scraper is available */} {showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && ( ShowBox UI Token 0 && !showboxTokenVisible} multiline={false} numberOfLines={1} /> {showboxSavedToken.length > 0 && ( setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}> )} {showboxUiToken !== showboxSavedToken && ( { if (showboxScraperId) { await pluginService.setScraperSettings(showboxScraperId, { uiToken: showboxUiToken }); } setShowboxSavedToken(showboxUiToken); openAlert('Saved', 'ShowBox settings updated'); }} > Save )} { setShowboxUiToken(''); setShowboxSavedToken(''); if (showboxScraperId) { await pluginService.setScraperSettings(showboxScraperId, {}); } }} > Clear )} ))} )} {/* Additional Settings */} toggleSection('settings')} colors={colors} styles={styles} > Enable URL Validation Validate streaming URLs before returning them (may slow down results but improves reliability) Group Plugin Streams When enabled, all plugin streams are grouped under "{pluginService.getRepositoryName()}". When disabled, each plugin shows as a separate provider. { updateSetting('streamDisplayMode', value ? 'grouped' : 'separate'); // Auto-disable quality sorting when grouping is disabled if (!value && settings.streamSortMode === 'quality-then-scraper') { updateSetting('streamSortMode', 'scraper-then-quality'); } }} trackColor={{ false: colors.elevation3, true: colors.primary }} thumbColor={settings.streamDisplayMode === 'grouped' ? colors.white : '#f4f3f4'} disabled={!settings.enableLocalScrapers} /> Sort by Quality First When enabled, streams are sorted by quality first, then by scraper. When disabled, streams are sorted by scraper first, then by quality. Only available when grouping is enabled. updateSetting('streamSortMode', value ? 'quality-then-scraper' : 'scraper-then-quality')} trackColor={{ false: colors.elevation3, true: colors.primary }} thumbColor={settings.streamSortMode === 'quality-then-scraper' ? colors.white : '#f4f3f4'} disabled={!settings.enableLocalScrapers || settings.streamDisplayMode !== 'grouped'} /> Show Scraper Logos Display scraper logos next to streaming links on the streams screen. updateSetting('showScraperLogos', value)} trackColor={{ false: colors.elevation3, true: colors.primary }} thumbColor={settings.showScraperLogos && settings.enableLocalScrapers ? colors.white : '#f4f3f4'} disabled={!settings.enableLocalScrapers} /> {/* Quality Filtering */} toggleSection('quality')} colors={colors} styles={styles} > Exclude specific video qualities from search results. Tap on a quality to exclude it from plugin results. {qualityOptions.map((quality) => { const isExcluded = (settings.excludedQualities || []).includes(quality); return ( handleToggleQualityExclusion(quality)} disabled={!settings.enableLocalScrapers} > {isExcluded ? '✕ ' : ''}{quality} ); })} {(settings.excludedQualities || []).length > 0 && ( Excluded qualities: {(settings.excludedQualities || []).join(', ')} )} {/* Language Filtering */} toggleSection('quality')} colors={colors} styles={styles} > Exclude specific languages from search results. Tap on a language to exclude it from plugin results. Note: This filter only applies to providers that include language information in their stream names. It does not affect other providers. {languageOptions.map((language) => { const isExcluded = (settings.excludedLanguages || []).includes(language); return ( handleToggleLanguageExclusion(language)} disabled={!settings.enableLocalScrapers} > {isExcluded ? '✕ ' : ''}{language} ); })} {(settings.excludedLanguages || []).length > 0 && ( Excluded languages: {(settings.excludedLanguages || []).join(', ')} )} {/* About */} About Plugins Plugins are JavaScript modules that can search for streaming links from various sources. They run locally on your device and can be installed from trusted repositories. Note: Providers marked as "Limited" depend on external APIs that may stop working without notice. {/* Help Modal */} setShowHelpModal(false)} > Getting Started with Plugins 1. Enable Plugins - Turn on the main switch to allow plugins 2. Add Repository - Add a GitHub raw URL or use the default repository 3. Refresh Repository - Download available scrapers from the repository 4. Enable Scrapers - Turn on the scrapers you want to use for streaming setShowHelpModal(false)} > Got it! {/* Add Repository Modal */} setShowAddRepositoryModal(false)} > Add Repository {/* Format Hint */} Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch {/* Action Buttons */} { setShowAddRepositoryModal(false); setNewRepositoryUrl(''); }} > Cancel {isLoading ? ( ) : ( Add )} setAlertVisible(false)} /> ); }; export default PluginsScreen;