refactor for tf

This commit is contained in:
tapframe 2025-12-12 14:29:02 +05:30
parent 8a34bf6678
commit 9cc8b2ea67
6 changed files with 431 additions and 602 deletions

View file

@ -155,7 +155,7 @@ export type RootStackParamList = {
TraktSettings: undefined; TraktSettings: undefined;
PlayerSettings: undefined; PlayerSettings: undefined;
ThemeSettings: undefined; ThemeSettings: undefined;
ScraperSettings: undefined; PluginSettings: undefined;
CastMovies: { CastMovies: {
castMember: { castMember: {
id: number; id: number;
@ -1463,7 +1463,7 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
}} }}
/> />
<Stack.Screen <Stack.Screen
name="ScraperSettings" name="PluginSettings"
component={PluginsScreen} component={PluginsScreen}
options={{ options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',

View file

@ -46,7 +46,7 @@ if (Platform.OS === 'ios') {
} }
} }
// Removed community blur and expo-constants for Android overlay // Removed community blur and expo-constants for Android overlay
import axios from 'axios';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
// Extend Manifest type to include logo only (remove disabled status) // Extend Manifest type to include logo only (remove disabled status)
@ -60,11 +60,7 @@ interface ExtendedManifest extends Manifest {
}; };
} }
// Interface for Community Addon structure from the JSON URL
interface CommunityAddon {
transportUrl: string;
manifest: ExtendedManifest;
}
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
@ -623,10 +619,7 @@ const AddonsScreen = () => {
const colors = currentTheme.colors; const colors = currentTheme.colors;
const styles = createStyles(colors); const styles = createStyles(colors);
// State for community addons
const [communityAddons, setCommunityAddons] = useState<CommunityAddon[]>([]);
const [communityLoading, setCommunityLoading] = useState(true);
const [communityError, setCommunityError] = useState<string | null>(null);
// Promotional addon: Nuvio Streams // Promotional addon: Nuvio Streams
const PROMO_ADDON_URL = 'https://nuviostreams.hayd.uk/manifest.json'; const PROMO_ADDON_URL = 'https://nuviostreams.hayd.uk/manifest.json';
@ -652,7 +645,6 @@ const AddonsScreen = () => {
useEffect(() => { useEffect(() => {
loadAddons(); loadAddons();
loadCommunityAddons();
}, []); }, []);
const loadAddons = async () => { const loadAddons = async () => {
@ -702,27 +694,7 @@ const AddonsScreen = () => {
} }
}; };
// Function to load community addons
const loadCommunityAddons = async () => {
setCommunityLoading(true);
setCommunityError(null);
try {
const response = await axios.get<CommunityAddon[]>('https://stremio-addons.com/catalog.json');
// Filter out addons without a manifest or transportUrl (basic validation)
let validAddons = response.data.filter(addon => addon.manifest && addon.transportUrl);
// Filter out Cinemeta since it's now pre-installed
validAddons = validAddons.filter(addon => addon.manifest.id !== 'com.linvo.cinemeta');
setCommunityAddons(validAddons);
} catch (error) {
logger.error('Failed to load community addons:', error);
setCommunityError('Failed to load community addons. Please try again later.');
setCommunityAddons([]);
} finally {
setCommunityLoading(false);
}
};
const handleAddAddon = async (url?: string) => { const handleAddAddon = async (url?: string) => {
let urlToInstall = url || addonUrl; let urlToInstall = url || addonUrl;
@ -783,7 +755,6 @@ const AddonsScreen = () => {
const refreshAddons = async () => { const refreshAddons = async () => {
loadAddons(); loadAddons();
loadCommunityAddons();
}; };
const moveAddonUp = (addon: ExtendedManifest) => { const moveAddonUp = (addon: ExtendedManifest) => {
@ -835,7 +806,7 @@ const AddonsScreen = () => {
configUrl = addon.behaviorHints.configurationURL; configUrl = addon.behaviorHints.configurationURL;
logger.info(`Using configurationURL from behaviorHints: ${configUrl}`); logger.info(`Using configurationURL from behaviorHints: ${configUrl}`);
} }
// If a transport URL was provided directly (for community addons) // If a transport URL was provided directly
else if (transportUrl) { else if (transportUrl) {
// Remove any trailing filename like manifest.json // Remove any trailing filename like manifest.json
const baseUrl = transportUrl.replace(/\/[^\/]+\.json$/, '/'); const baseUrl = transportUrl.replace(/\/[^\/]+\.json$/, '/');
@ -1057,65 +1028,7 @@ const AddonsScreen = () => {
); );
}; };
// Function to render community addon items
const renderCommunityAddonItem = ({ item }: { item: CommunityAddon }) => {
const { manifest, transportUrl } = item;
const types = manifest.types || [];
const description = manifest.description || 'No description provided.';
// @ts-ignore - logo might exist
const logo = manifest.logo || null;
const categoryText = types.length > 0
? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
: 'General';
// Check if addon is configurable
const isConfigurable = manifest.behaviorHints?.configurable === true;
return (
<View style={styles.communityAddonItem}>
{logo ? (
<FastImage
source={{ uri: logo }}
style={styles.communityAddonIcon}
resizeMode={FastImage.resizeMode.contain}
/>
) : (
<View style={styles.communityAddonIconPlaceholder}>
<MaterialIcons name="extension" size={22} color={colors.darkGray} />
</View>
)}
<View style={styles.communityAddonDetails}>
<Text style={styles.communityAddonName}>{manifest.name}</Text>
<Text style={styles.communityAddonDesc} numberOfLines={2}>{description}</Text>
<View style={styles.communityAddonMetaContainer}>
<Text style={styles.communityAddonVersion}>v{manifest.version || 'N/A'}</Text>
<Text style={styles.communityAddonDot}></Text>
<Text style={styles.communityAddonCategory}>{categoryText}</Text>
</View>
</View>
<View style={styles.addonActionButtons}>
{isConfigurable && (
<TouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(manifest, transportUrl)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.installButton, installing && { opacity: 0.6 }]}
onPress={() => handleAddAddon(transportUrl)}
disabled={installing}
>
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</View>
</View>
);
};
const StatsCard = ({ value, label }: { value: number; label: string }) => ( const StatsCard = ({ value, label }: { value: number; label: string }) => (
<View style={styles.statsCard}> <View style={styles.statsCard}>
@ -1316,91 +1229,7 @@ const AddonsScreen = () => {
</View> </View>
)} )}
{/* Community Addons Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>COMMUNITY ADDONS</Text>
<View style={styles.addonList}>
{communityLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : communityError ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="error-outline" size={32} color={colors.error} />
<Text style={styles.emptyText}>{communityError}</Text>
</View>
) : communityAddons.length === 0 ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="extension-off" size={32} color={colors.mediumGray} />
<Text style={styles.emptyText}>No community addons available</Text>
</View>
) : (
communityAddons.map((item, index) => (
<View
key={item.transportUrl}
style={{ marginBottom: index === communityAddons.length - 1 ? 32 : 16 }}
>
<View style={styles.addonItem}>
<View style={styles.addonHeader}>
{item.manifest.logo ? (
<FastImage
source={{ uri: item.manifest.logo }}
style={styles.addonIcon}
resizeMode={FastImage.resizeMode.contain}
/>
) : (
<View style={styles.addonIconPlaceholder}>
<MaterialIcons name="extension" size={22} color={colors.mediumGray} />
</View>
)}
<View style={styles.addonTitleContainer}>
<Text style={styles.addonName}>{item.manifest.name}</Text>
<View style={styles.addonMetaContainer}>
<Text style={styles.addonVersion}>v{item.manifest.version || 'N/A'}</Text>
<Text style={styles.addonDot}></Text>
<Text style={styles.addonCategory}>
{item.manifest.types && item.manifest.types.length > 0
? item.manifest.types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
: 'General'}
</Text>
</View>
</View>
<View style={styles.addonActions}>
{item.manifest.behaviorHints?.configurable && (
<TouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(item.manifest, item.transportUrl)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.installButton, installing && { opacity: 0.6 }]}
onPress={() => handleAddAddon(item.transportUrl)}
disabled={installing}
>
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</View>
</View>
<Text style={styles.addonDescription}>
{item.manifest.description
? (item.manifest.description.length > 100
? item.manifest.description.substring(0, 100) + '...'
: item.manifest.description)
: 'No description provided.'}
</Text>
</View>
</View>
))
)}
</View>
</View>
</ScrollView> </ScrollView>
)} )}

View file

@ -78,17 +78,17 @@ const createStyles = (colors: any) => StyleSheet.create({
padding: 16, padding: 16,
}, },
sectionTitle: { sectionTitle: {
fontSize: 20, fontSize: 20,
fontWeight: '600', fontWeight: '600',
color: colors.white, color: colors.white,
marginBottom: 8, marginBottom: 8,
}, },
sectionHeader: { sectionHeader: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: 16, marginBottom: 16,
}, },
sectionDescription: { sectionDescription: {
fontSize: 14, fontSize: 14,
color: colors.mediumGray, color: colors.mediumGray,
@ -283,59 +283,59 @@ const createStyles = (colors: any) => StyleSheet.create({
marginTop: 8, marginTop: 8,
}, },
infoText: { infoText: {
fontSize: 14, fontSize: 14,
color: colors.mediumEmphasis, color: colors.mediumEmphasis,
lineHeight: 20, lineHeight: 20,
}, },
content: { content: {
flex: 1, flex: 1,
}, },
emptyState: { emptyState: {
alignItems: 'center', alignItems: 'center',
paddingVertical: 32, paddingVertical: 32,
}, },
emptyStateTitle: { emptyStateTitle: {
fontSize: 18, fontSize: 18,
fontWeight: '600', fontWeight: '600',
color: colors.white, color: colors.white,
marginTop: 16, marginTop: 16,
marginBottom: 8, marginBottom: 8,
}, },
emptyStateDescription: { emptyStateDescription: {
fontSize: 14, fontSize: 14,
color: colors.mediumGray, color: colors.mediumGray,
textAlign: 'center', textAlign: 'center',
lineHeight: 20, lineHeight: 20,
}, },
scrapersList: { scrapersList: {
gap: 12, gap: 12,
}, },
scrapersContainer: { scrapersContainer: {
marginBottom: 24, marginBottom: 24,
}, },
inputContainer: { inputContainer: {
marginBottom: 16, marginBottom: 16,
}, },
lastSection: { lastSection: {
borderBottomWidth: 0, borderBottomWidth: 0,
}, },
disabledSection: { disabledSection: {
opacity: 0.5, opacity: 0.5,
}, },
disabledText: { disabledText: {
color: colors.elevation3, color: colors.elevation3,
}, },
disabledContainer: { disabledContainer: {
opacity: 0.5, opacity: 0.5,
}, },
disabledInput: { disabledInput: {
backgroundColor: colors.elevation1, backgroundColor: colors.elevation1,
opacity: 0.5, opacity: 0.5,
}, },
disabledButton: { disabledButton: {
opacity: 0.5, opacity: 0.5,
}, },
disabledImage: { disabledImage: {
opacity: 0.3, opacity: 0.3,
}, },
availableIndicator: { availableIndicator: {
@ -842,7 +842,7 @@ const PluginsScreen: React.FC = () => {
) => { ) => {
setAlertTitle(title); setAlertTitle(title);
setAlertMessage(message); setAlertMessage(message);
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]);
setAlertVisible(true); setAlertVisible(true);
}; };
@ -940,7 +940,7 @@ const PluginsScreen: React.FC = () => {
await loadScrapers(); await loadScrapers();
openAlert('Success', `${enabled ? 'Enabled' : 'Disabled'} ${filteredScrapers.length} scrapers`); openAlert('Success', `${enabled ? 'Enabled' : 'Disabled'} ${filteredScrapers.length} scrapers`);
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to bulk toggle:', error); logger.error('[PluginsScreen] Failed to bulk toggle:', error);
openAlert('Error', 'Failed to update scrapers'); openAlert('Error', 'Failed to update scrapers');
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
@ -1021,7 +1021,7 @@ const PluginsScreen: React.FC = () => {
await loadScrapers(); await loadScrapers();
openAlert('Success', 'Repository switched successfully'); openAlert('Success', 'Repository switched successfully');
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to switch repository:', error); logger.error('[PluginsScreen] Failed to switch repository:', error);
openAlert('Error', 'Failed to switch repository'); openAlert('Error', 'Failed to switch repository');
} finally { } finally {
setSwitchingRepository(null); setSwitchingRepository(null);
@ -1044,7 +1044,7 @@ const PluginsScreen: React.FC = () => {
alertTitle, alertTitle,
alertMessage, alertMessage,
[ [
{ label: 'Cancel', onPress: () => {} }, { label: 'Cancel', onPress: () => { } },
{ {
label: 'Remove', label: 'Remove',
onPress: async () => { onPress: async () => {
@ -1057,7 +1057,7 @@ const PluginsScreen: React.FC = () => {
: 'Repository removed successfully'; : 'Repository removed successfully';
openAlert('Success', successMessage); openAlert('Success', successMessage);
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to remove repository:', error); logger.error('[PluginsScreen] Failed to remove repository:', error);
openAlert('Error', error instanceof Error ? error.message : 'Failed to remove repository'); openAlert('Error', error instanceof Error ? error.message : 'Failed to remove repository');
} }
}, },
@ -1097,7 +1097,7 @@ const PluginsScreen: React.FC = () => {
setShowboxTokenVisible(false); setShowboxTokenVisible(false);
} }
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to load scrapers:', error); logger.error('[PluginsScreen] Failed to load scrapers:', error);
} }
}; };
@ -1118,7 +1118,7 @@ const PluginsScreen: React.FC = () => {
setRepositoryUrl(currentRepo.url); setRepositoryUrl(currentRepo.url);
} }
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to load repositories:', error); logger.error('[PluginsScreen] Failed to load repositories:', error);
} }
}; };
@ -1130,7 +1130,7 @@ const PluginsScreen: React.FC = () => {
setRepositoryUrl(repoUrl); setRepositoryUrl(repoUrl);
} }
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to check repository:', error); logger.error('[PluginsScreen] Failed to check repository:', error);
} }
}; };
@ -1157,7 +1157,7 @@ const PluginsScreen: React.FC = () => {
setHasRepository(true); setHasRepository(true);
openAlert('Success', 'Repository URL saved successfully'); openAlert('Success', 'Repository URL saved successfully');
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to save repository:', error); logger.error('[PluginsScreen] Failed to save repository:', error);
openAlert('Error', 'Failed to save repository URL'); openAlert('Error', 'Failed to save repository URL');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -1211,28 +1211,28 @@ const PluginsScreen: React.FC = () => {
await pluginService.setScraperEnabled(scraperId, enabled); await pluginService.setScraperEnabled(scraperId, enabled);
await loadScrapers(); await loadScrapers();
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to toggle scraper:', error); logger.error('[PluginsScreen] Failed to toggle plugin:', error);
openAlert('Error', 'Failed to update scraper status'); openAlert('Error', 'Failed to update plugin status');
setIsRefreshing(false); setIsRefreshing(false);
} }
}; };
const handleClearScrapers = () => { const handleClearScrapers = () => {
openAlert( openAlert(
'Clear All Scrapers', 'Clear All Plugins',
'Are you sure you want to remove all installed scrapers? This action cannot be undone.', 'Are you sure you want to remove all installed plugins? This action cannot be undone.',
[ [
{ label: 'Cancel', onPress: () => {} }, { label: 'Cancel', onPress: () => { } },
{ {
label: 'Clear', label: 'Clear',
onPress: async () => { onPress: async () => {
try { try {
await pluginService.clearScrapers(); await pluginService.clearScrapers();
await loadScrapers(); await loadScrapers();
openAlert('Success', 'All scrapers have been removed'); openAlert('Success', 'All plugins have been removed');
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to clear scrapers:', error); logger.error('[PluginsScreen] Failed to clear plugins:', error);
openAlert('Error', 'Failed to clear scrapers'); openAlert('Error', 'Failed to clear plugins');
} }
}, },
}, },
@ -1243,9 +1243,9 @@ const PluginsScreen: React.FC = () => {
const handleClearCache = () => { const handleClearCache = () => {
openAlert( openAlert(
'Clear Repository Cache', '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.', 'This will remove the saved repository URL and clear all cached plugin data. You will need to re-enter your repository URL.',
[ [
{ label: 'Cancel', onPress: () => {} }, { label: 'Cancel', onPress: () => { } },
{ {
label: 'Clear Cache', label: 'Clear Cache',
onPress: async () => { onPress: async () => {
@ -1258,7 +1258,7 @@ const PluginsScreen: React.FC = () => {
await loadScrapers(); await loadScrapers();
openAlert('Success', 'Repository cache cleared successfully'); openAlert('Success', 'Repository cache cleared successfully');
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to clear cache:', error); logger.error('[PluginsScreen] Failed to clear cache:', error);
openAlert('Error', 'Failed to clear repository cache'); openAlert('Error', 'Failed to clear repository cache');
} }
}, },
@ -1427,7 +1427,7 @@ const PluginsScreen: React.FC = () => {
styles={styles} styles={styles}
> >
<Text style={styles.sectionDescription}> <Text style={styles.sectionDescription}>
Manage multiple scraper repositories. Switch between repositories to access different sets of scrapers. Manage multiple plugin repositories. Switch between repositories to access different sets of plugins.
</Text> </Text>
{/* Current Repository */} {/* Current Repository */}
@ -1466,9 +1466,9 @@ const PluginsScreen: React.FC = () => {
)} )}
<Text style={styles.repositoryUrl}>{repo.url}</Text> <Text style={styles.repositoryUrl}>{repo.url}</Text>
<Text style={styles.repositoryMeta}> <Text style={styles.repositoryMeta}>
{repo.scraperCount || 0} scrapers Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'} {repo.scraperCount || 0} plugins Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'}
</Text> </Text>
</View> </View>
<View style={styles.repositoryActions}> <View style={styles.repositoryActions}>
{repo.id !== currentRepositoryId && ( {repo.id !== currentRepositoryId && (
<TouchableOpacity <TouchableOpacity
@ -1502,7 +1502,7 @@ const PluginsScreen: React.FC = () => {
<Text style={styles.repositoryActionButtonText}>Remove</Text> <Text style={styles.repositoryActionButtonText}>Remove</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
))} ))}
</View> </View>
)} )}
@ -1535,15 +1535,15 @@ const PluginsScreen: React.FC = () => {
style={styles.searchInput} style={styles.searchInput}
value={searchQuery} value={searchQuery}
onChangeText={setSearchQuery} onChangeText={setSearchQuery}
placeholder="Search scrapers..." placeholder="Search plugins..."
placeholderTextColor={colors.mediumGray} placeholderTextColor={colors.mediumGray}
/> />
{searchQuery.length > 0 && ( {searchQuery.length > 0 && (
<TouchableOpacity onPress={() => setSearchQuery('')}> <TouchableOpacity onPress={() => setSearchQuery('')}>
<Ionicons name="close-circle" size={20} color={colors.mediumGray} /> <Ionicons name="close-circle" size={20} color={colors.mediumGray} />
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
{/* Filter Chips */} {/* Filter Chips */}
<View style={styles.filterContainer}> <View style={styles.filterContainer}>
@ -1561,7 +1561,7 @@ const PluginsScreen: React.FC = () => {
selectedFilter === filter && styles.filterChipTextSelected selectedFilter === filter && styles.filterChipTextSelected
]}> ]}>
{filter === 'all' ? 'All' : filter === 'movie' ? 'Movies' : 'TV Shows'} {filter === 'all' ? 'All' : filter === 'movie' ? 'Movies' : 'TV Shows'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
@ -1597,12 +1597,12 @@ const PluginsScreen: React.FC = () => {
style={styles.emptyStateIcon} style={styles.emptyStateIcon}
/> />
<Text style={styles.emptyStateTitle}> <Text style={styles.emptyStateTitle}>
{searchQuery ? 'No Scrapers Found' : 'No Scrapers Available'} {searchQuery ? 'No Plugins Found' : 'No Plugins Available'}
</Text> </Text>
<Text style={styles.emptyStateDescription}> <Text style={styles.emptyStateDescription}>
{searchQuery {searchQuery
? `No scrapers match "${searchQuery}". Try a different search term.` ? `No plugins match "${searchQuery}". Try a different search term.`
: 'Configure a repository above to view available scrapers.' : 'Configure a repository above to view available plugins.'
} }
</Text> </Text>
{searchQuery && ( {searchQuery && (
@ -1613,44 +1613,44 @@ const PluginsScreen: React.FC = () => {
<Text style={styles.secondaryButtonText}>Clear Search</Text> <Text style={styles.secondaryButtonText}>Clear Search</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
) : ( ) : (
<View style={styles.scrapersContainer}> <View style={styles.scrapersContainer}>
{filteredScrapers.map((scraper) => ( {filteredScrapers.map((scraper) => (
<View key={scraper.id} style={styles.scraperCard}> <View key={scraper.id} style={styles.scraperCard}>
<View style={styles.scraperCardHeader}> <View style={styles.scraperCardHeader}>
{scraper.logo ? ( {scraper.logo ? (
(scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? ( (scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? (
<Image <Image
source={{ uri: scraper.logo }} source={{ uri: scraper.logo }}
style={styles.scraperLogo} style={styles.scraperLogo}
resizeMode="contain" resizeMode="contain"
/> />
) : ( ) : (
<FastImage <FastImage
source={{ uri: scraper.logo }} source={{ uri: scraper.logo }}
style={styles.scraperLogo} style={styles.scraperLogo}
resizeMode={FastImage.resizeMode.contain} resizeMode={FastImage.resizeMode.contain}
/> />
) )
) : ( ) : (
<View style={styles.scraperLogo} /> <View style={styles.scraperLogo} />
)} )}
<View style={styles.scraperCardInfo}> <View style={styles.scraperCardInfo}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}> <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}>
<Text style={styles.scraperName}>{scraper.name}</Text> <Text style={styles.scraperName}>{scraper.name}</Text>
<StatusBadge status={getScraperStatus(scraper)} colors={colors} /> <StatusBadge status={getScraperStatus(scraper)} colors={colors} />
</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>
<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 style={styles.scraperCardMeta}> <View style={styles.scraperCardMeta}>
<View style={styles.scraperCardMetaItem}> <View style={styles.scraperCardMetaItem}>
@ -1682,62 +1682,62 @@ const PluginsScreen: React.FC = () => {
</View> </View>
{/* ShowBox Settings - only visible when ShowBox scraper is available */} {/* ShowBox Settings - only visible when ShowBox scraper is available */}
{showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && ( {showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && (
<View style={{ marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: colors.elevation3 }}> <View style={{ marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: colors.elevation3 }}>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox UI Token</Text> <Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox UI Token</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}> <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}>
<TextInput <TextInput
style={[styles.textInput, { flex: 1, marginBottom: 0 }]} style={[styles.textInput, { flex: 1, marginBottom: 0 }]}
value={showboxUiToken} value={showboxUiToken}
onChangeText={setShowboxUiToken} onChangeText={setShowboxUiToken}
placeholder="Paste your ShowBox UI token" placeholder="Paste your ShowBox UI token"
placeholderTextColor={colors.mediumGray} placeholderTextColor={colors.mediumGray}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
secureTextEntry={showboxSavedToken.length > 0 && !showboxTokenVisible} secureTextEntry={showboxSavedToken.length > 0 && !showboxTokenVisible}
multiline={false} multiline={false}
numberOfLines={1} numberOfLines={1}
/> />
{showboxSavedToken.length > 0 && ( {showboxSavedToken.length > 0 && (
<TouchableOpacity onPress={() => setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}> <TouchableOpacity onPress={() => setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}>
<Ionicons name={showboxTokenVisible ? 'eye-off' : 'eye'} size={18} color={colors.primary} /> <Ionicons name={showboxTokenVisible ? 'eye-off' : 'eye'} size={18} color={colors.primary} />
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
<View style={styles.buttonRow}> <View style={styles.buttonRow}>
{showboxUiToken !== showboxSavedToken && ( {showboxUiToken !== showboxSavedToken && (
<TouchableOpacity <TouchableOpacity
style={[styles.button, styles.primaryButton]} style={[styles.button, styles.primaryButton]}
onPress={async () => { onPress={async () => {
if (showboxScraperId) { if (showboxScraperId) {
await pluginService.setScraperSettings(showboxScraperId, { uiToken: showboxUiToken }); await pluginService.setScraperSettings(showboxScraperId, { uiToken: showboxUiToken });
} }
setShowboxSavedToken(showboxUiToken); setShowboxSavedToken(showboxUiToken);
openAlert('Saved', 'ShowBox settings updated'); openAlert('Saved', 'ShowBox settings updated');
}} }}
> >
<Text style={styles.buttonText}>Save</Text> <Text style={styles.buttonText}>Save</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
<TouchableOpacity <TouchableOpacity
style={[styles.button, styles.secondaryButton]} style={[styles.button, styles.secondaryButton]}
onPress={async () => { onPress={async () => {
setShowboxUiToken(''); setShowboxUiToken('');
setShowboxSavedToken(''); setShowboxSavedToken('');
if (showboxScraperId) { if (showboxScraperId) {
await pluginService.setScraperSettings(showboxScraperId, {}); await pluginService.setScraperSettings(showboxScraperId, {});
} }
}} }}
> >
<Text style={styles.secondaryButtonText}>Clear</Text> <Text style={styles.secondaryButtonText}>Clear</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
)} )}
</View> </View>
))} ))}
</View> </View>
)} )}
</CollapsibleSection> </CollapsibleSection>
{/* Additional Settings */} {/* Additional Settings */}
@ -1772,50 +1772,50 @@ const PluginsScreen: React.FC = () => {
</Text> </Text>
</View> </View>
<Switch <Switch
value={settings.streamDisplayMode === 'grouped'} value={settings.streamDisplayMode === 'grouped'}
onValueChange={(value) => { onValueChange={(value) => {
updateSetting('streamDisplayMode', value ? 'grouped' : 'separate'); updateSetting('streamDisplayMode', value ? 'grouped' : 'separate');
// Auto-disable quality sorting when grouping is disabled // Auto-disable quality sorting when grouping is disabled
if (!value && settings.streamSortMode === 'quality-then-scraper') { if (!value && settings.streamSortMode === 'quality-then-scraper') {
updateSetting('streamSortMode', 'scraper-then-quality'); updateSetting('streamSortMode', 'scraper-then-quality');
} }
}} }}
trackColor={{ false: colors.elevation3, true: colors.primary }} trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={settings.streamDisplayMode === 'grouped' ? colors.white : '#f4f3f4'} thumbColor={settings.streamDisplayMode === 'grouped' ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers} disabled={!settings.enableLocalScrapers}
/> />
</View> </View>
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Sort by Quality First</Text> <Text style={styles.settingTitle}>Sort by Quality First</Text>
<Text style={styles.settingDescription}> <Text style={styles.settingDescription}>
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. When enabled, streams are sorted by quality first, then by plugin. When disabled, streams are sorted by plugin first, then by quality. Only available when grouping is enabled.
</Text> </Text>
</View> </View>
<Switch <Switch
value={settings.streamSortMode === 'quality-then-scraper'} value={settings.streamSortMode === 'quality-then-scraper'}
onValueChange={(value) => updateSetting('streamSortMode', value ? 'quality-then-scraper' : 'scraper-then-quality')} onValueChange={(value) => updateSetting('streamSortMode', value ? 'quality-then-scraper' : 'scraper-then-quality')}
trackColor={{ false: colors.elevation3, true: colors.primary }} trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={settings.streamSortMode === 'quality-then-scraper' ? colors.white : '#f4f3f4'} thumbColor={settings.streamSortMode === 'quality-then-scraper' ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers || settings.streamDisplayMode !== 'grouped'} disabled={!settings.enableLocalScrapers || settings.streamDisplayMode !== 'grouped'}
/> />
</View> </View>
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Show Scraper Logos</Text> <Text style={styles.settingTitle}>Show Plugin Logos</Text>
<Text style={styles.settingDescription}> <Text style={styles.settingDescription}>
Display scraper logos next to streaming links on the streams screen. Display plugin logos next to streaming links on the streams screen.
</Text> </Text>
</View> </View>
<Switch <Switch
value={settings.showScraperLogos && settings.enableLocalScrapers} value={settings.showScraperLogos && settings.enableLocalScrapers}
onValueChange={(value) => updateSetting('showScraperLogos', value)} onValueChange={(value) => updateSetting('showScraperLogos', value)}
trackColor={{ false: colors.elevation3, true: colors.primary }} trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={settings.showScraperLogos && settings.enableLocalScrapers ? colors.white : '#f4f3f4'} thumbColor={settings.showScraperLogos && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers} disabled={!settings.enableLocalScrapers}
/> />
</View> </View>
</CollapsibleSection> </CollapsibleSection>
@ -1944,10 +1944,10 @@ const PluginsScreen: React.FC = () => {
2. <Text style={{ fontWeight: '600' }}>Add Repository</Text> - Add 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>
<Text style={styles.modalText}> <Text style={styles.modalText}>
3. <Text style={{ fontWeight: '600' }}>Refresh Repository</Text> - Download available scrapers from the repository 3. <Text style={{ fontWeight: '600' }}>Refresh Repository</Text> - Download available plugins from the repository
</Text> </Text>
<Text style={styles.modalText}> <Text style={styles.modalText}>
4. <Text style={{ fontWeight: '600' }}>Enable Scrapers</Text> - Turn on the scrapers you want to use for streaming 4. <Text style={{ fontWeight: '600' }}>Enable Plugins</Text> - Turn on the plugins you want to use for streaming
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.modalButton} style={styles.modalButton}
@ -1988,36 +1988,36 @@ const PluginsScreen: React.FC = () => {
/> />
{/* Format Hint */} {/* Format Hint */}
<Text style={styles.formatHint}> <Text style={styles.formatHint}>
Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch
</Text> </Text>
{/* Action Buttons */} {/* Action Buttons */}
<View style={styles.compactActions}> <View style={styles.compactActions}>
<TouchableOpacity <TouchableOpacity
style={[styles.compactButton, styles.cancelButton]} style={[styles.compactButton, styles.cancelButton]}
onPress={() => { onPress={() => {
setShowAddRepositoryModal(false); setShowAddRepositoryModal(false);
setNewRepositoryUrl(''); setNewRepositoryUrl('');
}} }}
> >
<Text style={styles.cancelButtonText}>Cancel</Text> <Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.compactButton, styles.addButton, (!newRepositoryUrl.trim() || isLoading) && styles.disabledButton]} style={[styles.compactButton, styles.addButton, (!newRepositoryUrl.trim() || isLoading) && styles.disabledButton]}
onPress={handleAddRepository} onPress={handleAddRepository}
disabled={!newRepositoryUrl.trim() || isLoading} disabled={!newRepositoryUrl.trim() || isLoading}
> >
{isLoading ? ( {isLoading ? (
<ActivityIndicator size="small" color={colors.white} /> <ActivityIndicator size="small" color={colors.white} />
) : ( ) : (
<Text style={styles.addButtonText}>Add</Text> <Text style={styles.addButtonText}>Add</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
</View> </View>
</View> </View>
</Modal> </Modal>

View file

@ -561,7 +561,7 @@ const SettingsScreen: React.FC = () => {
description="Manage plugins and repositories" description="Manage plugins and repositories"
customIcon={<PluginIcon size={isTablet ? 24 : 20} color={currentTheme.colors.primary} />} customIcon={<PluginIcon size={isTablet ? 24 : 20} color={currentTheme.colors.primary} />}
renderControl={ChevronRight} renderControl={ChevronRight}
onPress={() => navigation.navigate('ScraperSettings')} onPress={() => navigation.navigate('PluginSettings')}
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <SettingItem

View file

@ -913,13 +913,13 @@ export const StreamsScreen = () => {
const handleStreamPress = useCallback(async (stream: Stream) => { const handleStreamPress = useCallback(async (stream: Stream) => {
try { try {
if (stream.url) { if (stream.url) {
// Block magnet links - not supported yet
// Block magnet links with sanitized message
if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) { if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) {
try { openAlert('Stream Not Supported', 'This stream format is not supported.');
openAlert('Not supported', 'Torrent streaming is not supported yet.');
} catch (_e) { }
return; return;
} }
// If stream is actually MKV format, force the in-app VLC-based player on iOS // If stream is actually MKV format, force the in-app VLC-based player on iOS
try { try {
if (Platform.OS === 'ios' && settings.preferredPlayer === 'internal') { if (Platform.OS === 'ios' && settings.preferredPlayer === 'internal') {
@ -1078,7 +1078,7 @@ export const StreamsScreen = () => {
const isMagnet = typeof stream.url === 'string' && stream.url.startsWith('magnet:'); const isMagnet = typeof stream.url === 'string' && stream.url.startsWith('magnet:');
if (isMagnet) { if (isMagnet) {
// For magnet links, open directly which will trigger the torrent app chooser // For magnet links, open directly
if (__DEV__) console.log('Opening magnet link directly'); if (__DEV__) console.log('Opening magnet link directly');
Linking.openURL(stream.url) Linking.openURL(stream.url)
.then(() => { if (__DEV__) console.log('Successfully opened magnet link'); }) .then(() => { if (__DEV__) console.log('Successfully opened magnet link'); })

View file

@ -113,7 +113,7 @@ class LocalScraperService {
id: 'default', id: 'default',
name: this.extractRepositoryName(storedRepoUrl), name: this.extractRepositoryName(storedRepoUrl),
url: storedRepoUrl, url: storedRepoUrl,
description: 'Default repository', description: 'Default Plugins Repository',
isDefault: true, isDefault: true,
enabled: true, enabled: true,
lastUpdated: Date.now() lastUpdated: Date.now()
@ -516,27 +516,27 @@ class LocalScraperService {
const manifestUrl = `${baseManifestUrl}?t=${Date.now()}&v=${Math.random()}`; const manifestUrl = `${baseManifestUrl}?t=${Date.now()}&v=${Math.random()}`;
const response = await axios.get(manifestUrl, { const response = await axios.get(manifestUrl, {
timeout: 10000, timeout: 10000,
headers: { headers: {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
'Pragma': 'no-cache', 'Pragma': 'no-cache',
'Expires': '0' 'Expires': '0'
} }
}); });
const manifest: ScraperManifest = response.data; const manifest: ScraperManifest = response.data;
// Store repository name from manifest // Store repository name from manifest
if (manifest.name) { if (manifest.name) {
this.repositoryName = manifest.name; this.repositoryName = manifest.name;
} }
logger.log('[LocalScraperService] getAvailableScrapers - Raw manifest data:', JSON.stringify(manifest, null, 2)); logger.log('[LocalScraperService] getAvailableScrapers - Raw manifest data:', JSON.stringify(manifest, null, 2));
logger.log('[LocalScraperService] getAvailableScrapers - Manifest scrapers count:', manifest.scrapers?.length || 0); logger.log('[LocalScraperService] getAvailableScrapers - Manifest scrapers count:', manifest.scrapers?.length || 0);
// Log each scraper's enabled status from manifest // Log each scraper's enabled status from manifest
manifest.scrapers?.forEach(scraper => { manifest.scrapers?.forEach(scraper => {
logger.log(`[LocalScraperService] getAvailableScrapers - Scraper ${scraper.name}: enabled=${scraper.enabled}`); logger.log(`[LocalScraperService] getAvailableScrapers - Scraper ${scraper.name}: enabled=${scraper.enabled}`);
}); });
logger.log('[LocalScraperService] Found', manifest.scrapers.length, 'scrapers in repository'); logger.log('[LocalScraperService] Found', manifest.scrapers.length, 'scrapers in repository');
@ -760,13 +760,13 @@ class LocalScraperService {
const manifestUrl = `${baseManifestUrl}?t=${Date.now()}&v=${Math.random()}`; const manifestUrl = `${baseManifestUrl}?t=${Date.now()}&v=${Math.random()}`;
const response = await axios.get(manifestUrl, { const response = await axios.get(manifestUrl, {
timeout: 10000, timeout: 10000,
headers: { headers: {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
'Pragma': 'no-cache', 'Pragma': 'no-cache',
'Expires': '0' 'Expires': '0'
} }
}); });
const manifest: ScraperManifest = response.data; const manifest: ScraperManifest = response.data;
// Store repository name from manifest // Store repository name from manifest
@ -808,12 +808,12 @@ class LocalScraperService {
logger.log('[LocalScraperService] Found', availableScrapers.length, 'available scrapers in repository'); logger.log('[LocalScraperService] Found', availableScrapers.length, 'available scrapers in repository');
// Log final scraper states being returned to UI // Log final scraper states being returned to UI
availableScrapers.forEach(scraper => { availableScrapers.forEach(scraper => {
logger.log(`[LocalScraperService] Final scraper ${scraper.name}: manifestEnabled=${scraper.manifestEnabled}, enabled=${scraper.enabled}`); logger.log(`[LocalScraperService] Final scraper ${scraper.name}: manifestEnabled=${scraper.manifestEnabled}, enabled=${scraper.enabled}`);
}); });
return availableScrapers; return availableScrapers;
} catch (error) { } catch (error) {
logger.error('[LocalScraperService] Failed to fetch available scrapers from manifest:', error); logger.error('[LocalScraperService] Failed to fetch available scrapers from manifest:', error);
@ -920,7 +920,7 @@ class LocalScraperService {
if (enabledScrapers.length > 0) { if (enabledScrapers.length > 0) {
try { try {
logger.log('[LocalScraperService] Enabled scrapers:', enabledScrapers.map(s => s.name).join(', ')); logger.log('[LocalScraperService] Enabled scrapers:', enabledScrapers.map(s => s.name).join(', '));
} catch {} } catch { }
} }
if (enabledScrapers.length === 0) { if (enabledScrapers.length === 0) {
@ -983,7 +983,7 @@ class LocalScraperService {
promise.finally(() => { promise.finally(() => {
const current = this.inFlightByKey.get(flightKey); const current = this.inFlightByKey.get(flightKey);
if (current === promise) this.inFlightByKey.delete(flightKey); if (current === promise) this.inFlightByKey.delete(flightKey);
}).catch(() => {}); }).catch(() => { });
} }
const results = await promise; const results = await promise;