import React, { useState, useEffect, useCallback } from 'react'; import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, ActivityIndicator, Alert, SafeAreaView, StatusBar, Modal, KeyboardAvoidingView, Platform, Image, Dimensions, ScrollView, useColorScheme, Switch, Linking } from 'react-native'; import { stremioService, Manifest } from '../services/stremioService'; import { MaterialIcons } from '@expo/vector-icons'; import { Image as ExpoImage } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { BlurView as ExpoBlurView } from 'expo-blur'; import { BlurView as CommunityBlurView } from '@react-native-community/blur'; import Constants, { ExecutionEnvironment } from 'expo-constants'; import axios from 'axios'; import { useTheme } from '../contexts/ThemeContext'; // Extend Manifest type to include logo only (remove disabled status) interface ExtendedManifest extends Manifest { logo?: string; transport?: string; behaviorHints?: { configurable?: boolean; configurationRequired?: boolean; configurationURL?: string; }; } // Interface for Community Addon structure from the JSON URL interface CommunityAddon { transportUrl: string; manifest: ExtendedManifest; } const { width } = Dimensions.get('window'); const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; // Create a styles creator function that accepts the theme colors const createStyles = (colors: any) => StyleSheet.create({ container: { flex: 1, backgroundColor: colors.darkBackground, }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, }, headerActions: { flexDirection: 'row', alignItems: 'center', }, headerButton: { padding: 8, marginLeft: 8, }, activeHeaderButton: { backgroundColor: 'rgba(45, 156, 219, 0.2)', borderRadius: 6, }, reorderModeText: { color: colors.primary, fontSize: 18, fontWeight: '400', }, reorderInfoBanner: { backgroundColor: 'rgba(45, 156, 219, 0.15)', paddingHorizontal: 16, paddingVertical: 10, marginHorizontal: 16, borderRadius: 8, flexDirection: 'row', alignItems: 'center', marginBottom: 16, }, reorderInfoText: { color: colors.white, fontSize: 14, marginLeft: 8, }, reorderButtons: { position: 'absolute', left: -12, top: '50%', marginTop: -40, flexDirection: 'column', alignItems: 'center', justifyContent: 'center', zIndex: 10, }, reorderButton: { backgroundColor: colors.elevation3, width: 30, height: 30, borderRadius: 15, justifyContent: 'center', alignItems: 'center', marginVertical: 4, }, disabledButton: { opacity: 0.5, backgroundColor: colors.elevation2, }, priorityBadge: { backgroundColor: colors.primary, borderRadius: 12, paddingHorizontal: 8, paddingVertical: 3, }, priorityText: { color: colors.white, fontSize: 12, fontWeight: 'bold', }, backButton: { flexDirection: 'row', alignItems: 'center', padding: 8, }, backText: { fontSize: 17, fontWeight: '400', color: colors.primary, }, headerTitle: { fontSize: 34, fontWeight: '700', color: colors.white, paddingHorizontal: 16, paddingBottom: 16, paddingTop: 8, }, scrollView: { flex: 1, }, section: { marginBottom: 24, }, sectionTitle: { fontSize: 13, fontWeight: '600', color: colors.mediumGray, marginHorizontal: 16, marginBottom: 8, letterSpacing: 0.5, textTransform: 'uppercase', }, statsContainer: { flexDirection: 'row', justifyContent: 'space-between', marginHorizontal: 16, backgroundColor: colors.elevation2, borderRadius: 12, padding: 16, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 2, }, statsCard: { flex: 1, alignItems: 'center', justifyContent: 'center', }, statsDivider: { width: 1, height: '80%', backgroundColor: 'rgba(150, 150, 150, 0.2)', alignSelf: 'center', }, statsValue: { fontSize: 24, fontWeight: 'bold', color: colors.white, marginBottom: 4, }, statsLabel: { fontSize: 13, color: colors.mediumGray, }, addAddonContainer: { marginHorizontal: 16, backgroundColor: colors.elevation2, borderRadius: 12, padding: 16, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 2, }, addonInput: { backgroundColor: colors.elevation1, borderRadius: 8, padding: 12, color: colors.white, marginBottom: 16, fontSize: 15, }, addButton: { backgroundColor: colors.primary, borderRadius: 8, padding: 12, alignItems: 'center', }, addButtonText: { color: colors.white, fontWeight: '600', fontSize: 16, }, addonList: { paddingHorizontal: 16, }, emptyContainer: { backgroundColor: colors.elevation2, borderRadius: 12, padding: 32, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 2, }, emptyText: { marginTop: 8, color: colors.mediumGray, fontSize: 15, }, addonItem: { backgroundColor: colors.elevation2, borderRadius: 12, padding: 16, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 2, marginBottom: 16, }, addonHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, }, addonIcon: { width: 36, height: 36, borderRadius: 8, backgroundColor: colors.elevation3, }, addonIconPlaceholder: { width: 36, height: 36, borderRadius: 8, backgroundColor: colors.elevation3, justifyContent: 'center', alignItems: 'center', }, addonTitleContainer: { flex: 1, marginLeft: 12, marginRight: 16, }, addonName: { fontSize: 17, fontWeight: '600', color: colors.white, marginBottom: 2, }, addonMetaContainer: { flexDirection: 'row', alignItems: 'center', }, addonVersion: { fontSize: 13, color: colors.mediumGray, }, addonDot: { fontSize: 13, color: colors.mediumGray, marginHorizontal: 4, }, addonCategory: { fontSize: 13, color: colors.mediumGray, flex: 1, }, addonDescription: { fontSize: 14, color: colors.mediumEmphasis, marginTop: 6, marginBottom: 4, lineHeight: 20, marginLeft: 48, // Align with title, accounting for icon width }, loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', }, modalContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', }, modalContent: { backgroundColor: colors.elevation2, borderRadius: 14, width: '85%', maxHeight: '85%', overflow: 'hidden', shadowColor: '#000', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.25, shadowRadius: 8, elevation: 5, }, modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16, borderBottomWidth: 1, borderBottomColor: colors.elevation3, }, modalTitle: { fontSize: 17, fontWeight: 'bold', color: colors.white, }, modalScrollContent: { maxHeight: 400, }, addonDetailHeader: { alignItems: 'center', padding: 24, borderBottomWidth: 1, borderBottomColor: colors.elevation3, }, addonLogo: { width: 64, height: 64, borderRadius: 12, marginBottom: 16, backgroundColor: colors.elevation3, }, addonLogoPlaceholder: { width: 64, height: 64, borderRadius: 12, backgroundColor: colors.elevation3, justifyContent: 'center', alignItems: 'center', marginBottom: 16, }, addonDetailName: { fontSize: 20, fontWeight: 'bold', color: colors.white, marginBottom: 4, textAlign: 'center', }, addonDetailVersion: { fontSize: 14, color: colors.mediumGray, }, addonDetailSection: { padding: 16, borderBottomWidth: 1, borderBottomColor: colors.elevation3, }, addonDetailSectionTitle: { fontSize: 16, fontWeight: '600', color: colors.white, marginBottom: 8, }, addonDetailDescription: { fontSize: 15, color: colors.mediumEmphasis, lineHeight: 20, }, addonDetailChips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, }, addonDetailChip: { backgroundColor: colors.elevation3, borderRadius: 12, paddingHorizontal: 8, paddingVertical: 4, }, addonDetailChipText: { fontSize: 13, color: colors.white, }, modalActions: { flexDirection: 'row', justifyContent: 'flex-end', padding: 16, borderTopWidth: 1, borderTopColor: colors.elevation3, }, modalButton: { paddingVertical: 8, paddingHorizontal: 16, borderRadius: 8, minWidth: 80, alignItems: 'center', }, cancelButton: { backgroundColor: colors.elevation3, marginRight: 8, }, installButton: { backgroundColor: colors.success, borderRadius: 6, padding: 8, justifyContent: 'center', alignItems: 'center', }, modalButtonText: { color: colors.white, fontWeight: '600', }, addonActions: { flexDirection: 'row', alignItems: 'center', }, deleteButton: { padding: 6, }, configButton: { padding: 6, marginRight: 8, }, sectionSeparator: { height: 1, backgroundColor: colors.border, marginHorizontal: 20, marginVertical: 20, }, emptyMessage: { textAlign: 'center', color: colors.mediumGray, marginTop: 20, fontSize: 16, paddingHorizontal: 20, }, errorMessage: { textAlign: 'center', color: colors.error, marginTop: 20, fontSize: 16, paddingHorizontal: 20, }, loader: { marginTop: 30, alignSelf: 'center', }, addonActionButtons: { flexDirection: 'row', alignItems: 'center', }, blurOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'black', }, androidBlurContainer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, androidBlur: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, androidFallbackBlur: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'black', }, }); // Cinemeta addon details const cinemetaAddon: CommunityAddon = { transportUrl: 'https://v3-cinemeta.strem.io/manifest.json', manifest: { id: 'com.linvo.cinemeta', version: '3.0.13', name: 'Cinemeta', description: 'Provides metadata for movies and series from TheTVDB, TheMovieDB, etc.', logo: 'https://static.strem.io/addons/cinemeta.png', types: ['movie', 'series'], behaviorHints: { configurable: false } } as ExtendedManifest, }; const AddonsScreen = () => { const navigation = useNavigation>(); const [addons, setAddons] = useState([]); const [loading, setLoading] = useState(true); const [addonUrl, setAddonUrl] = useState(''); const [addonDetails, setAddonDetails] = useState(null); const [showConfirmModal, setShowConfirmModal] = useState(false); const [installing, setInstalling] = useState(false); const [catalogCount, setCatalogCount] = useState(0); // Add state for reorder mode const [reorderMode, setReorderMode] = useState(false); // Use ThemeContext const { currentTheme } = useTheme(); const colors = currentTheme.colors; const styles = createStyles(colors); useEffect(() => { loadAddons(); }, []); const loadAddons = async () => { try { setLoading(true); // Use the regular method without disabled state const installedAddons = await stremioService.getInstalledAddonsAsync(); setAddons(installedAddons as ExtendedManifest[]); // Count catalogs let totalCatalogs = 0; installedAddons.forEach(addon => { if (addon.catalogs && addon.catalogs.length > 0) { totalCatalogs += addon.catalogs.length; } }); // Get catalog settings to determine enabled count const catalogSettingsJson = await AsyncStorage.getItem('catalog_settings'); if (catalogSettingsJson) { const catalogSettings = JSON.parse(catalogSettingsJson); const disabledCount = Object.entries(catalogSettings) .filter(([key, value]) => key !== '_lastUpdate' && value === false) .length; setCatalogCount(totalCatalogs - disabledCount); } else { setCatalogCount(totalCatalogs); } } catch (error) { logger.error('Failed to load addons:', error); Alert.alert('Error', 'Failed to load addons'); } finally { setLoading(false); } }; const handleAddAddon = async (url?: string) => { const urlToInstall = url || addonUrl; if (!urlToInstall) { Alert.alert('Error', 'Please enter an addon URL'); return; } try { setInstalling(true); const manifest = await stremioService.getManifest(urlToInstall); setAddonDetails(manifest); setAddonUrl(urlToInstall); setShowConfirmModal(true); } catch (error) { logger.error('Failed to fetch addon details:', error); Alert.alert('Error', `Failed to fetch addon details from ${urlToInstall}`); } finally { setInstalling(false); } }; const confirmInstallAddon = async () => { if (!addonDetails || !addonUrl) return; try { setInstalling(true); await stremioService.installAddon(addonUrl); setAddonUrl(''); setShowConfirmModal(false); setAddonDetails(null); loadAddons(); Alert.alert('Success', 'Addon installed successfully'); } catch (error) { logger.error('Failed to install addon:', error); Alert.alert('Error', 'Failed to install addon'); } finally { setInstalling(false); } }; const refreshAddons = async () => { loadAddons(); }; const moveAddonUp = (addon: ExtendedManifest) => { if (stremioService.moveAddonUp(addon.id)) { // Refresh the list to reflect the new order loadAddons(); } }; const moveAddonDown = (addon: ExtendedManifest) => { if (stremioService.moveAddonDown(addon.id)) { // Refresh the list to reflect the new order loadAddons(); } }; const handleRemoveAddon = (addon: ExtendedManifest) => { Alert.alert( 'Uninstall Addon', `Are you sure you want to uninstall ${addon.name}?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Uninstall', style: 'destructive', onPress: () => { stremioService.removeAddon(addon.id); // Remove from addons list setAddons(prev => prev.filter(a => a.id !== addon.id)); }, }, ] ); }; // Add function to handle configuration const handleConfigureAddon = (addon: ExtendedManifest, transportUrl?: string) => { // Try different ways to get the configuration URL let configUrl = ''; // Debug log the addon data to help troubleshoot logger.info(`Configure addon: ${addon.name}, ID: ${addon.id}`); if (transportUrl) { logger.info(`TransportUrl provided: ${transportUrl}`); } // First check if the addon has a configurationURL directly if (addon.behaviorHints?.configurationURL) { configUrl = addon.behaviorHints.configurationURL; logger.info(`Using configurationURL from behaviorHints: ${configUrl}`); } // If a transport URL was provided directly (for community addons) else if (transportUrl) { // Remove any trailing filename like manifest.json const baseUrl = transportUrl.replace(/\/[^\/]+\.json$/, '/'); configUrl = `${baseUrl}configure`; logger.info(`Using transportUrl to create config URL: ${configUrl}`); } // If the addon has a url property (this is set during installation) else if (addon.url) { configUrl = `${addon.url}configure`; logger.info(`Using addon.url property: ${configUrl}`); } // For com.stremio.*.addon format (common format for installed addons) else if (addon.id && addon.id.match(/^com\.stremio\.(.*?)\.addon$/)) { // Extract the domain part const match = addon.id.match(/^com\.stremio\.(.*?)\.addon$/); if (match && match[1]) { // Construct URL from the domain part of the ID const addonName = match[1]; // For torrentio specifically, use known URL if (addonName === 'torrentio') { configUrl = 'https://torrentio.strem.fun/configure'; logger.info(`Special case for torrentio: ${configUrl}`); } else { // Try to construct a reasonable URL for other addons configUrl = `https://${addonName}.strem.fun/configure`; logger.info(`Constructed URL from addon name: ${configUrl}`); } } } // If the ID is a URL, use that as the base (common for installed addons) else if (addon.id && addon.id.startsWith('http')) { // Get base URL from addon id (remove manifest.json or any trailing file) const baseUrl = addon.id.replace(/\/[^\/]+\.json$/, '/'); configUrl = `${baseUrl}configure`; logger.info(`Using addon.id as HTTP URL: ${configUrl}`); } // If the ID uses stremio:// protocol but contains http URL (common format) else if (addon.id && (addon.id.includes('https://') || addon.id.includes('http://'))) { // Extract the HTTP URL using a more flexible regex const match = addon.id.match(/(https?:\/\/[^\/]+)(\/[^\s]*)?/); if (match) { // Use the domain and path if available, otherwise just domain with /configure const domain = match[1]; const path = match[2] ? match[2].replace(/\/[^\/]+\.json$/, '/') : '/'; configUrl = `${domain}${path}configure`; logger.info(`Extracted HTTP URL from stremio:// format: ${configUrl}`); } } // Special case for common addon format like stremio://addon.stremio.com/... if (!configUrl && addon.id && addon.id.startsWith('stremio://')) { // Try to convert stremio://domain.com/... to https://domain.com/... const domainMatch = addon.id.match(/stremio:\/\/([^\/]+)(\/[^\s]*)?/); if (domainMatch) { const domain = domainMatch[1]; const path = domainMatch[2] ? domainMatch[2].replace(/\/[^\/]+\.json$/, '/') : '/'; configUrl = `https://${domain}${path}configure`; logger.info(`Converted stremio:// protocol to https:// for config URL: ${configUrl}`); } } // Use transport property if available (some addons include this) if (!configUrl && addon.transport && typeof addon.transport === 'string' && addon.transport.includes('http')) { const baseUrl = addon.transport.replace(/\/[^\/]+\.json$/, '/'); configUrl = `${baseUrl}configure`; logger.info(`Using addon.transport for config URL: ${configUrl}`); } // Get the URL from manifest's originalUrl if available if (!configUrl && (addon as any).originalUrl) { const baseUrl = (addon as any).originalUrl.replace(/\/[^\/]+\.json$/, '/'); configUrl = `${baseUrl}configure`; logger.info(`Using originalUrl property: ${configUrl}`); } // If we couldn't determine a config URL, show an error if (!configUrl) { logger.error(`Failed to determine config URL for addon: ${addon.name}, ID: ${addon.id}`); Alert.alert( 'Configuration Unavailable', 'Could not determine configuration URL for this addon.', [{ text: 'OK' }] ); return; } // Log the URL being opened logger.info(`Opening configuration for addon: ${addon.name} at URL: ${configUrl}`); // Check if the URL can be opened Linking.canOpenURL(configUrl).then(supported => { if (supported) { Linking.openURL(configUrl); } else { logger.error(`URL cannot be opened: ${configUrl}`); Alert.alert( 'Cannot Open Configuration', `The configuration URL (${configUrl}) cannot be opened. The addon may not have a configuration page.`, [{ text: 'OK' }] ); } }).catch(err => { logger.error(`Error checking if URL can be opened: ${configUrl}`, err); Alert.alert('Error', 'Could not open configuration page.'); }); }; const toggleReorderMode = () => { setReorderMode(!reorderMode); }; const renderAddonItem = ({ item, index }: { item: ExtendedManifest, index: number }) => { const types = item.types || []; const description = item.description || ''; // @ts-ignore - some addons might have logo property even though it's not in the type const logo = item.logo || null; // Check if addon is configurable const isConfigurable = item.behaviorHints?.configurable === true; // Format the types into a simple category text const categoryText = types.length > 0 ? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ') : 'No categories'; const isFirstItem = index === 0; const isLastItem = index === addons.length - 1; return ( {reorderMode && ( moveAddonUp(item)} disabled={isFirstItem} > moveAddonDown(item)} disabled={isLastItem} > )} {logo ? ( ) : ( )} {item.name} v{item.version || '1.0.0'} {categoryText} {!reorderMode ? ( <> {isConfigurable && ( handleConfigureAddon(item, item.transport)} > )} handleRemoveAddon(item)} > ) : ( #{index + 1} )} {description.length > 100 ? description.substring(0, 100) + '...' : description} ); }; const StatsCard = ({ value, label }: { value: number; label: string }) => ( {value} {label} ); return ( {/* Header */} navigation.goBack()} > Settings {/* Reorder Mode Toggle Button */} {/* Refresh Button */} Addons {reorderMode && (Reorder Mode)} {reorderMode && ( Addons at the top have higher priority when loading content )} {loading ? ( ) : ( {/* Overview Section */} OVERVIEW {/* Hide Add Addon Section in reorder mode */} {!reorderMode && ( ADD NEW ADDON handleAddAddon()} disabled={installing || !addonUrl} > {installing ? 'Loading...' : 'Add Addon'} )} {/* Installed Addons Section */} {reorderMode ? "DRAG ADDONS TO REORDER" : "INSTALLED ADDONS"} {addons.length === 0 ? ( No addons installed ) : ( addons.map((addon, index) => ( {renderAddonItem({ item: addon, index })} )) )} )} {/* Addon Details Confirmation Modal */} { setShowConfirmModal(false); setAddonDetails(null); }} > {Platform.OS === 'ios' ? ( ) : ( Constants.executionEnvironment === ExecutionEnvironment.StoreClient ? ( ) : ( ) )} {addonDetails && ( <> Install Addon { setShowConfirmModal(false); setAddonDetails(null); }} > {/* @ts-ignore */} {addonDetails.logo ? ( ) : ( )} {addonDetails.name} v{addonDetails.version || '1.0.0'} Description {addonDetails.description || 'No description available'} {addonDetails.types && addonDetails.types.length > 0 && ( Supported Types {addonDetails.types.map((type, index) => ( {type} ))} )} {addonDetails.catalogs && addonDetails.catalogs.length > 0 && ( Catalogs {addonDetails.catalogs.map((catalog, index) => ( {catalog.type} - {catalog.id} ))} )} { setShowConfirmModal(false); setAddonDetails(null); }} > Cancel {installing ? ( ) : ( Install )} )} ); }; export default AddonsScreen;