import React, { useState, useEffect, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
Switch,
TextInput,
ScrollView,
RefreshControl,
StatusBar,
Platform,
Image,
ActivityIndicator,
Modal,
Dimensions,
Animated,
} from 'react-native';
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, ScraperInfo, RepositoryInfo } from '../services/localScraperService';
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.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'ios' ? 44 : ANDROID_STATUSBAR_HEIGHT + 16,
paddingBottom: 16,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
},
backText: {
fontSize: 17,
color: colors.primary,
marginLeft: 8,
},
headerTitle: {
fontSize: 34,
fontWeight: 'bold',
color: colors.white,
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.elevation1,
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.elevation1,
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.elevation1,
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',
},
helpButton: {
position: 'absolute',
top: Platform.OS === 'ios' ? 44 : ANDROID_STATUSBAR_HEIGHT + 16,
right: 16,
backgroundColor: 'transparent',
borderRadius: 20,
padding: 8,
borderWidth: 1,
borderColor: colors.elevation3,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
backgroundColor: colors.elevation1,
borderRadius: 16,
padding: 24,
margin: 20,
maxHeight: '80%',
width: screenWidth - 40,
},
modalTitle: {
fontSize: 20,
fontWeight: '600',
color: colors.white,
marginBottom: 16,
},
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',
},
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,
},
scraperCardHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
scraperCardInfo: {
flex: 1,
marginRight: 12,
},
scraperCardMeta: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
gap: 12,
},
scraperCardMetaItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 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';
colors: any;
}> = ({ status, colors }) => {
const getStatusConfig = () => {
switch (status) {
case 'enabled':
return { color: '#34C759', text: 'Active', icon: 'checkmark-circle' };
case 'disabled':
return { color: colors.mediumGray, text: 'Disabled', icon: 'close-circle' };
case 'available':
return { color: colors.primary, text: 'Available', icon: 'download' };
case 'platform-disabled':
return { color: '#FF9500', text: 'Platform Disabled', icon: 'phone-portrait' };
case 'error':
return { color: '#FF3B30', text: 'Error', icon: 'warning' };
default:
return { color: colors.mediumGray, text: 'Unknown', icon: 'help-circle' };
}
};
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);
// 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 [showboxCookie, setShowboxCookie] = useState('');
const [showboxRegion, setShowboxRegion] = useState('');
// Multiple repositories state
const [repositories, setRepositories] = useState([]);
const [currentRepositoryId, setCurrentRepositoryId] = useState('');
const [showAddRepositoryModal, setShowAddRepositoryModal] = useState(false);
const [newRepositoryUrl, setNewRepositoryUrl] = useState('');
const [newRepositoryName, setNewRepositoryName] = useState('');
const [newRepositoryDescription, setNewRepositoryDescription] = useState('');
const [switchingRepository, setSwitchingRepository] = useState(null);
const [fetchingRepoName, setFetchingRepoName] = useState(false);
// 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' => {
if (scraper.manifestEnabled === false) return 'disabled';
if (scraper.disabledPlatforms?.includes(Platform.OS as 'ios' | 'android')) return 'platform-disabled';
if (scraper.enabled) return 'enabled';
return 'available';
};
const handleBulkToggle = async (enabled: boolean) => {
try {
setIsRefreshing(true);
const promises = filteredScrapers.map(scraper =>
localScraperService.setScraperEnabled(scraper.id, enabled)
);
await Promise.all(promises);
await loadScrapers();
Alert.alert('Success', `${enabled ? 'Enabled' : 'Disabled'} ${filteredScrapers.length} scrapers`);
} catch (error) {
logger.error('[ScraperSettings] Failed to bulk toggle:', error);
Alert.alert('Error', 'Failed to update scrapers');
} finally {
setIsRefreshing(false);
}
};
const handleUrlChange = async (url: string) => {
setNewRepositoryUrl(url);
// Auto-populate repository name if it's empty and URL is valid
if (!newRepositoryName.trim() && url.trim()) {
setFetchingRepoName(true);
try {
// Try to fetch name from manifest first
const manifestName = await localScraperService.fetchRepositoryNameFromManifest(url.trim());
setNewRepositoryName(manifestName);
} catch (error) {
// Fallback to URL extraction if manifest fetch fails
try {
const extractedName = localScraperService.extractRepositoryName(url.trim());
if (extractedName !== 'Unknown Repository') {
setNewRepositoryName(extractedName);
}
} catch (extractError) {
// Ignore errors, just don't auto-populate
}
} finally {
setFetchingRepoName(false);
}
}
};
const handleAddRepository = async () => {
if (!newRepositoryUrl.trim()) {
Alert.alert('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://')) {
Alert.alert(
'Invalid URL Format',
'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/branch/\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/main/'
);
return;
}
try {
setIsLoading(true);
const repoId = await localScraperService.addRepository({
name: newRepositoryName.trim(), // Let the service fetch from manifest if empty
url,
description: newRepositoryDescription.trim(),
enabled: true
});
await loadRepositories();
// Switch to the new repository and refresh it
await localScraperService.setCurrentRepository(repoId);
await loadRepositories();
await loadScrapers();
setNewRepositoryUrl('');
setNewRepositoryName('');
setNewRepositoryDescription('');
setFetchingRepoName(false);
setShowAddRepositoryModal(false);
Alert.alert('Success', 'Repository added and refreshed successfully');
} catch (error) {
logger.error('[ScraperSettings] Failed to add repository:', error);
Alert.alert('Error', 'Failed to add repository');
} finally {
setIsLoading(false);
}
};
const handleSwitchRepository = async (repoId: string) => {
try {
setSwitchingRepository(repoId);
await localScraperService.setCurrentRepository(repoId);
await loadRepositories();
await loadScrapers();
Alert.alert('Success', 'Repository switched successfully');
} catch (error) {
logger.error('[ScraperSettings] Failed to switch repository:', error);
Alert.alert('Error', 'Failed to switch repository');
} finally {
setSwitchingRepository(null);
}
};
const handleRemoveRepository = async (repoId: string) => {
const repo = repositories.find(r => r.id === repoId);
if (!repo) return;
Alert.alert(
'Remove Repository',
`Are you sure you want to remove "${repo.name}"? This will also remove all scrapers from this repository.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
try {
await localScraperService.removeRepository(repoId);
await loadRepositories();
await loadScrapers();
Alert.alert('Success', 'Repository removed successfully');
} catch (error) {
logger.error('[ScraperSettings] Failed to remove repository:', error);
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to remove repository');
}
},
},
]
);
};
useEffect(() => {
loadScrapers();
loadRepositories();
}, []);
const loadScrapers = async () => {
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);
}
};
const loadRepositories = async () => {
try {
// First refresh repository names from manifests for existing repositories
await localScraperService.refreshRepositoryNamesFromManifests();
const repos = await localScraperService.getRepositories();
setRepositories(repos);
setHasRepository(repos.length > 0);
const currentRepoId = localScraperService.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 localScraperService.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()) {
Alert.alert('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://')) {
Alert.alert(
'Invalid URL Format',
'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/branch/\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/main/'
);
return;
}
try {
setIsLoading(true);
await localScraperService.setRepositoryUrl(url);
await updateSetting('scraperRepositoryUrl', url);
setHasRepository(true);
Alert.alert('Success', 'Repository URL saved successfully');
} catch (error) {
logger.error('[ScraperSettings] Failed to save repository:', error);
Alert.alert('Error', 'Failed to save repository URL');
} finally {
setIsLoading(false);
}
};
const handleRefreshRepository = async () => {
if (!repositoryUrl.trim()) {
Alert.alert('Error', 'Please set a repository URL first');
return;
}
try {
setIsRefreshing(true);
await localScraperService.refreshRepository();
await loadScrapers(); // This will now load available scrapers from manifest
Alert.alert('Success', 'Repository refreshed successfully');
} catch (error) {
logger.error('[ScraperSettings] Failed to refresh repository:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
Alert.alert(
'Repository Error',
`Failed to refresh repository: ${errorMessage}\n\nPlease ensure your URL is correct and follows this format:\nhttps://raw.githubusercontent.com/username/repo/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 localScraperService.getInstalledScrapers();
const isInstalled = installedScrapers.some(scraper => scraper.id === scraperId);
if (!isInstalled) {
// Need to install the scraper first
setIsRefreshing(true);
await localScraperService.refreshRepository();
setIsRefreshing(false);
}
}
await localScraperService.setScraperEnabled(scraperId, enabled);
await loadScrapers();
} catch (error) {
logger.error('[ScraperSettings] Failed to toggle scraper:', error);
Alert.alert('Error', 'Failed to update scraper status');
setIsRefreshing(false);
}
};
const handleClearScrapers = () => {
Alert.alert(
'Clear All Scrapers',
'Are you sure you want to remove all installed scrapers? This action cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
try {
await localScraperService.clearScrapers();
await loadScrapers();
Alert.alert('Success', 'All scrapers have been removed');
} catch (error) {
logger.error('[ScraperSettings] Failed to clear scrapers:', error);
Alert.alert('Error', 'Failed to clear scrapers');
}
},
},
]
);
};
const handleClearCache = () => {
Alert.alert(
'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.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Clear Cache',
style: 'destructive',
onPress: async () => {
try {
await localScraperService.clearScrapers();
await localScraperService.setRepositoryUrl('');
await updateSetting('scraperRepositoryUrl', '');
setRepositoryUrl('');
setHasRepository(false);
await loadScrapers();
Alert.alert('Success', 'Repository cache cleared successfully');
} catch (error) {
logger.error('[ScraperSettings] Failed to clear cache:', error);
Alert.alert('Error', 'Failed to clear repository cache');
}
},
},
]
);
};
const handleUseDefaultRepo = () => {
const defaultUrl = 'https://raw.githubusercontent.com/tapframe/nuvio-providers/main';
setRepositoryUrl(defaultUrl);
};
const handleToggleLocalScrapers = async (enabled: boolean) => {
await updateSetting('enableLocalScrapers', enabled);
};
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);
};
// Define available quality options
const qualityOptions = ['2160p', '4K', '1080p', '720p', '360p', 'DV', 'HDR', 'REMUX', '480p', 'CAM', 'TS'];
return (
{/* Header */}
navigation.goBack()}
>
Settings
{/* Help Button */}
setShowHelpModal(true)}
>
Plugins
}
>
{/* Quick Setup for New Users */}
{!hasRepository && (
Quick Setup
Get started with plugins in 3 easy steps! Enable local scrapers, set up a repository, and start streaming.
{
setExpandedSections(prev => ({ ...prev, repository: true }));
setShowHelpModal(true);
}}
>
Get Started
)}
{/* Enable Local Scrapers */}
toggleSection('repository')}
colors={colors}
styles={styles}
>
Enable Local Scrapers
Allow the app to use locally installed scrapers 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:
{localScraperService.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
)}
{repositories.length > 1 && (
handleRemoveRepository(repo.id)}
disabled={switchingRepository !== null}
>
Remove
)}
))}
)}
{/* Add Repository Button */}
setShowAddRepositoryModal(true)}
disabled={!settings.enableLocalScrapers || switchingRepository !== null}
>
Add New Repository
{/* Available Scrapers */}
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.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(', ')}
)}
{/* ShowBox Settings */}
{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
)}
))}
)}
{/* 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 "{localScraperService.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(', ')}
)}
{/* 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.
{/* Help Modal */}
setShowHelpModal(false)}
>
Getting Started with Plugins
1. Enable Local Scrapers - 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 New Repository
Repository Name
{fetchingRepoName && (
)}
Repository URL
Description (Optional)
{
setShowAddRepositoryModal(false);
setNewRepositoryUrl('');
setNewRepositoryName('');
setNewRepositoryDescription('');
setFetchingRepoName(false);
}}
>
Cancel
{isLoading ? (
) : (
Add Repository
)}
);
};
export default PluginsScreen;