ui overhaul for plugnscreen

This commit is contained in:
tapframe 2025-09-06 01:57:28 +05:30
parent c9ea142dfb
commit a30ace14c9
2 changed files with 773 additions and 167 deletions

View file

@ -21,7 +21,7 @@ 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 } from '../services/localScraperService';
import { localScraperService, ScraperInfo, RepositoryInfo } from '../services/localScraperService';
import { logger } from '../utils/logger';
import { useTheme } from '../contexts/ThemeContext';
@ -189,28 +189,34 @@ const createStyles = (colors: any) => StyleSheet.create({
fontSize: 15,
},
button: {
backgroundColor: colors.elevation2,
backgroundColor: 'transparent',
paddingVertical: 12,
paddingHorizontal: 16,
paddingHorizontal: 20,
borderRadius: 8,
marginRight: 8,
borderWidth: 1,
borderColor: colors.elevation3,
alignItems: 'center',
justifyContent: 'center',
minHeight: 44,
},
primaryButton: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
secondaryButton: {
backgroundColor: colors.elevation2,
backgroundColor: 'transparent',
borderColor: colors.elevation3,
},
buttonText: {
fontSize: 16,
fontWeight: '600',
fontSize: 15,
fontWeight: '500',
color: colors.white,
textAlign: 'center',
},
secondaryButtonText: {
fontSize: 16,
fontWeight: '600',
color: colors.mediumGray,
fontSize: 15,
fontWeight: '500',
color: colors.white,
textAlign: 'center',
},
clearButton: {
@ -264,6 +270,7 @@ const createStyles = (colors: any) => StyleSheet.create({
buttonRow: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
infoText: {
fontSize: 14,
@ -440,35 +447,40 @@ const createStyles = (colors: any) => StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 16,
gap: 8,
gap: 12,
},
bulkActionButton: {
flex: 1,
paddingVertical: 10,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
minHeight: 44,
borderWidth: 1,
},
bulkActionButtonEnabled: {
backgroundColor: '#34C759',
backgroundColor: 'transparent',
borderColor: '#34C759',
},
bulkActionButtonDisabled: {
backgroundColor: colors.elevation2,
borderWidth: 1,
backgroundColor: 'transparent',
borderColor: colors.elevation3,
},
bulkActionButtonText: {
color: colors.white,
fontSize: 14,
fontWeight: '600',
fontWeight: '500',
},
helpButton: {
position: 'absolute',
top: Platform.OS === 'ios' ? 44 : ANDROID_STATUSBAR_HEIGHT + 16,
right: 16,
backgroundColor: colors.elevation2,
backgroundColor: 'transparent',
borderRadius: 20,
padding: 8,
borderWidth: 1,
borderColor: colors.elevation3,
},
modalOverlay: {
flex: 1,
@ -498,16 +510,18 @@ const createStyles = (colors: any) => StyleSheet.create({
},
modalButton: {
backgroundColor: colors.primary,
paddingVertical: 12,
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
marginTop: 16,
minHeight: 48,
},
modalButtonText: {
color: colors.white,
fontSize: 16,
fontWeight: '600',
fontWeight: '500',
},
quickSetupContainer: {
backgroundColor: colors.elevation2,
@ -531,15 +545,17 @@ const createStyles = (colors: any) => StyleSheet.create({
},
quickSetupButton: {
backgroundColor: colors.primary,
paddingVertical: 10,
paddingHorizontal: 16,
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
minHeight: 44,
},
quickSetupButtonText: {
color: colors.white,
fontSize: 14,
fontWeight: '600',
fontSize: 15,
fontWeight: '500',
},
scraperCard: {
backgroundColor: colors.elevation2,
@ -581,6 +597,75 @@ const createStyles = (colors: any) => StyleSheet.create({
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
@ -660,6 +745,16 @@ const PluginsScreen: React.FC = () => {
const [showboxCookie, setShowboxCookie] = useState<string>('');
const [showboxRegion, setShowboxRegion] = useState<string>('');
// Multiple repositories state
const [repositories, setRepositories] = useState<RepositoryInfo[]>([]);
const [currentRepositoryId, setCurrentRepositoryId] = useState<string>('');
const [showAddRepositoryModal, setShowAddRepositoryModal] = useState(false);
const [newRepositoryUrl, setNewRepositoryUrl] = useState('');
const [newRepositoryName, setNewRepositoryName] = useState('');
const [newRepositoryDescription, setNewRepositoryDescription] = useState('');
const [switchingRepository, setSwitchingRepository] = useState<string | null>(null);
const [fetchingRepoName, setFetchingRepoName] = useState(false);
// New UX state
const [searchQuery, setSearchQuery] = useState('');
const [selectedFilter, setSelectedFilter] = useState<'all' | 'movie' | 'tv'>('all');
@ -742,9 +837,123 @@ const PluginsScreen: React.FC = () => {
}
};
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();
checkRepository();
loadRepositories();
}, []);
const loadScrapers = async () => {
@ -763,6 +972,27 @@ const PluginsScreen: React.FC = () => {
}
};
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();
@ -1024,10 +1254,11 @@ const PluginsScreen: React.FC = () => {
styles={styles}
>
<Text style={styles.sectionDescription}>
Enter the URL of a Nuvio scraper repository to download and install scrapers.
Manage multiple scraper repositories. Switch between repositories to access different sets of scrapers.
</Text>
{hasRepository && repositoryUrl && (
{/* Current Repository */}
{currentRepositoryId && (
<View style={styles.currentRepoContainer}>
<Text style={styles.currentRepoLabel}>Current Repository:</Text>
<Text style={styles.currentRepoUrl}>{localScraperService.getRepositoryName()}</Text>
@ -1035,58 +1266,84 @@ const PluginsScreen: React.FC = () => {
</View>
)}
<View style={styles.inputContainer}>
<TextInput
style={[styles.textInput, !settings.enableLocalScrapers && styles.disabledInput]}
value={repositoryUrl}
onChangeText={setRepositoryUrl}
placeholder="https://raw.githubusercontent.com/tapframe/nuvio-providers/main"
placeholderTextColor={!settings.enableLocalScrapers ? colors.elevation3 : "#999"}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={settings.enableLocalScrapers}
/>
<Text style={[styles.urlHint, !settings.enableLocalScrapers && styles.disabledText]}>
Use GitHub raw URL format. Default: https://raw.githubusercontent.com/tapframe/nuvio-providers/main
{/* Repository List */}
{repositories.length > 0 && (
<View style={styles.repositoriesList}>
<Text style={[styles.settingTitle, { marginBottom: 12 }]}>Available Repositories</Text>
{repositories.map((repo) => (
<View key={repo.id} style={styles.repositoryItem}>
<View style={styles.repositoryInfo}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}>
<Text style={styles.repositoryName}>{repo.name}</Text>
{repo.id === currentRepositoryId && (
<View style={[styles.statusBadge, { backgroundColor: '#34C759' }]}>
<Ionicons name="checkmark-circle" size={12} color="white" />
<Text style={styles.statusBadgeText}>Active</Text>
</View>
)}
{switchingRepository === repo.id && (
<View style={[styles.statusBadge, { backgroundColor: colors.primary }]}>
<ActivityIndicator size={12} color="white" />
<Text style={styles.statusBadgeText}>Switching...</Text>
</View>
)}
</View>
{repo.description && (
<Text style={styles.repositoryDescription}>{repo.description}</Text>
)}
<Text style={styles.repositoryUrl}>{repo.url}</Text>
<Text style={styles.repositoryMeta}>
{repo.scraperCount || 0} scrapers Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'}
</Text>
<TouchableOpacity
style={[styles.defaultRepoButton, !settings.enableLocalScrapers && styles.disabledButton]}
onPress={handleUseDefaultRepo}
disabled={!settings.enableLocalScrapers}
>
<Text style={[styles.defaultRepoButtonText, !settings.enableLocalScrapers && styles.disabledText]}>Use Default Repository</Text>
</TouchableOpacity>
</View>
<View style={styles.repositoryActions}>
{repo.id !== currentRepositoryId && (
<TouchableOpacity
style={[styles.repositoryActionButton, styles.repositoryActionButtonPrimary]}
onPress={() => handleSwitchRepository(repo.id)}
disabled={switchingRepository === repo.id}
>
{switchingRepository === repo.id ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<Text style={styles.repositoryActionButtonText}>Switch</Text>
)}
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.repositoryActionButton, styles.repositoryActionButtonSecondary]}
onPress={() => handleRefreshRepository()}
disabled={isRefreshing || switchingRepository !== null}
>
{isRefreshing ? (
<ActivityIndicator size="small" color={colors.mediumGray} />
) : (
<Text style={styles.repositoryActionButtonText}>Refresh</Text>
)}
</TouchableOpacity>
{repositories.length > 1 && (
<TouchableOpacity
style={[styles.repositoryActionButton, styles.repositoryActionButtonDanger]}
onPress={() => handleRemoveRepository(repo.id)}
disabled={switchingRepository !== null}
>
<Text style={styles.repositoryActionButtonText}>Remove</Text>
</TouchableOpacity>
)}
</View>
</View>
))}
</View>
)}
<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.button, styles.primaryButton, !settings.enableLocalScrapers && styles.disabledButton]}
onPress={handleSaveRepository}
disabled={isLoading || !settings.enableLocalScrapers}
>
{isLoading ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={[styles.buttonText, !settings.enableLocalScrapers && styles.disabledText]}>Save Repository</Text>
)}
</TouchableOpacity>
{hasRepository && (
<TouchableOpacity
style={[styles.button, styles.secondaryButton, !settings.enableLocalScrapers && styles.disabledButton]}
onPress={handleRefreshRepository}
disabled={isRefreshing || !settings.enableLocalScrapers}
>
{isRefreshing ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<Text style={[styles.secondaryButtonText, !settings.enableLocalScrapers && styles.disabledText]}>Refresh</Text>
)}
</TouchableOpacity>
)}
</View>
{/* Add Repository Button */}
<TouchableOpacity
style={[styles.button, styles.primaryButton, { marginTop: 16 }]}
onPress={() => setShowAddRepositoryModal(true)}
disabled={!settings.enableLocalScrapers || switchingRepository !== null}
>
<Text style={styles.buttonText}>Add New Repository</Text>
</TouchableOpacity>
</CollapsibleSection>
{/* Available Scrapers */}
@ -1112,9 +1369,9 @@ const PluginsScreen: React.FC = () => {
{searchQuery.length > 0 && (
<TouchableOpacity onPress={() => setSearchQuery('')}>
<Ionicons name="close-circle" size={20} color={colors.mediumGray} />
</TouchableOpacity>
)}
</View>
</TouchableOpacity>
)}
</View>
{/* Filter Chips */}
<View style={styles.filterContainer}>
@ -1132,7 +1389,7 @@ const PluginsScreen: React.FC = () => {
selectedFilter === filter && styles.filterChipTextSelected
]}>
{filter === 'all' ? 'All' : filter === 'movie' ? 'Movies' : 'TV Shows'}
</Text>
</Text>
</TouchableOpacity>
))}
</View>
@ -1145,14 +1402,14 @@ const PluginsScreen: React.FC = () => {
onPress={() => handleBulkToggle(true)}
disabled={isRefreshing}
>
<Text style={styles.bulkActionButtonText}>Enable All</Text>
<Text style={[styles.bulkActionButtonText, { color: '#34C759' }]}>Enable All</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.bulkActionButton, styles.bulkActionButtonDisabled]}
onPress={() => handleBulkToggle(false)}
disabled={isRefreshing}
>
<Text style={styles.bulkActionButtonText}>Disable All</Text>
<Text style={[styles.bulkActionButtonText, { color: colors.mediumGray }]}>Disable All</Text>
</TouchableOpacity>
</View>
)}
@ -1169,7 +1426,7 @@ const PluginsScreen: React.FC = () => {
/>
<Text style={styles.emptyStateTitle}>
{searchQuery ? 'No Scrapers Found' : 'No Scrapers Available'}
</Text>
</Text>
<Text style={styles.emptyStateDescription}>
{searchQuery
? `No scrapers match "${searchQuery}". Try a different search term.`
@ -1178,42 +1435,42 @@ const PluginsScreen: React.FC = () => {
</Text>
{searchQuery && (
<TouchableOpacity
style={styles.button}
style={[styles.button, styles.secondaryButton]}
onPress={() => setSearchQuery('')}
>
<Text style={styles.buttonText}>Clear Search</Text>
<Text style={styles.secondaryButtonText}>Clear Search</Text>
</TouchableOpacity>
)}
</View>
) : (
<View style={styles.scrapersContainer}>
</View>
) : (
<View style={styles.scrapersContainer}>
{filteredScrapers.map((scraper) => (
<View key={scraper.id} style={styles.scraperCard}>
<View style={styles.scraperCardHeader}>
{scraper.logo ? (
<Image
source={{ uri: scraper.logo }}
{scraper.logo ? (
<Image
source={{ uri: scraper.logo }}
style={styles.scraperLogo}
resizeMode="contain"
/>
) : (
resizeMode="contain"
/>
) : (
<View style={styles.scraperLogo} />
)}
<View style={styles.scraperCardInfo}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}>
<Text style={styles.scraperName}>{scraper.name}</Text>
<StatusBadge status={getScraperStatus(scraper)} colors={colors} />
</View>
</View>
<Text style={styles.scraperDescription}>{scraper.description}</Text>
</View>
<Switch
value={scraper.enabled && settings.enableLocalScrapers}
onValueChange={(enabled) => 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'))}
/>
</View>
</View>
<Switch
value={scraper.enabled && settings.enableLocalScrapers}
onValueChange={(enabled) => 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'))}
/>
</View>
<View style={styles.scraperCardMeta}>
<View style={styles.scraperCardMetaItem}>
@ -1237,64 +1494,64 @@ const PluginsScreen: React.FC = () => {
</View>
{/* ShowBox Settings */}
{scraper.id === 'showboxog' && settings.enableLocalScrapers && (
{scraper.id === 'showboxog' && settings.enableLocalScrapers && (
<View style={{ marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: colors.elevation3 }}>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox Cookie</Text>
<TextInput
style={[styles.textInput, { marginBottom: 12 }]}
value={showboxCookie}
onChangeText={setShowboxCookie}
placeholder="Paste FebBox ui cookie value"
placeholderTextColor={colors.mediumGray}
autoCapitalize="none"
autoCorrect={false}
multiline={true}
numberOfLines={3}
/>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>Region</Text>
<View style={[styles.qualityChipsContainer, { marginBottom: 16 }]}>
{regionOptions.map(opt => {
const selected = showboxRegion === opt.value;
return (
<TouchableOpacity
key={opt.value}
style={[styles.qualityChip, selected && styles.qualityChipSelected]}
onPress={() => setShowboxRegion(opt.value)}
>
<Text style={[styles.qualityChipText, selected && styles.qualityChipTextSelected]}>
{opt.label}
</Text>
</TouchableOpacity>
);
})}
</View>
<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={async () => {
await localScraperService.setScraperSettings('showboxog', { cookie: showboxCookie, region: showboxRegion });
Alert.alert('Saved', 'ShowBox settings updated');
}}
>
<Text style={styles.buttonText}>Save</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={async () => {
setShowboxCookie('');
setShowboxRegion('');
await localScraperService.setScraperSettings('showboxog', {});
}}
>
<Text style={styles.secondaryButtonText}>Clear</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox Cookie</Text>
<TextInput
style={[styles.textInput, { marginBottom: 12 }]}
value={showboxCookie}
onChangeText={setShowboxCookie}
placeholder="Paste FebBox ui cookie value"
placeholderTextColor={colors.mediumGray}
autoCapitalize="none"
autoCorrect={false}
multiline={true}
numberOfLines={3}
/>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>Region</Text>
<View style={[styles.qualityChipsContainer, { marginBottom: 16 }]}>
{regionOptions.map(opt => {
const selected = showboxRegion === opt.value;
return (
<TouchableOpacity
key={opt.value}
style={[styles.qualityChip, selected && styles.qualityChipSelected]}
onPress={() => setShowboxRegion(opt.value)}
>
<Text style={[styles.qualityChipText, selected && styles.qualityChipTextSelected]}>
{opt.label}
</Text>
</TouchableOpacity>
);
})}
</View>
<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={async () => {
await localScraperService.setScraperSettings('showboxog', { cookie: showboxCookie, region: showboxRegion });
Alert.alert('Saved', 'ShowBox settings updated');
}}
>
<Text style={styles.buttonText}>Save</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={async () => {
setShowboxCookie('');
setShowboxRegion('');
await localScraperService.setScraperSettings('showboxog', {});
}}
>
<Text style={styles.secondaryButtonText}>Clear</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
))}
</View>
)}
</View>
)}
</CollapsibleSection>
{/* Additional Settings */}
@ -1445,7 +1702,7 @@ const PluginsScreen: React.FC = () => {
1. <Text style={{ fontWeight: '600' }}>Enable Local Scrapers</Text> - Turn on the main switch to allow plugins
</Text>
<Text style={styles.modalText}>
2. <Text style={{ fontWeight: '600' }}>Set Repository URL</Text> - Enter a GitHub raw URL or use the default repository
2. <Text style={{ fontWeight: '600' }}>Add Repository</Text> - Add a GitHub raw URL or use the default repository
</Text>
<Text style={styles.modalText}>
3. <Text style={{ fontWeight: '600' }}>Refresh Repository</Text> - Download available scrapers from the repository
@ -1462,6 +1719,85 @@ const PluginsScreen: React.FC = () => {
</View>
</View>
</Modal>
{/* Add Repository Modal */}
<Modal
visible={showAddRepositoryModal}
transparent={true}
animationType="fade"
onRequestClose={() => setShowAddRepositoryModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Add New Repository</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8 }}>
<Text style={styles.settingTitle}>Repository Name</Text>
{fetchingRepoName && (
<ActivityIndicator size="small" color={colors.primary} style={{ marginLeft: 8 }} />
)}
</View>
<TextInput
style={styles.textInput}
value={newRepositoryName}
onChangeText={setNewRepositoryName}
placeholder="Enter repository name"
placeholderTextColor={colors.mediumGray}
autoCapitalize="words"
/>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>Repository URL</Text>
<TextInput
style={styles.textInput}
value={newRepositoryUrl}
onChangeText={handleUrlChange}
placeholder="https://raw.githubusercontent.com/username/repo/branch/"
placeholderTextColor={colors.mediumGray}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>Description (Optional)</Text>
<TextInput
style={[styles.textInput, { height: 80 }]}
value={newRepositoryDescription}
onChangeText={setNewRepositoryDescription}
placeholder="Enter repository description"
placeholderTextColor={colors.mediumGray}
multiline={true}
numberOfLines={3}
/>
<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.button, styles.secondaryButton, { flex: 1 }]}
onPress={() => {
setShowAddRepositoryModal(false);
setNewRepositoryUrl('');
setNewRepositoryName('');
setNewRepositoryDescription('');
setFetchingRepoName(false);
}}
>
<Text style={styles.secondaryButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.primaryButton, { flex: 1 }]}
onPress={handleAddRepository}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={styles.buttonText}>Add Repository</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
};

View file

@ -31,6 +31,18 @@ export interface ScraperInfo {
// We support both `formats` and `supportedFormats` keys for manifest flexibility.
formats?: string[];
supportedFormats?: string[];
repositoryId?: string; // Which repository this scraper came from
}
export interface RepositoryInfo {
id: string;
name: string;
url: string;
description?: string;
isDefault?: boolean;
enabled: boolean;
lastUpdated?: number;
scraperCount?: number;
}
export interface LocalScraperResult {
@ -55,12 +67,17 @@ class LocalScraperService {
private static instance: LocalScraperService;
private readonly STORAGE_KEY = 'local-scrapers';
private readonly REPOSITORY_KEY = 'scraper-repository-url';
private readonly REPOSITORIES_KEY = 'scraper-repositories';
private readonly SCRAPER_SETTINGS_KEY = 'scraper-settings';
private installedScrapers: Map<string, ScraperInfo> = new Map();
private scraperCode: Map<string, string> = new Map();
private repositories: Map<string, RepositoryInfo> = new Map();
private currentRepositoryId: string = '';
private repositoryUrl: string = '';
private repositoryName: string = '';
private initialized: boolean = false;
private autoRefreshCompleted: boolean = false;
private isRefreshing: boolean = false;
private scraperSettingsCache: Record<string, any> | null = null;
private constructor() {
@ -78,10 +95,43 @@ class LocalScraperService {
if (this.initialized) return;
try {
// Load repository URL
const storedRepoUrl = await AsyncStorage.getItem(this.REPOSITORY_KEY);
if (storedRepoUrl) {
this.repositoryUrl = storedRepoUrl;
// Load repositories
const repositoriesData = await AsyncStorage.getItem(this.REPOSITORIES_KEY);
if (repositoriesData) {
const repos = JSON.parse(repositoriesData);
this.repositories = new Map(Object.entries(repos));
} else {
// Migrate from old single repository format
const storedRepoUrl = await AsyncStorage.getItem(this.REPOSITORY_KEY);
if (storedRepoUrl) {
const defaultRepo: RepositoryInfo = {
id: 'default',
name: this.extractRepositoryName(storedRepoUrl),
url: storedRepoUrl,
description: 'Default repository',
isDefault: true,
enabled: true,
lastUpdated: Date.now()
};
this.repositories.set('default', defaultRepo);
this.currentRepositoryId = 'default';
await this.saveRepositories();
}
}
// Load current repository
const currentRepoId = await AsyncStorage.getItem('current-repository-id');
if (currentRepoId && this.repositories.has(currentRepoId)) {
this.currentRepositoryId = currentRepoId;
const currentRepo = this.repositories.get(currentRepoId)!;
this.repositoryUrl = currentRepo.url;
this.repositoryName = currentRepo.name;
} else if (this.repositories.size > 0) {
// Use first repository as default
const firstRepo = Array.from(this.repositories.values())[0];
this.currentRepositoryId = firstRepo.id;
this.repositoryUrl = firstRepo.url;
this.repositoryName = firstRepo.name;
}
// Load installed scrapers
@ -156,14 +206,16 @@ class LocalScraperService {
// Load scraper code from cache
await this.loadScraperCode();
// Auto-refresh repository on app startup if URL is configured
if (this.repositoryUrl) {
// Auto-refresh repository on app startup if URL is configured (only once)
if (this.repositoryUrl && !this.autoRefreshCompleted) {
try {
logger.log('[LocalScraperService] Auto-refreshing repository on startup');
await this.performRepositoryRefresh();
this.autoRefreshCompleted = true;
} catch (error) {
logger.error('[LocalScraperService] Auto-refresh failed on startup:', error);
// Don't fail initialization if auto-refresh fails
this.autoRefreshCompleted = true; // Mark as completed even on error to prevent retries
}
}
@ -199,6 +251,199 @@ class LocalScraperService {
return this.repositoryName || 'Plugins';
}
// Multiple repository management methods
async getRepositories(): Promise<RepositoryInfo[]> {
await this.ensureInitialized();
return Array.from(this.repositories.values());
}
async addRepository(repo: Omit<RepositoryInfo, 'id' | 'lastUpdated' | 'scraperCount'>): Promise<string> {
await this.ensureInitialized();
const id = `repo_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Try to fetch the repository name from manifest if not provided
let repositoryName = repo.name;
if (!repositoryName || repositoryName.trim() === '') {
try {
repositoryName = await this.fetchRepositoryNameFromManifest(repo.url);
} catch (error) {
logger.warn('[LocalScraperService] Failed to fetch repository name from manifest, using fallback:', error);
repositoryName = this.extractRepositoryName(repo.url);
}
}
const newRepo: RepositoryInfo = {
...repo,
name: repositoryName,
id,
lastUpdated: Date.now(),
scraperCount: 0
};
this.repositories.set(id, newRepo);
await this.saveRepositories();
logger.log('[LocalScraperService] Added repository:', newRepo.name);
return id;
}
async updateRepository(id: string, updates: Partial<RepositoryInfo>): Promise<void> {
await this.ensureInitialized();
const repo = this.repositories.get(id);
if (!repo) {
throw new Error(`Repository with id ${id} not found`);
}
const updatedRepo = { ...repo, ...updates };
this.repositories.set(id, updatedRepo);
await this.saveRepositories();
// If this is the current repository, update current values
if (id === this.currentRepositoryId) {
this.repositoryUrl = updatedRepo.url;
this.repositoryName = updatedRepo.name;
}
logger.log('[LocalScraperService] Updated repository:', updatedRepo.name);
}
async removeRepository(id: string): Promise<void> {
await this.ensureInitialized();
if (!this.repositories.has(id)) {
throw new Error(`Repository with id ${id} not found`);
}
// Don't allow removing the last repository
if (this.repositories.size <= 1) {
throw new Error('Cannot remove the last repository');
}
// If removing current repository, switch to another one
if (id === this.currentRepositoryId) {
const remainingRepos = Array.from(this.repositories.values()).filter(r => r.id !== id);
if (remainingRepos.length > 0) {
await this.setCurrentRepository(remainingRepos[0].id);
}
}
// Remove scrapers from this repository
const scrapersToRemove = Array.from(this.installedScrapers.values())
.filter(s => s.repositoryId === id)
.map(s => s.id);
for (const scraperId of scrapersToRemove) {
this.installedScrapers.delete(scraperId);
this.scraperCode.delete(scraperId);
await AsyncStorage.removeItem(`scraper-code-${scraperId}`);
}
this.repositories.delete(id);
await this.saveRepositories();
await this.saveInstalledScrapers();
logger.log('[LocalScraperService] Removed repository:', id);
}
async setCurrentRepository(id: string): Promise<void> {
await this.ensureInitialized();
const repo = this.repositories.get(id);
if (!repo) {
throw new Error(`Repository with id ${id} not found`);
}
this.currentRepositoryId = id;
this.repositoryUrl = repo.url;
this.repositoryName = repo.name;
await AsyncStorage.setItem('current-repository-id', id);
// Refresh the repository to get its scrapers
try {
logger.log('[LocalScraperService] Refreshing repository after switch:', repo.name);
await this.performRepositoryRefresh();
} catch (error) {
logger.error('[LocalScraperService] Failed to refresh repository after switch:', error);
// Don't throw error, just log it - the switch should still succeed
}
logger.log('[LocalScraperService] Switched to repository:', repo.name);
}
getCurrentRepositoryId(): string {
return this.currentRepositoryId;
}
// Public method to extract repository name from URL
extractRepositoryName(url: string): string {
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/').filter(part => part.length > 0);
if (pathParts.length >= 2) {
return `${pathParts[0]}/${pathParts[1]}`;
}
return urlObj.hostname || 'Unknown Repository';
} catch {
return 'Unknown Repository';
}
}
// Fetch repository name from manifest.json
async fetchRepositoryNameFromManifest(repositoryUrl: string): Promise<string> {
try {
logger.log('[LocalScraperService] Fetching repository name from manifest:', repositoryUrl);
// Construct manifest URL
const baseManifestUrl = repositoryUrl.endsWith('/')
? `${repositoryUrl}manifest.json`
: `${repositoryUrl}/manifest.json`;
const manifestUrl = `${baseManifestUrl}?t=${Date.now()}`;
const response = await axios.get(manifestUrl, {
timeout: 10000,
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
});
if (response.data && response.data.name) {
logger.log('[LocalScraperService] Found repository name in manifest:', response.data.name);
return response.data.name;
} else {
logger.warn('[LocalScraperService] No name found in manifest, using fallback');
return this.extractRepositoryName(repositoryUrl);
}
} catch (error) {
logger.error('[LocalScraperService] Failed to fetch repository name from manifest:', error);
throw error;
}
}
// Update repository name from manifest for existing repositories
async refreshRepositoryNamesFromManifests(): Promise<void> {
await this.ensureInitialized();
for (const [id, repo] of this.repositories) {
try {
const manifestName = await this.fetchRepositoryNameFromManifest(repo.url);
if (manifestName !== repo.name) {
logger.log('[LocalScraperService] Updating repository name:', repo.name, '->', manifestName);
repo.name = manifestName;
// If this is the current repository, update the current name
if (id === this.currentRepositoryId) {
this.repositoryName = manifestName;
}
}
} catch (error) {
logger.warn('[LocalScraperService] Failed to refresh name for repository:', repo.name, error);
}
}
await this.saveRepositories();
}
private async saveRepositories(): Promise<void> {
const reposObject = Object.fromEntries(this.repositories);
await AsyncStorage.setItem(this.REPOSITORIES_KEY, JSON.stringify(reposObject));
}
// Check if a scraper is compatible with the current platform
private isPlatformCompatible(scraper: ScraperInfo): boolean {
const currentPlatform = Platform.OS as 'ios' | 'android';
@ -223,6 +468,7 @@ class LocalScraperService {
async refreshRepository(): Promise<void> {
await this.ensureInitialized();
await this.performRepositoryRefresh();
this.autoRefreshCompleted = true; // Mark as completed after manual refresh
}
// Internal method to refresh repository without initialization check
@ -231,6 +477,14 @@ class LocalScraperService {
throw new Error('No repository URL configured');
}
// Prevent multiple simultaneous refreshes
if (this.isRefreshing) {
logger.log('[LocalScraperService] Repository refresh already in progress, skipping');
return;
}
this.isRefreshing = true;
try {
logger.log('[LocalScraperService] Fetching repository manifest from:', this.repositoryUrl);
@ -285,8 +539,10 @@ class LocalScraperService {
const isPlatformCompatible = this.isPlatformCompatible(scraperInfo);
if (isPlatformCompatible) {
// Add repository ID to scraper info
const scraperWithRepo = { ...scraperInfo, repositoryId: this.currentRepositoryId };
// Download/update the scraper (downloadScraper handles force disabling based on manifest.enabled)
await this.downloadScraper(scraperInfo);
await this.downloadScraper(scraperWithRepo);
} else {
logger.log('[LocalScraperService] Skipping platform-incompatible scraper:', scraperInfo.name);
// Remove if it was previously installed but is now platform-incompatible
@ -300,11 +556,25 @@ class LocalScraperService {
}
await this.saveInstalledScrapers();
// Update repository info
const currentRepo = this.repositories.get(this.currentRepositoryId);
if (currentRepo) {
const scraperCount = Array.from(this.installedScrapers.values())
.filter(s => s.repositoryId === this.currentRepositoryId).length;
await this.updateRepository(this.currentRepositoryId, {
lastUpdated: Date.now(),
scraperCount
});
}
logger.log('[LocalScraperService] Repository refresh completed');
} catch (error) {
logger.error('[LocalScraperService] Failed to refresh repository:', error);
throw error;
} finally {
this.isRefreshing = false;
}
}