diff --git a/package-lock.json b/package-lock.json index c6cd9c4..5ef1cbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", "react-native-svg": "^15.11.2", + "react-native-tab-view": "^4.0.10", "react-native-video": "^6.12.0", "react-native-web": "~0.19.13", "subsrt": "^1.1.1" @@ -10787,6 +10788,17 @@ } } }, + "node_modules/react-native-pager-view": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.7.0.tgz", + "integrity": "sha512-sutxKiMqBuQrEyt4mLaLNzy8taIC7IuYpxfcwQBXfSYBSSpAa0qE9G1FXlP/iXqTSlFgBXyK7BESsl9umOjECQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-paper": { "version": "5.13.1", "resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.13.1.tgz", @@ -10918,6 +10930,20 @@ "react-native-svg": ">=12.0.0" } }, + "node_modules/react-native-tab-view": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/react-native-tab-view/-/react-native-tab-view-4.0.10.tgz", + "integrity": "sha512-KU1ovavUURfKffqNn7F2jwgQ0tUSa2WosnHSztVYArCr22HP2nR7xHrd8DddFL4uenaT9KGXlNgx1IUPGUdZSw==", + "license": "MIT", + "dependencies": { + "use-latest-callback": "^0.2.1" + }, + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*", + "react-native-pager-view": ">= 6.0.0" + } + }, "node_modules/react-native-vector-icons": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.2.0.tgz", diff --git a/package.json b/package.json index f0c2cae..99902dd 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", "react-native-svg": "^15.11.2", + "react-native-tab-view": "^4.0.10", "react-native-video": "^6.12.0", "react-native-web": "~0.19.13", "subsrt": "^1.1.1" diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index 6956c90..4af15cd 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -17,7 +17,8 @@ import { Dimensions, ScrollView, useColorScheme, - Switch + Switch, + Linking } from 'react-native'; import { stremioService, Manifest } from '../services/stremioService'; import { MaterialIcons } from '@expo/vector-icons'; @@ -30,10 +31,23 @@ import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { BlurView } from 'expo-blur'; +import axios from 'axios'; // 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'); @@ -54,8 +68,14 @@ const AddonsScreen = () => { // Force dark mode const isDarkMode = true; + // State for community addons + const [communityAddons, setCommunityAddons] = useState([]); + const [communityLoading, setCommunityLoading] = useState(true); + const [communityError, setCommunityError] = useState(null); + useEffect(() => { loadAddons(); + loadCommunityAddons(); }, []); const loadAddons = async () => { @@ -92,28 +112,46 @@ const AddonsScreen = () => { } }; - const handleAddAddon = async () => { - if (!addonUrl) { - Alert.alert('Error', 'Please enter an addon URL'); + // Function to load community addons + const loadCommunityAddons = async () => { + setCommunityLoading(true); + setCommunityError(null); + try { + const response = await axios.get('https://stremio-addons.com/catalog.json'); + // Filter out addons without a manifest or transportUrl (basic validation) + const validAddons = response.data.filter(addon => addon.manifest && addon.transportUrl); + setCommunityAddons(validAddons); + } catch (error) { + logger.error('Failed to load community addons:', error); + setCommunityError('Failed to load community addons. Please try again later.'); + } finally { + setCommunityLoading(false); + } + }; + + const handleAddAddon = async (url?: string) => { + const urlToInstall = url || addonUrl; + if (!urlToInstall) { + Alert.alert('Error', 'Please enter an addon URL or select a community addon'); return; } try { setInstalling(true); - // First fetch the addon manifest - const manifest = await stremioService.getManifest(addonUrl); + 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'); + Alert.alert('Error', `Failed to fetch addon details from ${urlToInstall}`); } finally { setInstalling(false); } }; const confirmInstallAddon = async () => { - if (!addonDetails) return; + if (!addonDetails || !addonUrl) return; try { setInstalling(true); @@ -133,6 +171,7 @@ const AddonsScreen = () => { const refreshAddons = async () => { loadAddons(); + loadCommunityAddons(); }; const moveAddonUp = (addon: ExtendedManifest) => { @@ -169,6 +208,130 @@ const AddonsScreen = () => { ); }; + // 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); }; @@ -178,6 +341,8 @@ const AddonsScreen = () => { 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 @@ -238,12 +403,22 @@ const AddonsScreen = () => { {!reorderMode ? ( - handleRemoveAddon(item)} - > - - + <> + {isConfigurable && ( + handleConfigureAddon(item, item.transport)} + > + + + )} + handleRemoveAddon(item)} + > + + + ) : ( #{index + 1} @@ -259,6 +434,66 @@ 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 ( + + {logo ? ( + + ) : ( + + + + )} + + {manifest.name} + {description} + + v{manifest.version || 'N/A'} + + {categoryText} + + + + {isConfigurable && ( + handleConfigureAddon(manifest, transportUrl)} + > + + + )} + handleAddAddon(transportUrl)} + disabled={installing} + > + {installing ? ( + + ) : ( + + )} + + + + ); + }; + const StatsCard = ({ value, label }: { value: number; label: string }) => ( {value} @@ -360,7 +595,7 @@ const AddonsScreen = () => { /> handleAddAddon()} disabled={installing || !addonUrl} > @@ -394,6 +629,95 @@ const AddonsScreen = () => { )} + + {/* Separator */} + + + {/* Community Addons Section */} + + COMMUNITY ADDONS + + {communityLoading ? ( + + + + ) : communityError ? ( + + + {communityError} + + ) : communityAddons.length === 0 ? ( + + + No community addons available + + ) : ( + communityAddons.map((item, index) => ( + + + + {item.manifest.logo ? ( + + ) : ( + + + + )} + + {item.manifest.name} + + v{item.manifest.version || 'N/A'} + + + {item.manifest.types && item.manifest.types.length > 0 + ? item.manifest.types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ') + : 'General'} + + + + + {item.manifest.behaviorHints?.configurable && ( + handleConfigureAddon(item.manifest, item.transportUrl)} + > + + + )} + handleAddAddon(item.transportUrl)} + disabled={installing} + > + {installing ? ( + + ) : ( + + )} + + + + + + {item.manifest.description + ? (item.manifest.description.length > 100 + ? item.manifest.description.substring(0, 100) + '...' + : item.manifest.description) + : 'No description provided.'} + + + + )) + )} + + )} @@ -896,7 +1220,11 @@ const styles = StyleSheet.create({ marginRight: 8, }, installButton: { - backgroundColor: colors.primary, + backgroundColor: colors.success, + borderRadius: 6, + padding: 8, + justifyContent: 'center', + alignItems: 'center', }, modalButtonText: { color: colors.white, @@ -909,8 +1237,101 @@ const styles = StyleSheet.create({ deleteButton: { padding: 6, }, - refreshButton: { - padding: 8, + configButton: { + padding: 6, + marginRight: 8, + }, + communityAddonsList: { + paddingHorizontal: 20, + }, + communityAddonItem: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.card, + borderRadius: 8, + padding: 15, + marginBottom: 10, + }, + communityAddonIcon: { + width: 40, + height: 40, + borderRadius: 6, + marginRight: 15, + }, + communityAddonIconPlaceholder: { + width: 40, + height: 40, + borderRadius: 6, + marginRight: 15, + backgroundColor: colors.darkGray, + justifyContent: 'center', + alignItems: 'center', + }, + communityAddonDetails: { + flex: 1, + marginRight: 10, + }, + communityAddonName: { + fontSize: 16, + fontWeight: '600', + color: colors.white, + marginBottom: 3, + }, + communityAddonDesc: { + fontSize: 13, + color: colors.lightGray, + marginBottom: 5, + opacity: 0.9, + }, + communityAddonMetaContainer: { + flexDirection: 'row', + alignItems: 'center', + opacity: 0.8, + }, + communityAddonVersion: { + fontSize: 12, + color: colors.lightGray, + }, + communityAddonDot: { + fontSize: 12, + color: colors.lightGray, + marginHorizontal: 5, + }, + communityAddonCategory: { + fontSize: 12, + color: colors.lightGray, + flexShrink: 1, + }, + separator: { + height: 10, + }, + 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', }, }); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 6aab5b4..f58f8d6 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -323,13 +323,6 @@ const SettingsScreen: React.FC = () => { isDarkMode={isDarkMode} renderControl={ChevronRight} onPress={() => navigation.navigate('PlayerSettings')} - /> -