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;