import React, { useCallback, useState, useEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Switch, ScrollView, SafeAreaView, StatusBar, Platform, Dimensions, Button, Linking, Clipboard } from 'react-native'; import { mmkvStorage } from '../services/mmkvStorage'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import FastImage from '@d11/react-native-fast-image'; import LottieView from 'lottie-react-native'; import { Feather } from '@expo/vector-icons'; import { Picker } from '@react-native-picker/picker'; import { useSettings, DEFAULT_SETTINGS } from '../hooks/useSettings'; import { RootStackParamList } from '../navigation/AppNavigator'; import { stremioService } from '../services/stremioService'; import { useCatalogContext } from '../contexts/CatalogContext'; import { useTraktContext } from '../contexts/TraktContext'; import { useTheme } from '../contexts/ThemeContext'; import { catalogService } from '../services/catalogService'; import { fetchTotalDownloads } from '../services/githubReleaseService'; import * as WebBrowser from 'expo-web-browser'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as Sentry from '@sentry/react-native'; import { getDisplayedAppVersion } from '../utils/version'; import CustomAlert from '../components/CustomAlert'; import ScreenHeader from '../components/common/ScreenHeader'; import PluginIcon from '../components/icons/PluginIcon'; import TraktIcon from '../components/icons/TraktIcon'; import TMDBIcon from '../components/icons/TMDBIcon'; import MDBListIcon from '../components/icons/MDBListIcon'; import { campaignService } from '../services/campaignService'; const { width, height } = Dimensions.get('window'); const isTablet = width >= 768; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; // Settings categories for tablet sidebar const SETTINGS_CATEGORIES = [ { id: 'account', title: 'Account', icon: 'user' as string }, { id: 'content', title: 'Content & Discovery', icon: 'compass' as string }, { id: 'appearance', title: 'Appearance', icon: 'sliders' as string }, { id: 'integrations', title: 'Integrations', icon: 'layers' as string }, { id: 'ai', title: 'AI Assistant', icon: 'cpu' as string }, { id: 'playback', title: 'Playback', icon: 'play-circle' as string }, { id: 'backup', title: 'Backup & Restore', icon: 'archive' as string }, { id: 'updates', title: 'Updates', icon: 'refresh-ccw' as string }, { id: 'about', title: 'About', icon: 'info' as string }, { id: 'developer', title: 'Developer', icon: 'code' as string }, { id: 'cache', title: 'Cache', icon: 'database' as string }, ]; // Card component with minimalistic style interface SettingsCardProps { children: React.ReactNode; title?: string; isTablet?: boolean; } const SettingsCard: React.FC = ({ children, title, isTablet = false }) => { const { currentTheme } = useTheme(); return ( {title && ( {title} )} {children} ); }; interface SettingItemProps { title: string; description?: string; icon?: string; customIcon?: React.ReactNode; renderControl?: () => React.ReactNode; isLast?: boolean; onPress?: () => void; badge?: string | number; isTablet?: boolean; } const SettingItem: React.FC = ({ title, description, icon, customIcon, renderControl, isLast = false, onPress, badge, isTablet = false }) => { const { currentTheme } = useTheme(); return ( {customIcon ? ( customIcon ) : ( )} {title} {description && ( {description} )} {badge && ( {String(badge)} )} {renderControl && ( {renderControl()} )} ); }; // Tablet Sidebar Component interface SidebarProps { selectedCategory: string; onCategorySelect: (category: string) => void; currentTheme: any; categories: typeof SETTINGS_CATEGORIES; extraTopPadding?: number; } const Sidebar: React.FC = ({ selectedCategory, onCategorySelect, currentTheme, categories, extraTopPadding = 0 }) => { return ( Settings {categories.map((category) => ( onCategorySelect(category.id)} activeOpacity={0.6} > {category.title} ))} ); }; const SettingsScreen: React.FC = () => { const { settings, updateSetting } = useSettings(); const [hasUpdateBadge, setHasUpdateBadge] = useState(false); // CustomAlert state const [alertVisible, setAlertVisible] = useState(false); const [alertTitle, setAlertTitle] = useState(''); const [alertMessage, setAlertMessage] = useState(''); const [alertActions, setAlertActions] = useState void; style?: object }>>([]); const openAlert = ( title: string, message: string, actions?: Array<{ label: string; onPress: () => void; style?: object }> ) => { setAlertTitle(title); setAlertMessage(message); setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]); setAlertVisible(true); }; useEffect(() => { if (Platform.OS !== 'android') return; let mounted = true; (async () => { try { const flag = await mmkvStorage.getItem('@update_badge_pending'); if (mounted) setHasUpdateBadge(flag === 'true'); } catch { } })(); return () => { mounted = false; }; }, []); const navigation = useNavigation>(); const { lastUpdate } = useCatalogContext(); const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext(); const { currentTheme } = useTheme(); const insets = useSafeAreaInsets(); // Tablet-specific state const [selectedCategory, setSelectedCategory] = useState('account'); // States for dynamic content const [addonCount, setAddonCount] = useState(0); const [catalogCount, setCatalogCount] = useState(0); const [mdblistKeySet, setMdblistKeySet] = useState(false); const [openRouterKeySet, setOpenRouterKeySet] = useState(false); const [initialLoadComplete, setInitialLoadComplete] = useState(false); const [totalDownloads, setTotalDownloads] = useState(null); const [displayDownloads, setDisplayDownloads] = useState(null); const [isCountingUp, setIsCountingUp] = useState(false); // Add a useEffect to check Trakt authentication status on focus useEffect(() => { // This will reload the Trakt auth status whenever the settings screen is focused const unsubscribe = navigation.addListener('focus', () => { // Force a re-render when returning to this screen // This will reflect the updated isAuthenticated state from the TraktContext // Refresh auth status if (isAuthenticated || userProfile) { // Just to be cautious, log the current state if (__DEV__) console.log('SettingsScreen focused, refreshing auth status. Current state:', { isAuthenticated, userProfile: userProfile?.username }); } refreshAuthStatus(); }); return unsubscribe; }, [navigation, isAuthenticated, userProfile, refreshAuthStatus]); const loadData = useCallback(async () => { try { // Load addon count and get their catalogs const addons = await stremioService.getInstalledAddonsAsync(); setAddonCount(addons.length); setInitialLoadComplete(true); // Count total available catalogs let totalCatalogs = 0; addons.forEach(addon => { if (addon.catalogs && addon.catalogs.length > 0) { totalCatalogs += addon.catalogs.length; } }); // Load saved catalog settings const catalogSettingsJson = await mmkvStorage.getItem('catalog_settings'); if (catalogSettingsJson) { const catalogSettings = JSON.parse(catalogSettingsJson); // Filter out _lastUpdate key and count only explicitly disabled catalogs const disabledCount = Object.entries(catalogSettings) .filter(([key, value]) => key !== '_lastUpdate' && value === false) .length; // Since catalogs are enabled by default, subtract disabled ones from total setCatalogCount(totalCatalogs - disabledCount); } else { // If no settings saved, all catalogs are enabled by default setCatalogCount(totalCatalogs); } // Check MDBList API key status const mdblistKey = await mmkvStorage.getItem('mdblist_api_key'); setMdblistKeySet(!!mdblistKey); // Check OpenRouter API key status const openRouterKey = await mmkvStorage.getItem('openrouter_api_key'); setOpenRouterKeySet(!!openRouterKey); // Load GitHub total downloads (initial load only, polling happens in useEffect) const downloads = await fetchTotalDownloads(); if (downloads !== null) { setTotalDownloads(downloads); setDisplayDownloads(downloads); } } catch (error) { if (__DEV__) console.error('Error loading settings data:', error); } }, []); // Load data initially and when catalogs are updated useEffect(() => { loadData(); }, [loadData, lastUpdate]); // Add focus listener to reload data when screen comes into focus useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { loadData(); }); return unsubscribe; }, [navigation, loadData]); // Poll GitHub downloads every 10 seconds when on the About section useEffect(() => { // Only poll when viewing the About section (where downloads counter is shown) const shouldPoll = isTablet ? selectedCategory === 'about' : true; if (!shouldPoll) return; const pollInterval = setInterval(async () => { try { const downloads = await fetchTotalDownloads(); if (downloads !== null && downloads !== totalDownloads) { setTotalDownloads(downloads); } } catch (error) { if (__DEV__) console.error('Error polling downloads:', error); } }, 3600000); // 3600000 milliseconds (1 hour) return () => clearInterval(pollInterval); }, [selectedCategory, isTablet, totalDownloads]); // Animate counting up when totalDownloads changes useEffect(() => { if (totalDownloads === null || displayDownloads === null) return; if (totalDownloads === displayDownloads) return; setIsCountingUp(true); const start = displayDownloads; const end = totalDownloads; const duration = 2000; // 2 seconds animation const startTime = Date.now(); const animate = () => { const now = Date.now(); const elapsed = now - startTime; const progress = Math.min(elapsed / duration, 1); // Ease out quad for smooth deceleration const easeProgress = 1 - Math.pow(1 - progress, 2); const current = Math.floor(start + (end - start) * easeProgress); setDisplayDownloads(current); if (progress < 1) { requestAnimationFrame(animate); } else { setDisplayDownloads(end); setIsCountingUp(false); } }; requestAnimationFrame(animate); }, [totalDownloads]); const handleResetSettings = useCallback(() => { openAlert( 'Reset Settings', 'Are you sure you want to reset all settings to default values?', [ { label: 'Cancel', onPress: () => { } }, { label: 'Reset', onPress: () => { (Object.keys(DEFAULT_SETTINGS) as Array).forEach(key => { updateSetting(key, DEFAULT_SETTINGS[key]); }); } } ] ); }, [updateSetting]); const handleClearMDBListCache = () => { openAlert( 'Clear MDBList Cache', 'Are you sure you want to clear all cached MDBList data? This cannot be undone.', [ { label: 'Cancel', onPress: () => { } }, { label: 'Clear', onPress: async () => { try { await mmkvStorage.removeItem('mdblist_cache'); openAlert('Success', 'MDBList cache has been cleared.'); } catch (error) { openAlert('Error', 'Could not clear MDBList cache.'); if (__DEV__) console.error('Error clearing MDBList cache:', error); } } } ] ); }; const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => ( ); const ChevronRight = () => ( ); // Filter categories based on conditions const visibleCategories = SETTINGS_CATEGORIES.filter(category => { if (category.id === 'developer' && !__DEV__) return false; if (category.id === 'cache' && !mdblistKeySet) return false; return true; }); const renderCategoryContent = (categoryId: string) => { switch (categoryId) { case 'account': return ( } renderControl={ChevronRight} onPress={() => navigation.navigate('TraktSettings')} isLast={true} isTablet={isTablet} /> ); case 'content': return ( navigation.navigate('Addons')} isTablet={isTablet} /> navigation.navigate('DebridIntegration')} isTablet={isTablet} /> } renderControl={ChevronRight} onPress={() => navigation.navigate('ScraperSettings')} isTablet={isTablet} /> navigation.navigate('CatalogSettings')} isTablet={isTablet} /> navigation.navigate('HomeScreenSettings')} isTablet={isTablet} /> navigation.navigate('ContinueWatchingSettings')} isLast={true} isTablet={isTablet} /> ); case 'appearance': return ( navigation.navigate('ThemeSettings')} isTablet={isTablet} /> ( updateSetting('episodeLayoutStyle', value ? 'horizontal' : 'vertical')} /> )} isLast={isTablet} isTablet={isTablet} /> {!isTablet && ( ( updateSetting('enableStreamsBackdrop', value)} /> )} isLast={true} isTablet={isTablet} /> )} ); case 'integrations': return ( } renderControl={ChevronRight} onPress={() => navigation.navigate('MDBListSettings')} isTablet={isTablet} /> } renderControl={ChevronRight} onPress={() => navigation.navigate('TMDBSettings')} isLast={true} isTablet={isTablet} /> ); case 'ai': return ( navigation.navigate('AISettings')} isLast={true} isTablet={isTablet} /> ); case 'playback': return ( navigation.navigate('PlayerSettings')} isTablet={isTablet} /> ( updateSetting('showTrailers', value)} trackColor={{ false: 'rgba(255,255,255,0.2)', true: currentTheme.colors.primary }} thumbColor={settings?.showTrailers ? '#fff' : '#f4f3f4'} /> )} isTablet={isTablet} /> ( updateSetting('enableDownloads', value)} trackColor={{ false: 'rgba(255,255,255,0.2)', true: currentTheme.colors.primary }} thumbColor={settings?.enableDownloads ? '#fff' : '#f4f3f4'} /> )} isTablet={isTablet} /> navigation.navigate('NotificationSettings')} isLast={true} isTablet={isTablet} /> ); case 'about': return ( Linking.openURL('https://tapframe.github.io/NuvioStreaming/#privacy-policy')} renderControl={ChevronRight} isTablet={isTablet} /> Sentry.showFeedbackWidget()} renderControl={ChevronRight} isTablet={isTablet} /> navigation.navigate('Contributors')} isLast={true} isTablet={isTablet} /> ); case 'developer': return __DEV__ ? ( navigation.navigate('Onboarding')} renderControl={ChevronRight} isTablet={isTablet} /> { try { await mmkvStorage.removeItem('hasCompletedOnboarding'); openAlert('Success', 'Onboarding has been reset. Restart the app to see the onboarding flow.'); } catch (error) { openAlert('Error', 'Failed to reset onboarding.'); } }} renderControl={ChevronRight} isTablet={isTablet} /> { try { await mmkvStorage.removeItem('announcement_v1.0.0_shown'); openAlert('Success', 'Announcement reset. Restart the app to see the announcement overlay.'); } catch (error) { openAlert('Error', 'Failed to reset announcement.'); } }} renderControl={ChevronRight} isTablet={isTablet} /> { await campaignService.resetCampaigns(); openAlert('Success', 'Campaign history reset. Restart app to see posters again.'); }} renderControl={ChevronRight} isTablet={isTablet} /> { openAlert( 'Clear All Data', 'This will reset all settings and clear all cached data. Are you sure?', [ { label: 'Cancel', onPress: () => { } }, { label: 'Clear', onPress: async () => { try { await mmkvStorage.clear(); openAlert('Success', 'All data cleared. Please restart the app.'); } catch (error) { openAlert('Error', 'Failed to clear data.'); } } } ] ); }} isLast={true} isTablet={isTablet} /> ) : null; case 'cache': return mdblistKeySet ? ( ) : null; case 'backup': return ( navigation.navigate('Backup')} isLast={true} isTablet={isTablet} /> ); case 'updates': return ( { if (Platform.OS === 'android') { try { await mmkvStorage.removeItem('@update_badge_pending'); } catch { } setHasUpdateBadge(false); } navigation.navigate('Update'); }} isLast={true} isTablet={isTablet} /> ); default: return null; } }; // Keep headers below floating top navigator on tablets by adding extra offset const tabletNavOffset = isTablet ? 64 : 0; if (isTablet) { return ( {renderCategoryContent(selectedCategory)} {selectedCategory === 'about' && ( <> {displayDownloads !== null && ( {displayDownloads.toLocaleString()} downloads and counting )} WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', { presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET })} activeOpacity={0.7} > Linking.openURL('https://discord.gg/6w8dr3TSDN')} activeOpacity={0.7} > Discord Linking.openURL('https://www.reddit.com/r/Nuvio/')} activeOpacity={0.7} > Reddit {/* Monkey Animation */} Made with ❤️ by Tapframe and Friends )} setAlertVisible(false)} /> ); } // Mobile Layout (original) return ( {renderCategoryContent('account')} {renderCategoryContent('content')} {renderCategoryContent('appearance')} {renderCategoryContent('integrations')} {renderCategoryContent('ai')} {renderCategoryContent('playback')} {renderCategoryContent('backup')} {renderCategoryContent('updates')} {renderCategoryContent('about')} {renderCategoryContent('developer')} {renderCategoryContent('cache')} {displayDownloads !== null && ( {displayDownloads.toLocaleString()} downloads and counting )} {/* Support & Community Buttons */} WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', { presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET })} activeOpacity={0.7} > Linking.openURL('https://discord.gg/6w8dr3TSDN')} activeOpacity={0.7} > Discord Linking.openURL('https://www.reddit.com/r/Nuvio/')} activeOpacity={0.7} > Reddit {/* Monkey Animation */} Made with ❤️ by Tapframe and friends setAlertVisible(false)} /> ); }; const styles = StyleSheet.create({ container: { flex: 1, }, // Mobile styles contentContainer: { flex: 1, zIndex: 1, width: '100%', }, scrollView: { flex: 1, width: '100%', }, scrollContent: { flexGrow: 1, width: '100%', paddingTop: 8, paddingBottom: 100, }, // Tablet-specific styles tabletContainer: { flex: 1, flexDirection: 'row', }, sidebar: { width: 280, borderRightWidth: 1, }, sidebarHeader: { paddingHorizontal: 24, paddingBottom: 20, paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 24 : 48, borderBottomWidth: 1, }, sidebarTitle: { fontSize: 42, fontWeight: '700', letterSpacing: -0.3, }, sidebarContent: { flex: 1, paddingTop: 12, paddingBottom: 24, }, sidebarItem: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, marginHorizontal: 12, marginVertical: 2, borderRadius: 10, }, sidebarItemActive: { borderRadius: 10, }, sidebarItemIconContainer: { width: 32, height: 32, borderRadius: 8, alignItems: 'center', justifyContent: 'center', }, sidebarItemText: { fontSize: 15, marginLeft: 12, }, tabletContent: { flex: 1, paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 24 : 48, }, tabletScrollView: { flex: 1, paddingHorizontal: 40, }, tabletScrollContent: { paddingTop: 8, paddingBottom: 40, }, // Common card styles cardContainer: { width: '100%', marginBottom: 24, }, tabletCardContainer: { marginBottom: 28, }, cardTitle: { fontSize: 12, fontWeight: '600', letterSpacing: 1, marginLeft: Math.max(16, width * 0.045), marginBottom: 10, textTransform: 'uppercase', }, tabletCardTitle: { fontSize: 12, marginLeft: 4, marginBottom: 12, }, card: { marginHorizontal: Math.max(16, width * 0.04), borderRadius: 14, overflow: 'hidden', width: undefined, }, tabletCard: { marginHorizontal: 0, borderRadius: 16, }, settingItem: { flexDirection: 'row', alignItems: 'center', paddingVertical: 14, paddingHorizontal: Math.max(14, width * 0.04), borderBottomWidth: StyleSheet.hairlineWidth, minHeight: Math.max(60, width * 0.15), width: '100%', }, tabletSettingItem: { paddingVertical: 16, paddingHorizontal: 20, minHeight: 68, }, settingItemBorder: { // Border styling handled directly in the component with borderBottomWidth }, settingIconContainer: { marginRight: 14, width: 38, height: 38, borderRadius: 10, alignItems: 'center', justifyContent: 'center', }, tabletSettingIconContainer: { width: 42, height: 42, borderRadius: 11, marginRight: 16, }, settingContent: { flex: 1, flexDirection: 'row', alignItems: 'center', }, settingTextContainer: { flex: 1, }, settingTitle: { fontSize: Math.min(16, width * 0.04), fontWeight: '500', marginBottom: 2, letterSpacing: -0.2, }, tabletSettingTitle: { fontSize: 17, fontWeight: '500', marginBottom: 3, }, settingDescription: { fontSize: Math.min(13, width * 0.034), opacity: 0.7, }, tabletSettingDescription: { fontSize: 14, opacity: 0.6, }, settingControl: { justifyContent: 'center', alignItems: 'center', paddingLeft: 10, }, badge: { height: 20, minWidth: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 6, marginRight: 8, }, badgeText: { color: 'white', fontSize: 11, fontWeight: '700', }, segmentedControl: { flexDirection: 'row', backgroundColor: 'rgba(255,255,255,0.08)', borderRadius: 8, padding: 2, }, segment: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 6, minWidth: 60, alignItems: 'center', }, segmentActive: { backgroundColor: 'rgba(255,255,255,0.16)', }, segmentText: { fontSize: 13, fontWeight: '500', }, segmentTextActive: { color: 'white', fontWeight: '600', }, footer: { alignItems: 'center', justifyContent: 'center', marginTop: 0, marginBottom: 12, }, footerText: { fontSize: 13, opacity: 0.5, letterSpacing: 0.2, }, // Support buttons discordContainer: { marginTop: 12, marginBottom: 24, alignItems: 'center', }, discordButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 10, paddingHorizontal: 18, borderRadius: 10, maxWidth: 200, }, discordButtonContent: { flexDirection: 'row', alignItems: 'center', }, discordLogo: { width: 18, height: 18, marginRight: 10, }, discordButtonText: { fontSize: 14, fontWeight: '600', }, kofiImage: { height: 34, width: 155, }, downloadsContainer: { marginTop: 32, marginBottom: 16, alignItems: 'center', }, downloadsNumber: { fontSize: 36, fontWeight: '800', letterSpacing: 0.5, marginBottom: 6, }, downloadsLabel: { fontSize: 11, fontWeight: '600', opacity: 0.5, letterSpacing: 1.5, textTransform: 'uppercase', }, loadingSpinner: { width: 16, height: 16, borderWidth: 2, borderRadius: 8, borderTopColor: 'transparent', marginRight: 8, }, monkeyContainer: { alignItems: 'center', justifyContent: 'center', marginTop: 0, marginBottom: 32, }, monkeyAnimation: { width: 180, height: 180, }, }); export default SettingsScreen;