mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
1540 lines
No EOL
50 KiB
TypeScript
1540 lines
No EOL
50 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
FlatList,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
ActivityIndicator,
|
|
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 FastImage from '@d11/react-native-fast-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 { mmkvStorage } from '../services/mmkvStorage';
|
|
import { BlurView as ExpoBlurView } from 'expo-blur';
|
|
import CustomAlert from '../components/CustomAlert';
|
|
|
|
// Optional iOS Glass effect (expo-glass-effect) with safe fallback for AddonsScreen
|
|
let GlassViewComp: any = null;
|
|
let liquidGlassAvailable = false;
|
|
if (Platform.OS === 'ios') {
|
|
try {
|
|
// Dynamically require so app still runs if the package isn't installed yet
|
|
const glass = require('expo-glass-effect');
|
|
GlassViewComp = glass.GlassView;
|
|
liquidGlassAvailable = typeof glass.isLiquidGlassAvailable === 'function' ? glass.isLiquidGlassAvailable() : false;
|
|
} catch {
|
|
GlassViewComp = null;
|
|
liquidGlassAvailable = false;
|
|
}
|
|
}
|
|
// Removed community blur and expo-constants for Android overlay
|
|
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,
|
|
},
|
|
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',
|
|
},
|
|
blurOverlay: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: colors.transparentDark,
|
|
},
|
|
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: colors.darkBackground,
|
|
},
|
|
});
|
|
|
|
|
|
|
|
const AddonsScreen = () => {
|
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
|
const [addons, setAddons] = useState<ExtendedManifest[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [addonUrl, setAddonUrl] = useState('');
|
|
const [addonDetails, setAddonDetails] = useState<ExtendedManifest | null>(null);
|
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
|
const [installing, setInstalling] = useState(false);
|
|
const [catalogCount, setCatalogCount] = useState(0);
|
|
// Add state for reorder mode
|
|
// Custom alert state
|
|
const [alertVisible, setAlertVisible] = useState(false);
|
|
const [alertTitle, setAlertTitle] = useState('');
|
|
const [alertMessage, setAlertMessage] = useState('');
|
|
const [alertActions, setAlertActions] = useState<any[]>([]);
|
|
const [reorderMode, setReorderMode] = useState(false);
|
|
// Use ThemeContext
|
|
const { currentTheme } = useTheme();
|
|
const colors = currentTheme.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
|
|
const PROMO_ADDON_URL = 'https://nuviostreams.hayd.uk/manifest.json';
|
|
const promoAddon: ExtendedManifest = {
|
|
id: 'org.nuvio.streams',
|
|
name: 'Nuvio Streams | Elfhosted',
|
|
version: '0.5.0',
|
|
description: 'Stremio addon for high-quality streaming links.',
|
|
// @ts-ignore - logo not in base manifest type
|
|
logo: 'https://raw.githubusercontent.com/tapframe/NuvioStreaming/refs/heads/appstore/assets/titlelogo.png',
|
|
types: ['movie', 'series'],
|
|
catalogs: [],
|
|
behaviorHints: { configurable: true },
|
|
// help handleConfigureAddon derive configure URL from the transport
|
|
transport: PROMO_ADDON_URL,
|
|
} as ExtendedManifest;
|
|
const isPromoInstalled = addons.some(a =>
|
|
a.id === 'org.nuvio.streams' ||
|
|
(typeof a.id === 'string' && a.id.includes('nuviostreams.hayd.uk')) ||
|
|
(typeof a.transport === 'string' && a.transport.includes('nuviostreams.hayd.uk')) ||
|
|
(typeof (a as any).url === 'string' && (a as any).url.includes('nuviostreams.hayd.uk'))
|
|
);
|
|
|
|
useEffect(() => {
|
|
loadAddons();
|
|
loadCommunityAddons();
|
|
}, []);
|
|
|
|
const loadAddons = async () => {
|
|
try {
|
|
setLoading(true);
|
|
// Use the regular method without disabled state
|
|
const installedAddons = await stremioService.getInstalledAddonsAsync();
|
|
|
|
// Filter out Torbox addons (managed via DebridIntegrationScreen)
|
|
const filteredAddons = installedAddons.filter(addon => {
|
|
const isTorboxAddon =
|
|
addon.id?.includes('torbox') ||
|
|
addon.url?.includes('torbox') ||
|
|
(addon as any).transport?.includes('torbox');
|
|
return !isTorboxAddon;
|
|
});
|
|
|
|
setAddons(filteredAddons as ExtendedManifest[]);
|
|
|
|
// Count catalogs
|
|
let totalCatalogs = 0;
|
|
filteredAddons.forEach(addon => {
|
|
if (addon.catalogs && addon.catalogs.length > 0) {
|
|
totalCatalogs += addon.catalogs.length;
|
|
}
|
|
});
|
|
|
|
// Get catalog settings to determine enabled count
|
|
const catalogSettingsJson = await mmkvStorage.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);
|
|
setAlertTitle('Error');
|
|
setAlertMessage('Failed to load addons');
|
|
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
|
setAlertVisible(true);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// 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) => {
|
|
let urlToInstall = url || addonUrl;
|
|
if (!urlToInstall) {
|
|
setAlertTitle('Error');
|
|
setAlertMessage('Please enter an addon URL or select a community addon');
|
|
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
|
setAlertVisible(true);
|
|
return;
|
|
}
|
|
|
|
// Replace stremio:// with https:// if present
|
|
if (urlToInstall.startsWith('stremio://')) {
|
|
urlToInstall = urlToInstall.replace(/^stremio:\/\//, 'https://');
|
|
}
|
|
|
|
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);
|
|
setAlertTitle('Error');
|
|
setAlertMessage(`Failed to fetch addon details from ${urlToInstall}`);
|
|
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
|
setAlertVisible(true);
|
|
} finally {
|
|
setInstalling(false);
|
|
}
|
|
};
|
|
|
|
const confirmInstallAddon = async () => {
|
|
if (!addonDetails || !addonUrl) return;
|
|
|
|
try {
|
|
setInstalling(true);
|
|
await stremioService.installAddon(addonUrl);
|
|
setAddonUrl('');
|
|
setShowConfirmModal(false);
|
|
setAddonDetails(null);
|
|
loadAddons();
|
|
setAlertTitle('Success');
|
|
setAlertMessage('Addon installed successfully');
|
|
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
|
setAlertVisible(true);
|
|
} catch (error) {
|
|
logger.error('Failed to install addon:', error);
|
|
setAlertTitle('Error');
|
|
setAlertMessage('Failed to install addon');
|
|
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
|
setAlertVisible(true);
|
|
} finally {
|
|
setInstalling(false);
|
|
}
|
|
};
|
|
|
|
const refreshAddons = async () => {
|
|
loadAddons();
|
|
loadCommunityAddons();
|
|
};
|
|
|
|
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) => {
|
|
setAlertTitle('Uninstall Addon');
|
|
setAlertMessage(`Are you sure you want to uninstall ${addon.name}?`);
|
|
setAlertActions([
|
|
{ label: 'Cancel', onPress: () => setAlertVisible(false), style: { color: colors.mediumGray } },
|
|
{
|
|
label: 'Uninstall',
|
|
onPress: async () => {
|
|
await stremioService.removeAddon(addon.id);
|
|
setAddons(prev => prev.filter(a => a.id !== addon.id));
|
|
// Ensure we re-read from storage/order to avoid reappearing on next load
|
|
await loadAddons();
|
|
},
|
|
style: { color: colors.error }
|
|
},
|
|
]);
|
|
setAlertVisible(true);
|
|
};
|
|
|
|
// 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}`);
|
|
setAlertTitle('Configuration Unavailable');
|
|
setAlertMessage('Could not determine configuration URL for this addon.');
|
|
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
|
setAlertVisible(true);
|
|
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}`);
|
|
setAlertTitle('Cannot Open Configuration');
|
|
setAlertMessage(`The configuration URL (${configUrl}) cannot be opened. The addon may not have a configuration page.`);
|
|
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
|
setAlertVisible(true);
|
|
}
|
|
}).catch(err => {
|
|
logger.error(`Error checking if URL can be opened: ${configUrl}`, err);
|
|
setAlertTitle('Error');
|
|
setAlertMessage('Could not open configuration page.');
|
|
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
|
setAlertVisible(true);
|
|
});
|
|
};
|
|
|
|
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;
|
|
// Check if addon is pre-installed
|
|
const isPreInstalled = stremioService.isPreInstalledAddon(item.id);
|
|
|
|
// 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 (
|
|
<View style={styles.addonItem}>
|
|
{reorderMode && (
|
|
<View style={styles.reorderButtons}>
|
|
<TouchableOpacity
|
|
style={[styles.reorderButton, isFirstItem && styles.disabledButton]}
|
|
onPress={() => moveAddonUp(item)}
|
|
disabled={isFirstItem}
|
|
>
|
|
<MaterialIcons
|
|
name="arrow-upward"
|
|
size={20}
|
|
color={isFirstItem ? colors.mediumGray : colors.white}
|
|
/>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.reorderButton, isLastItem && styles.disabledButton]}
|
|
onPress={() => moveAddonDown(item)}
|
|
disabled={isLastItem}
|
|
>
|
|
<MaterialIcons
|
|
name="arrow-downward"
|
|
size={20}
|
|
color={isLastItem ? colors.mediumGray : colors.white}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
<View style={styles.addonHeader}>
|
|
{logo ? (
|
|
<FastImage
|
|
source={{ uri: 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}>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 2 }}>
|
|
<Text style={styles.addonName}>{item.name}</Text>
|
|
{isPreInstalled && (
|
|
<View style={[styles.priorityBadge, { marginLeft: 8, backgroundColor: colors.success }]}>
|
|
<Text style={[styles.priorityText, { fontSize: 10 }]}>PRE-INSTALLED</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<View style={styles.addonMetaContainer}>
|
|
<Text style={styles.addonVersion}>v{item.version || '1.0.0'}</Text>
|
|
<Text style={styles.addonDot}>•</Text>
|
|
<Text style={styles.addonCategory}>{categoryText}</Text>
|
|
</View>
|
|
</View>
|
|
<View style={styles.addonActions}>
|
|
{!reorderMode ? (
|
|
<>
|
|
{isConfigurable && (
|
|
<TouchableOpacity
|
|
style={styles.configButton}
|
|
onPress={() => handleConfigureAddon(item, item.transport)}
|
|
>
|
|
<MaterialIcons name="settings" size={20} color={colors.primary} />
|
|
</TouchableOpacity>
|
|
)}
|
|
{!stremioService.isPreInstalledAddon(item.id) && (
|
|
<TouchableOpacity
|
|
style={styles.deleteButton}
|
|
onPress={() => handleRemoveAddon(item)}
|
|
>
|
|
<MaterialIcons name="delete" size={20} color={colors.error} />
|
|
</TouchableOpacity>
|
|
)}
|
|
</>
|
|
) : (
|
|
<View style={styles.priorityBadge}>
|
|
<Text style={styles.priorityText}>#{index + 1}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
<Text style={styles.addonDescription}>
|
|
{description.length > 100 ? description.substring(0, 100) + '...' : description}
|
|
</Text>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// 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 }) => (
|
|
<View style={styles.statsCard}>
|
|
<Text style={styles.statsValue}>{value}</Text>
|
|
<Text style={styles.statsLabel}>{label}</Text>
|
|
</View>
|
|
);
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
<StatusBar barStyle="light-content" />
|
|
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<TouchableOpacity
|
|
style={styles.backButton}
|
|
onPress={() => navigation.goBack()}
|
|
>
|
|
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
|
|
<Text style={styles.backText}>Settings</Text>
|
|
</TouchableOpacity>
|
|
|
|
<View style={styles.headerActions}>
|
|
{/* Reorder Mode Toggle Button */}
|
|
<TouchableOpacity
|
|
style={[styles.headerButton, reorderMode && styles.activeHeaderButton]}
|
|
onPress={toggleReorderMode}
|
|
>
|
|
<MaterialIcons
|
|
name="swap-vert"
|
|
size={24}
|
|
color={reorderMode ? colors.primary : colors.white}
|
|
/>
|
|
</TouchableOpacity>
|
|
|
|
{/* Refresh Button */}
|
|
<TouchableOpacity
|
|
style={styles.headerButton}
|
|
onPress={refreshAddons}
|
|
disabled={loading}
|
|
>
|
|
<MaterialIcons
|
|
name="refresh"
|
|
size={24}
|
|
color={loading ? colors.mediumGray : colors.white}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
|
|
<Text style={styles.headerTitle}>
|
|
Addons
|
|
{reorderMode && <Text style={styles.reorderModeText}> (Reorder Mode)</Text>}
|
|
</Text>
|
|
|
|
{reorderMode && (
|
|
<View style={styles.reorderInfoBanner}>
|
|
<MaterialIcons name="info-outline" size={18} color={colors.primary} />
|
|
<Text style={styles.reorderInfoText}>
|
|
Addons at the top have higher priority when loading content
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{loading ? (
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color={colors.primary} />
|
|
</View>
|
|
) : (
|
|
<ScrollView
|
|
style={styles.scrollView}
|
|
showsVerticalScrollIndicator={false}
|
|
contentInsetAdjustmentBehavior="automatic"
|
|
>
|
|
|
|
{/* Overview Section */}
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>OVERVIEW</Text>
|
|
<View style={styles.statsContainer}>
|
|
<StatsCard value={addons.length} label="Addons" />
|
|
<View style={styles.statsDivider} />
|
|
<StatsCard value={addons.length} label="Active" />
|
|
<View style={styles.statsDivider} />
|
|
<StatsCard value={catalogCount} label="Catalogs" />
|
|
</View>
|
|
</View>
|
|
|
|
{/* Hide Add Addon Section in reorder mode */}
|
|
{!reorderMode && (
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>ADD NEW ADDON</Text>
|
|
<View style={styles.addAddonContainer}>
|
|
<TextInput
|
|
style={styles.addonInput}
|
|
placeholder="Addon URL"
|
|
placeholderTextColor={colors.mediumGray}
|
|
value={addonUrl}
|
|
onChangeText={setAddonUrl}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
<TouchableOpacity
|
|
style={[styles.addButton, { opacity: installing || !addonUrl ? 0.6 : 1 }]}
|
|
onPress={() => handleAddAddon()}
|
|
disabled={installing || !addonUrl}
|
|
>
|
|
<Text style={styles.addButtonText}>
|
|
{installing ? 'Loading...' : 'Add Addon'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Installed Addons Section */}
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>
|
|
{reorderMode ? "DRAG ADDONS TO REORDER" : "INSTALLED ADDONS"}
|
|
</Text>
|
|
<View style={styles.addonList}>
|
|
{addons.length === 0 ? (
|
|
<View style={styles.emptyContainer}>
|
|
<MaterialIcons name="extension-off" size={32} color={colors.mediumGray} />
|
|
<Text style={styles.emptyText}>No addons installed</Text>
|
|
</View>
|
|
) : (
|
|
addons.map((addon, index) => (
|
|
<View
|
|
key={addon.id}
|
|
style={{ marginBottom: index === addons.length - 1 ? 32 : 0 }}
|
|
>
|
|
{renderAddonItem({ item: addon, index })}
|
|
</View>
|
|
))
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Separator */}
|
|
<View style={styles.sectionSeparator} />
|
|
|
|
{/* Promotional Addon Section (hidden if installed) */}
|
|
{!isPromoInstalled && (
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>OFFICIAL ADDON</Text>
|
|
<View style={styles.addonList}>
|
|
<View style={styles.addonItem}>
|
|
<View style={styles.addonHeader}>
|
|
{promoAddon.logo ? (
|
|
<FastImage
|
|
source={{ uri: promoAddon.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}>{promoAddon.name}</Text>
|
|
<View style={styles.addonMetaContainer}>
|
|
<Text style={styles.addonVersion}>v{promoAddon.version}</Text>
|
|
<Text style={styles.addonDot}>•</Text>
|
|
<Text style={styles.addonCategory}>{promoAddon.types?.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')}</Text>
|
|
</View>
|
|
</View>
|
|
<View style={styles.addonActions}>
|
|
{promoAddon.behaviorHints?.configurable && (
|
|
<TouchableOpacity
|
|
style={styles.configButton}
|
|
onPress={() => handleConfigureAddon(promoAddon, PROMO_ADDON_URL)}
|
|
>
|
|
<MaterialIcons name="settings" size={20} color={colors.primary} />
|
|
</TouchableOpacity>
|
|
)}
|
|
<TouchableOpacity
|
|
style={styles.installButton}
|
|
onPress={() => handleAddAddon(PROMO_ADDON_URL)}
|
|
disabled={installing}
|
|
>
|
|
{installing ? (
|
|
<ActivityIndicator size="small" color={colors.white} />
|
|
) : (
|
|
<MaterialIcons name="add" size={20} color={colors.white} />
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
<Text style={styles.addonDescription}>
|
|
{promoAddon.description}
|
|
</Text>
|
|
<Text style={[styles.addonDescription, { marginTop: 4, opacity: 0.9 }]}>
|
|
Configure and install for full functionality.
|
|
</Text>
|
|
</View>
|
|
</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>
|
|
)}
|
|
|
|
{/* Addon Details Confirmation Modal */}
|
|
<Modal
|
|
visible={showConfirmModal}
|
|
transparent
|
|
animationType="fade"
|
|
onRequestClose={() => {
|
|
setShowConfirmModal(false);
|
|
setAddonDetails(null);
|
|
}}
|
|
>
|
|
<View style={styles.modalContainer}>
|
|
{Platform.OS === 'ios' ? (
|
|
GlassViewComp && liquidGlassAvailable ? (
|
|
<GlassViewComp style={styles.blurOverlay} glassEffectStyle="regular" />
|
|
) : (
|
|
<ExpoBlurView intensity={80} style={styles.blurOverlay} tint="dark" />
|
|
)
|
|
) : (
|
|
// Android: use solid themed background instead of semi-transparent overlay
|
|
<View style={[styles.androidBlurContainer, { backgroundColor: colors.darkBackground }]} />
|
|
)}
|
|
<View style={styles.modalContent}>
|
|
{addonDetails && (
|
|
<>
|
|
<View style={styles.modalHeader}>
|
|
<Text style={styles.modalTitle}>Install Addon</Text>
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
setShowConfirmModal(false);
|
|
setAddonDetails(null);
|
|
}}
|
|
>
|
|
<MaterialIcons name="close" size={24} color={colors.white} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<ScrollView
|
|
style={styles.modalScrollContent}
|
|
showsVerticalScrollIndicator={false}
|
|
bounces={true}
|
|
>
|
|
<View style={styles.addonDetailHeader}>
|
|
{/* @ts-ignore */}
|
|
{addonDetails.logo ? (
|
|
<FastImage
|
|
source={{ uri: addonDetails.logo }}
|
|
style={styles.addonLogo}
|
|
resizeMode={FastImage.resizeMode.contain}
|
|
/>
|
|
) : (
|
|
<View style={styles.addonLogoPlaceholder}>
|
|
<MaterialIcons name="extension" size={40} color={colors.mediumGray} />
|
|
</View>
|
|
)}
|
|
<Text style={styles.addonDetailName}>{addonDetails.name}</Text>
|
|
<Text style={styles.addonDetailVersion}>v{addonDetails.version || '1.0.0'}</Text>
|
|
</View>
|
|
|
|
<View style={styles.addonDetailSection}>
|
|
<Text style={styles.addonDetailSectionTitle}>Description</Text>
|
|
<Text style={styles.addonDetailDescription}>
|
|
{addonDetails.description || 'No description available'}
|
|
</Text>
|
|
</View>
|
|
|
|
{addonDetails.types && addonDetails.types.length > 0 && (
|
|
<View style={styles.addonDetailSection}>
|
|
<Text style={styles.addonDetailSectionTitle}>Supported Types</Text>
|
|
<View style={styles.addonDetailChips}>
|
|
{addonDetails.types.map((type, index) => (
|
|
<View key={index} style={styles.addonDetailChip}>
|
|
<Text style={styles.addonDetailChipText}>{type}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{addonDetails.catalogs && addonDetails.catalogs.length > 0 && (
|
|
<View style={styles.addonDetailSection}>
|
|
<Text style={styles.addonDetailSectionTitle}>Catalogs</Text>
|
|
<View style={styles.addonDetailChips}>
|
|
{addonDetails.catalogs.map((catalog, index) => (
|
|
<View key={index} style={styles.addonDetailChip}>
|
|
<Text style={styles.addonDetailChipText}>
|
|
{catalog.type} - {catalog.id}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
|
|
<View style={styles.modalActions}>
|
|
<TouchableOpacity
|
|
style={[styles.modalButton, styles.cancelButton]}
|
|
onPress={() => {
|
|
setShowConfirmModal(false);
|
|
setAddonDetails(null);
|
|
}}
|
|
>
|
|
<Text style={styles.modalButtonText}>Cancel</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.modalButton, styles.installButton]}
|
|
onPress={confirmInstallAddon}
|
|
disabled={installing}
|
|
>
|
|
{installing ? (
|
|
<ActivityIndicator size="small" color={colors.white} />
|
|
) : (
|
|
<Text style={styles.modalButtonText}>Install</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
{/* Custom Alert Modal */}
|
|
<CustomAlert
|
|
visible={alertVisible}
|
|
title={alertTitle}
|
|
message={alertMessage}
|
|
onClose={() => setAlertVisible(false)}
|
|
actions={alertActions}
|
|
/>
|
|
</SafeAreaView>
|
|
);
|
|
};
|
|
|
|
export default AddonsScreen; |