From 832e5368be3a446898a4d61a361d356ff2bfc0e8 Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 29 Dec 2025 15:05:50 +0530 Subject: [PATCH] settingscreen refactor --- src/components/player/AndroidVideoPlayer.tsx | 55 +++ .../player/controls/PlayerControls.tsx | 160 +++++---- src/navigation/AppNavigator.tsx | 107 ++++++ src/screens/AddonsScreen.tsx | 326 +----------------- src/screens/PluginsScreen.tsx | 260 +++++++------- src/screens/SettingsScreen.tsx | 124 ++++++- src/screens/settings/AboutSettingsScreen.tsx | 194 +++++++++++ .../settings/AppearanceSettingsScreen.tsx | 87 +++++ .../ContentDiscoverySettingsScreen.tsx | 153 ++++++++ .../settings/DeveloperSettingsScreen.tsx | 158 +++++++++ .../settings/IntegrationsSettingsScreen.tsx | 100 ++++++ .../settings/PlaybackSettingsScreen.tsx | 95 +++++ src/screens/settings/SettingsComponents.tsx | 268 ++++++++++++++ src/screens/settings/index.ts | 7 + 14 files changed, 1557 insertions(+), 537 deletions(-) create mode 100644 src/screens/settings/AboutSettingsScreen.tsx create mode 100644 src/screens/settings/AppearanceSettingsScreen.tsx create mode 100644 src/screens/settings/ContentDiscoverySettingsScreen.tsx create mode 100644 src/screens/settings/DeveloperSettingsScreen.tsx create mode 100644 src/screens/settings/IntegrationsSettingsScreen.tsx create mode 100644 src/screens/settings/PlaybackSettingsScreen.tsx create mode 100644 src/screens/settings/SettingsComponents.tsx create mode 100644 src/screens/settings/index.ts diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 73102e1..98f9444 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -42,6 +42,8 @@ import { CustomSubtitles } from './subtitles/CustomSubtitles'; import ParentalGuideOverlay from './overlays/ParentalGuideOverlay'; import SkipIntroButton from './overlays/SkipIntroButton'; import UpNextButton from './common/UpNextButton'; +import { CustomAlert } from '../CustomAlert'; + // Android-specific components import { VideoSurface } from './android/components/VideoSurface'; @@ -98,6 +100,8 @@ const AndroidVideoPlayer: React.FC = () => { const shouldUseMpvOnly = settings.videoPlayerEngine === 'mpv'; const [useExoPlayer, setUseExoPlayer] = useState(!shouldUseMpvOnly); const hasExoPlayerFailed = useRef(false); + const [showMpvSwitchAlert, setShowMpvSwitchAlert] = useState(false); + // Sync useExoPlayer with settings when videoPlayerEngine is set to 'mpv' // Only run once on mount to avoid re-render loops @@ -366,6 +370,34 @@ const AndroidVideoPlayer: React.FC = () => { } }, []); + // Handle manual switch to MPV - for users experiencing black screen + const handleManualSwitchToMPV = useCallback(() => { + if (useExoPlayer && !hasExoPlayerFailed.current) { + setShowMpvSwitchAlert(true); + } + }, [useExoPlayer]); + + // Confirm and execute the switch to MPV + const confirmSwitchToMPV = useCallback(() => { + hasExoPlayerFailed.current = true; + logger.info('[AndroidVideoPlayer] User confirmed switch to MPV'); + ToastAndroid.show('Switching to MPV player...', ToastAndroid.SHORT); + + // Store current playback position before switching + const currentPos = playerState.currentTime; + + // Switch to MPV + setUseExoPlayer(false); + + // Seek to current position after a brief delay to ensure MPV is loaded + setTimeout(() => { + if (mpvPlayerRef.current && currentPos > 0) { + mpvPlayerRef.current.seek(currentPos); + } + }, 500); + }, [playerState.currentTime]); + + const handleSelectStream = async (newStream: any) => { if (newStream.url === currentStreamUrl) { modals.setShowSourcesModal(false); @@ -722,6 +754,8 @@ const AndroidVideoPlayer: React.FC = () => { buffered={playerState.buffered} formatTime={formatTime} playerBackend={useExoPlayer ? 'ExoPlayer' : 'MPV'} + onSwitchToMPV={handleManualSwitchToMPV} + useExoPlayer={useExoPlayer} /> { metadata={{ id: id, name: title }} /> + {/* MPV Switch Confirmation Alert */} + setShowMpvSwitchAlert(false)} + actions={[ + { + label: 'Cancel', + onPress: () => setShowMpvSwitchAlert(false), + }, + { + label: 'Switch to MPV', + onPress: () => { + setShowMpvSwitchAlert(false); + confirmSwitchToMPV(); + }, + }, + ]} + /> + ); }; diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx index 0fd3107..444666c 100644 --- a/src/components/player/controls/PlayerControls.tsx +++ b/src/components/player/controls/PlayerControls.tsx @@ -24,7 +24,7 @@ interface PlayerControlsProps { duration: number; zoomScale: number; currentResizeMode?: string; - ksAudioTracks: Array<{id: number, name: string, language?: string}>; + ksAudioTracks: Array<{ id: number, name: string, language?: string }>; selectedAudioTrack: number | null; availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; togglePlayback: () => void; @@ -50,6 +50,9 @@ interface PlayerControlsProps { isAirPlayActive?: boolean; allowsAirPlay?: boolean; onAirPlayPress?: () => void; + // MPV Switch (Android only) + onSwitchToMPV?: () => void; + useExoPlayer?: boolean; } export const PlayerControls: React.FC = ({ @@ -92,6 +95,8 @@ export const PlayerControls: React.FC = ({ isAirPlayActive, allowsAirPlay, onAirPlayPress, + onSwitchToMPV, + useExoPlayer, }) => { const { currentTheme } = useTheme(); @@ -131,7 +136,7 @@ export const PlayerControls: React.FC = ({ /* Handle Seek with Animation */ const handleSeekWithAnimation = (seconds: number) => { const isForward = seconds > 0; - + if (isForward) { setShowForwardSign(true); } else { @@ -336,6 +341,19 @@ export const PlayerControls: React.FC = ({ /> )} + {/* Switch to MPV Button - Android only, when using ExoPlayer */} + {Platform.OS === 'android' && onSwitchToMPV && useExoPlayer && ( + + + + )} @@ -343,34 +361,34 @@ export const PlayerControls: React.FC = ({ - + {/* Center Controls - CloudStream Style */} - - + {/* Backward Seek Button (-10s) */} - handleSeekWithAnimation(-10)} + handleSeekWithAnimation(-10)} activeOpacity={0.7} > - = ({ }]}> {showBackwardSign ? '-10' : '10'} - - + + - - + ]} /> + {/* Play/Pause Button */} - - - @@ -449,26 +467,26 @@ export const PlayerControls: React.FC = ({ {/* Forward Seek Button (+10s) */} - handleSeekWithAnimation(10)} - activeOpacity={0.7} - > - - + handleSeekWithAnimation(10)} + activeOpacity={0.7} + > + + = ({ }]}> {showForwardSign ? '+10' : '10'} @@ -566,10 +584,10 @@ export const PlayerControls: React.FC = ({ onPress={() => setShowAudioModal(true)} disabled={ksAudioTracks.length <= 1} > - diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index ed0c4ce..4b01188 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -71,6 +71,15 @@ import BackupScreen from '../screens/BackupScreen'; import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen'; import ContributorsScreen from '../screens/ContributorsScreen'; import DebridIntegrationScreen from '../screens/DebridIntegrationScreen'; +import { + ContentDiscoverySettingsScreen, + AppearanceSettingsScreen, + IntegrationsSettingsScreen, + PlaybackSettingsScreen, + AboutSettingsScreen, + DeveloperSettingsScreen, +} from '../screens/settings'; + // Optional Android immersive mode module let RNImmersiveMode: any = null; @@ -199,8 +208,16 @@ export type RootStackParamList = { ContinueWatchingSettings: undefined; Contributors: undefined; DebridIntegration: undefined; + // New organized settings screens + ContentDiscoverySettings: undefined; + AppearanceSettings: undefined; + IntegrationsSettings: undefined; + PlaybackSettings: undefined; + AboutSettings: undefined; + DeveloperSettings: undefined; }; + export type RootStackNavigationProp = NativeStackNavigationProp; // Tab navigator types @@ -1641,6 +1658,96 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> + + + + + + diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index 85528a5..4644889 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -60,12 +60,6 @@ interface ExtendedManifest extends Manifest { }; } -// Interface for Community Addon structure from the JSON URL -interface CommunityAddon { - transportUrl: string; - manifest: ExtendedManifest; -} - const { width } = Dimensions.get('window'); const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; @@ -476,67 +470,6 @@ const createStyles = (colors: any) => StyleSheet.create({ 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, }, @@ -623,36 +556,9 @@ const AddonsScreen = () => { const colors = currentTheme.colors; const styles = createStyles(colors); - // State for community addons - const [communityAddons, setCommunityAddons] = useState([]); - const [communityLoading, setCommunityLoading] = useState(true); - const [communityError, setCommunityError] = useState(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 () => { @@ -706,33 +612,13 @@ const AddonsScreen = () => { } }; - // 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) - 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'); + setAlertMessage('Please enter an addon URL'); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertVisible(true); return; @@ -787,7 +673,7 @@ const AddonsScreen = () => { const refreshAddons = async () => { loadAddons(); - loadCommunityAddons(); + loadAddons(); }; const moveAddonUp = (addon: ExtendedManifest) => { @@ -1061,66 +947,6 @@ 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} @@ -1257,154 +1083,6 @@ const AddonsScreen = () => { )} - - {/* Separator */} - - - {/* Promotional Addon Section (hidden if installed) */} - {!isPromoInstalled && ( - - OFFICIAL ADDON - - - - {promoAddon.logo ? ( - - ) : ( - - - - )} - - {promoAddon.name} - - v{promoAddon.version} - - {promoAddon.types?.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')} - - - - {promoAddon.behaviorHints?.configurable && ( - handleConfigureAddon(promoAddon, PROMO_ADDON_URL)} - > - - - )} - handleAddAddon(PROMO_ADDON_URL)} - disabled={installing} - > - {installing ? ( - - ) : ( - - )} - - - - - {promoAddon.description} - - - Configure and install for full functionality. - - - - - )} - - {/* 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.'} - - - - )) - )} - - )} diff --git a/src/screens/PluginsScreen.tsx b/src/screens/PluginsScreen.tsx index f0bf46d..2d35def 100644 --- a/src/screens/PluginsScreen.tsx +++ b/src/screens/PluginsScreen.tsx @@ -113,7 +113,7 @@ const createStyles = (colors: any) => StyleSheet.create({ color: colors.mediumGray, fontSize: 15, }, - scraperItem: { + pluginItem: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.elevation2, @@ -126,46 +126,46 @@ const createStyles = (colors: any) => StyleSheet.create({ shadowRadius: 2, elevation: 1, }, - scraperLogo: { + pluginLogo: { width: 40, height: 40, marginRight: 12, borderRadius: 6, backgroundColor: colors.elevation3, }, - scraperInfo: { + pluginInfo: { flex: 1, }, - scraperName: { + pluginName: { fontSize: 15, fontWeight: '600', color: colors.white, marginBottom: 2, }, - scraperDescription: { + pluginDescription: { fontSize: 13, color: colors.mediumGray, marginBottom: 4, lineHeight: 18, }, - scraperMeta: { + pluginMeta: { flexDirection: 'row', alignItems: 'center', }, - scraperVersion: { + pluginVersion: { fontSize: 12, color: colors.mediumGray, }, - scraperDot: { + pluginDot: { fontSize: 12, color: colors.mediumGray, marginHorizontal: 8, }, - scraperTypes: { + pluginTypes: { fontSize: 12, color: colors.mediumGray, }, - scraperLanguage: { + pluginLanguage: { fontSize: 12, color: colors.mediumGray, }, @@ -307,10 +307,10 @@ const createStyles = (colors: any) => StyleSheet.create({ textAlign: 'center', lineHeight: 20, }, - scrapersList: { + pluginsList: { gap: 12, }, - scrapersContainer: { + pluginsContainer: { marginBottom: 24, }, inputContainer: { @@ -649,7 +649,7 @@ const createStyles = (colors: any) => StyleSheet.create({ fontSize: 15, fontWeight: '500', }, - scraperCard: { + pluginCard: { backgroundColor: colors.elevation2, borderRadius: 12, padding: 16, @@ -658,29 +658,29 @@ const createStyles = (colors: any) => StyleSheet.create({ borderColor: colors.elevation3, minHeight: 120, }, - scraperCardHeader: { + pluginCardHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12, }, - scraperCardInfo: { + pluginCardInfo: { flex: 1, marginRight: 12, }, - scraperCardMeta: { + pluginCardMeta: { flexDirection: 'row', alignItems: 'center', marginTop: 8, gap: 8, flexWrap: 'wrap', }, - scraperCardMetaItem: { + pluginCardMetaItem: { flexDirection: 'row', alignItems: 'center', gap: 2, marginBottom: 4, }, - scraperCardMetaText: { + pluginCardMetaText: { fontSize: 12, color: colors.mediumGray, }, @@ -862,7 +862,7 @@ const PluginsScreen: React.FC = () => { // Core state const [repositoryUrl, setRepositoryUrl] = useState(settings.scraperRepositoryUrl); - const [installedScrapers, setInstalledScrapers] = useState([]); + const [installedPlugins, setInstalledPlugins] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [hasRepository, setHasRepository] = useState(false); @@ -883,7 +883,7 @@ const PluginsScreen: React.FC = () => { const [selectedFilter, setSelectedFilter] = useState<'all' | 'movie' | 'tv'>('all'); const [expandedSections, setExpandedSections] = useState({ repository: true, - scrapers: true, + plugins: true, settings: false, quality: false, }); @@ -904,29 +904,29 @@ const PluginsScreen: React.FC = () => { { value: 'SZ', label: 'China' }, ]; - // Filtered scrapers based on search and filter - const filteredScrapers = useMemo(() => { - let filtered = installedScrapers; + // Filtered plugins based on search and filter + const filteredPlugins = useMemo(() => { + let filtered = installedPlugins; // Filter by search query if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); - filtered = filtered.filter(scraper => - scraper.name.toLowerCase().includes(query) || - scraper.description.toLowerCase().includes(query) || - scraper.id.toLowerCase().includes(query) + filtered = filtered.filter(plugin => + plugin.name.toLowerCase().includes(query) || + plugin.description.toLowerCase().includes(query) || + plugin.id.toLowerCase().includes(query) ); } // Filter by type if (selectedFilter !== 'all') { - filtered = filtered.filter(scraper => - scraper.supportedTypes?.includes(selectedFilter as 'movie' | 'tv') + filtered = filtered.filter(plugin => + plugin.supportedTypes?.includes(selectedFilter as 'movie' | 'tv') ); } return filtered; - }, [installedScrapers, searchQuery, selectedFilter]); + }, [installedPlugins, searchQuery, selectedFilter]); // Helper functions const toggleSection = (section: keyof typeof expandedSections) => { @@ -936,26 +936,26 @@ const PluginsScreen: React.FC = () => { })); }; - const getScraperStatus = (scraper: ScraperInfo): 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error' | 'limited' => { - if (scraper.manifestEnabled === false) return 'disabled'; - if (scraper.disabledPlatforms?.includes(Platform.OS as 'ios' | 'android')) return 'platform-disabled'; - if (scraper.limited) return 'limited'; - if (scraper.enabled) return 'enabled'; + const getPluginStatus = (plugin: ScraperInfo): 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error' | 'limited' => { + if (plugin.manifestEnabled === false) return 'disabled'; + if (plugin.disabledPlatforms?.includes(Platform.OS as 'ios' | 'android')) return 'platform-disabled'; + if (plugin.limited) return 'limited'; + if (plugin.enabled) return 'enabled'; return 'available'; }; const handleBulkToggle = async (enabled: boolean) => { try { setIsRefreshing(true); - const promises = filteredScrapers.map(scraper => - pluginService.setScraperEnabled(scraper.id, enabled) + const promises = filteredPlugins.map(plugin => + pluginService.setScraperEnabled(plugin.id, enabled) ); await Promise.all(promises); - await loadScrapers(); - openAlert('Success', `${enabled ? 'Enabled' : 'Disabled'} ${filteredScrapers.length} scrapers`); + await loadPlugins(); + openAlert('Success', `${enabled ? 'Enabled' : 'Disabled'} ${filteredPlugins.length} plugins`); } catch (error) { - logger.error('[ScraperSettings] Failed to bulk toggle:', error); - openAlert('Error', 'Failed to update scrapers'); + logger.error('[PluginSettings] Failed to bulk toggle:', error); + openAlert('Error', 'Failed to update plugins'); } finally { setIsRefreshing(false); } @@ -1014,7 +1014,7 @@ const PluginsScreen: React.FC = () => { // Switch to the new repository and refresh it await pluginService.setCurrentRepository(repoId); await loadRepositories(); - await loadScrapers(); + await loadPlugins(); setNewRepositoryUrl(''); setShowAddRepositoryModal(false); @@ -1032,10 +1032,10 @@ const PluginsScreen: React.FC = () => { setSwitchingRepository(repoId); await pluginService.setCurrentRepository(repoId); await loadRepositories(); - await loadScrapers(); + await loadPlugins(); openAlert('Success', 'Repository switched successfully'); } catch (error) { - logger.error('[ScraperSettings] Failed to switch repository:', error); + logger.error('[PluginSettings] Failed to switch repository:', error); openAlert('Error', 'Failed to switch repository'); } finally { setSwitchingRepository(null); @@ -1051,8 +1051,8 @@ const PluginsScreen: React.FC = () => { const alertTitle = isLastRepository ? 'Remove Last Repository' : 'Remove Repository'; const alertMessage = isLastRepository - ? `Are you sure you want to remove "${repo.name}"? This is your only repository, so you'll have no scrapers available until you add a new repository.` - : `Are you sure you want to remove "${repo.name}"? This will also remove all scrapers from this repository.`; + ? `Are you sure you want to remove "${repo.name}"? This is your only repository, so you'll have no plugins available until you add a new repository.` + : `Are you sure you want to remove "${repo.name}"? This will also remove all plugins from this repository.`; openAlert( alertTitle, @@ -1065,13 +1065,13 @@ const PluginsScreen: React.FC = () => { try { await pluginService.removeRepository(repoId); await loadRepositories(); - await loadScrapers(); + await loadPlugins(); const successMessage = isLastRepository ? 'Repository removed successfully. You can add a new repository using the "Add Repository" button.' : 'Repository removed successfully'; openAlert('Success', successMessage); } catch (error) { - logger.error('[ScraperSettings] Failed to remove repository:', error); + logger.error('[PluginSettings] Failed to remove repository:', error); openAlert('Error', error instanceof Error ? error.message : 'Failed to remove repository'); } }, @@ -1081,16 +1081,16 @@ const PluginsScreen: React.FC = () => { }; useEffect(() => { - loadScrapers(); + loadPlugins(); loadRepositories(); }, []); - const loadScrapers = async () => { + const loadPlugins = async () => { try { const scrapers = await pluginService.getAvailableScrapers(); - setInstalledScrapers(scrapers); + setInstalledPlugins(scrapers); // Detect ShowBox scraper dynamically and preload settings const sb = scrapers.find(s => { const id = (s.id || '').toLowerCase(); @@ -1111,7 +1111,7 @@ const PluginsScreen: React.FC = () => { setShowboxTokenVisible(false); } } catch (error) { - logger.error('[ScraperSettings] Failed to load scrapers:', error); + logger.error('[PluginSettings] Failed to load plugins:', error); } }; @@ -1132,7 +1132,7 @@ const PluginsScreen: React.FC = () => { setRepositoryUrl(currentRepo.url); } } catch (error) { - logger.error('[ScraperSettings] Failed to load repositories:', error); + logger.error('[PluginSettings] Failed to load repositories:', error); } }; @@ -1144,7 +1144,7 @@ const PluginsScreen: React.FC = () => { setRepositoryUrl(repoUrl); } } catch (error) { - logger.error('[ScraperSettings] Failed to check repository:', error); + logger.error('[PluginSettings] Failed to check repository:', error); } }; @@ -1171,7 +1171,7 @@ const PluginsScreen: React.FC = () => { setHasRepository(true); openAlert('Success', 'Repository URL saved successfully'); } catch (error) { - logger.error('[ScraperSettings] Failed to save repository:', error); + logger.error('[PluginSettings] Failed to save repository:', error); openAlert('Error', 'Failed to save repository URL'); } finally { setIsLoading(false); @@ -1191,8 +1191,8 @@ const PluginsScreen: React.FC = () => { // Force a complete hard refresh by clearing any cached data first await pluginService.refreshRepository(); - // Load fresh scrapers from the updated repository - await loadScrapers(); + // Load fresh plugins from the updated repository + await loadPlugins(); openAlert('Success', 'Repository refreshed successfully with latest files'); } catch (error) { @@ -1207,34 +1207,34 @@ const PluginsScreen: React.FC = () => { } }; - const handleToggleScraper = async (scraperId: string, enabled: boolean) => { + const handleTogglePlugin = async (pluginId: string, enabled: boolean) => { try { if (enabled) { - // If enabling a scraper, ensure it's installed first - const installedScrapers = await pluginService.getInstalledScrapers(); - const isInstalled = installedScrapers.some(scraper => scraper.id === scraperId); + // If enabling a plugin, ensure it's installed first + const installedPluginsList = await pluginService.getInstalledScrapers(); + const isInstalled = installedPluginsList.some(plugin => plugin.id === pluginId); if (!isInstalled) { - // Need to install the scraper first + // Need to install the plugin first setIsRefreshing(true); await pluginService.refreshRepository(); setIsRefreshing(false); } } - await pluginService.setScraperEnabled(scraperId, enabled); - await loadScrapers(); + await pluginService.setScraperEnabled(pluginId, enabled); + await loadPlugins(); } catch (error) { - logger.error('[ScraperSettings] Failed to toggle scraper:', error); - openAlert('Error', 'Failed to update scraper status'); + logger.error('[PluginSettings] Failed to toggle plugin:', error); + openAlert('Error', 'Failed to update plugin status'); setIsRefreshing(false); } }; - const handleClearScrapers = () => { + const handleClearPlugins = () => { openAlert( - 'Clear All Scrapers', - 'Are you sure you want to remove all installed scrapers? This action cannot be undone.', + 'Clear All Plugins', + 'Are you sure you want to remove all installed plugins? This action cannot be undone.', [ { label: 'Cancel', onPress: () => { } }, { @@ -1242,11 +1242,11 @@ const PluginsScreen: React.FC = () => { onPress: async () => { try { await pluginService.clearScrapers(); - await loadScrapers(); - openAlert('Success', 'All scrapers have been removed'); + await loadPlugins(); + openAlert('Success', 'All plugins have been removed'); } catch (error) { - logger.error('[ScraperSettings] Failed to clear scrapers:', error); - openAlert('Error', 'Failed to clear scrapers'); + logger.error('[PluginSettings] Failed to clear plugins:', error); + openAlert('Error', 'Failed to clear plugins'); } }, }, @@ -1254,10 +1254,10 @@ const PluginsScreen: React.FC = () => { ); }; - const handleClearCache = () => { + const handleClearPluginCache = () => { openAlert( 'Clear Repository Cache', - 'This will remove the saved repository URL and clear all cached scraper data. You will need to re-enter your repository URL.', + 'This will remove the saved repository URL and clear all cached plugin data. You will need to re-enter your repository URL.', [ { label: 'Cancel', onPress: () => { } }, { @@ -1269,10 +1269,10 @@ const PluginsScreen: React.FC = () => { await updateSetting('scraperRepositoryUrl', ''); setRepositoryUrl(''); setHasRepository(false); - await loadScrapers(); + await loadPlugins(); openAlert('Success', 'Repository cache cleared successfully'); } catch (error) { - logger.error('[ScraperSettings] Failed to clear cache:', error); + logger.error('[PluginSettings] Failed to clear cache:', error); openAlert('Error', 'Failed to clear repository cache'); } }, @@ -1299,7 +1299,7 @@ const PluginsScreen: React.FC = () => { await pluginService.refreshRepository(); // Reload plugins to get the latest state - await loadScrapers(); + await loadPlugins(); logger.log('[PluginsScreen] Plugins enabled and repository refreshed'); } catch (error) { @@ -1394,7 +1394,7 @@ const PluginsScreen: React.FC = () => { // Force hard refresh of repository await pluginService.refreshRepository(); - await loadScrapers(); + await loadPlugins(); logger.log('[PluginsScreen] Pull-to-refresh completed'); } catch (error) { @@ -1441,7 +1441,7 @@ const PluginsScreen: React.FC = () => { styles={styles} > - Manage multiple scraper repositories. Switch between repositories to access different sets of scrapers. + Manage multiple plugin repositories. Switch between repositories to access different sets of plugins. {/* Current Repository */} @@ -1480,7 +1480,7 @@ const PluginsScreen: React.FC = () => { )} {repo.url} - {repo.scraperCount || 0} scrapers • Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'} + {repo.scraperCount || 0} plugins • Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'} @@ -1534,13 +1534,13 @@ const PluginsScreen: React.FC = () => { {/* Available Plugins */} toggleSection('scrapers')} + title={`Available Plugins (${filteredPlugins.length})`} + isExpanded={expandedSections.plugins} + onToggle={() => toggleSection('plugins')} colors={colors} styles={styles} > - {installedScrapers.length > 0 && ( + {installedPlugins.length > 0 && ( <> {/* Search and Filter */} @@ -1549,7 +1549,7 @@ const PluginsScreen: React.FC = () => { style={styles.searchInput} value={searchQuery} onChangeText={setSearchQuery} - placeholder="Search scrapers..." + placeholder="Search plugins..." placeholderTextColor={colors.mediumGray} /> {searchQuery.length > 0 && ( @@ -1581,7 +1581,7 @@ const PluginsScreen: React.FC = () => { {/* Bulk Actions */} - {filteredScrapers.length > 0 && ( + {filteredPlugins.length > 0 && ( { )} - {filteredScrapers.length === 0 ? ( + {filteredPlugins.length === 0 ? ( { style={styles.emptyStateIcon} /> - {searchQuery ? 'No Scrapers Found' : 'No Scrapers Available'} + {searchQuery ? 'No Plugins Found' : 'No Plugins Available'} {searchQuery - ? `No scrapers match "${searchQuery}". Try a different search term.` - : 'Configure a repository above to view available scrapers.' + ? `No plugins match "${searchQuery}". Try a different search term.` + : 'Configure a repository above to view available plugins.' } {searchQuery && ( @@ -1629,74 +1629,74 @@ const PluginsScreen: React.FC = () => { )} ) : ( - - {filteredScrapers.map((scraper) => ( - - - {scraper.logo ? ( - (scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? ( + + {filteredPlugins.map((plugin) => ( + + + {plugin.logo ? ( + (plugin.logo.toLowerCase().endsWith('.svg') || plugin.logo.toLowerCase().includes('.svg?')) ? ( ) : ( ) ) : ( - + )} - + - {scraper.name} - + {plugin.name} + - {scraper.description} + {plugin.description} handleToggleScraper(scraper.id, enabled)} + value={plugin.enabled && settings.enableLocalScrapers} + onValueChange={(enabled) => handleTogglePlugin(plugin.id, enabled)} trackColor={{ false: colors.elevation3, true: colors.primary }} - thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'} - disabled={!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))} + thumbColor={plugin.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'} + disabled={!settings.enableLocalScrapers || plugin.manifestEnabled === false || (plugin.disabledPlatforms && plugin.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))} /> - - + + - v{scraper.version} + v{plugin.version} - + - - {scraper.supportedTypes?.join(', ') || 'Unknown'} + + {plugin.supportedTypes?.join(', ') || 'Unknown'} - {scraper.contentLanguage && scraper.contentLanguage.length > 0 && ( - + {plugin.contentLanguage && plugin.contentLanguage.length > 0 && ( + - - {scraper.contentLanguage.map(lang => lang.toUpperCase()).join(', ')} + + {plugin.contentLanguage.map((lang: string) => lang.toUpperCase()).join(', ')} )} - {scraper.supportsExternalPlayer === false && ( - + {plugin.supportsExternalPlayer === false && ( + - + No external player )} - {/* ShowBox Settings - only visible when ShowBox scraper is available */} - {showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && ( + {/* ShowBox Settings - only visible when ShowBox plugin is available */} + {showboxScraperId && plugin.id === showboxScraperId && settings.enableLocalScrapers && ( ShowBox UI Token @@ -1804,7 +1804,7 @@ const PluginsScreen: React.FC = () => { Sort by Quality First - When enabled, streams are sorted by quality first, then by scraper. When disabled, streams are sorted by scraper first, then by quality. Only available when grouping is enabled. + When enabled, streams are sorted by quality first, then by plugin. When disabled, streams are sorted by plugin first, then by quality. Only available when grouping is enabled. { - Show Scraper Logos + Show Plugin Logos - Display scraper logos next to streaming links on the streams screen. + Display plugin logos next to streaming links on the streams screen. { 2. Add Repository - Add a GitHub raw URL or use the default repository - 3. Refresh Repository - Download available scrapers from the repository + 3. Refresh Repository - Download available plugins from the repository - 4. Enable Scrapers - Turn on the scrapers you want to use for streaming + 4. Enable Plugins - Turn on the plugins you want to use for streaming { ); } - // Mobile Layout (original) + // Mobile Layout - Simplified navigation hub return ( { showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent} > - {renderCategoryContent('account')} - {renderCategoryContent('content')} - {renderCategoryContent('appearance')} - {renderCategoryContent('integrations')} - {renderCategoryContent('ai')} - {renderCategoryContent('playback')} - {renderCategoryContent('backup')} - {renderCategoryContent('updates')} - {renderCategoryContent('about')} - {renderCategoryContent('developer')} - {renderCategoryContent('cache')} + {/* Account */} + + } + renderControl={ChevronRight} + onPress={() => navigation.navigate('TraktSettings')} + isLast + /> + + {/* General Settings */} + + navigation.navigate('ContentDiscoverySettings')} + /> + navigation.navigate('AppearanceSettings')} + /> + navigation.navigate('IntegrationsSettings')} + /> + navigation.navigate('PlaybackSettings')} + isLast + /> + + + {/* Data */} + + navigation.navigate('Backup')} + /> + { + if (Platform.OS === 'android') { + try { await mmkvStorage.removeItem('@update_badge_pending'); } catch { } + setHasUpdateBadge(false); + } + navigation.navigate('Update'); + }} + isLast + /> + + + {/* Cache - only if MDBList is set */} + {mdblistKeySet && ( + + + + )} + + {/* About */} + + navigation.navigate('AboutSettings')} + isLast + /> + + + {/* Developer - only in DEV mode */} + {__DEV__ && ( + + navigation.navigate('DeveloperSettings')} + isLast + /> + + )} + + {/* Downloads Counter */} {displayDownloads !== null && ( @@ -1178,6 +1276,8 @@ const SettingsScreen: React.FC = () => { Made with ❤️ by Tapframe and friends + + diff --git a/src/screens/settings/AboutSettingsScreen.tsx b/src/screens/settings/AboutSettingsScreen.tsx new file mode 100644 index 0000000..f5b0e56 --- /dev/null +++ b/src/screens/settings/AboutSettingsScreen.tsx @@ -0,0 +1,194 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, StyleSheet, ScrollView, StatusBar, TouchableOpacity, Platform, Linking } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import FastImage from '@d11/react-native-fast-image'; +import LottieView from 'lottie-react-native'; +import * as WebBrowser from 'expo-web-browser'; +import * as Sentry from '@sentry/react-native'; +import { useTheme } from '../../contexts/ThemeContext'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import { fetchTotalDownloads } from '../../services/githubReleaseService'; +import { getDisplayedAppVersion } from '../../utils/version'; +import ScreenHeader from '../../components/common/ScreenHeader'; +import { SettingsCard, SettingItem, ChevronRight } from './SettingsComponents'; + +const AboutSettingsScreen: React.FC = () => { + const navigation = useNavigation>(); + const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); + + const [totalDownloads, setTotalDownloads] = useState(null); + const [displayDownloads, setDisplayDownloads] = useState(null); + + useEffect(() => { + const loadDownloads = async () => { + const downloads = await fetchTotalDownloads(); + if (downloads !== null) { + setTotalDownloads(downloads); + setDisplayDownloads(downloads); + } + }; + loadDownloads(); + }, []); + + // Animate counting up when totalDownloads changes + useEffect(() => { + if (totalDownloads === null || displayDownloads === null) return; + if (totalDownloads === displayDownloads) return; + + const start = displayDownloads; + const end = totalDownloads; + const duration = 2000; + const startTime = Date.now(); + + const animate = () => { + const now = Date.now(); + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + 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); + } + }; + + requestAnimationFrame(animate); + }, [totalDownloads]); + + return ( + + + navigation.goBack()} /> + + + + Linking.openURL('https://tapframe.github.io/NuvioStreaming/#privacy-policy')} + renderControl={() => } + /> + Sentry.showFeedbackWidget()} + renderControl={() => } + /> + + } + onPress={() => navigation.navigate('Contributors')} + isLast + /> + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingTop: 16, + }, + downloadsContainer: { + alignItems: 'center', + marginTop: 24, + marginBottom: 16, + }, + downloadsNumber: { + fontSize: 48, + fontWeight: '700', + letterSpacing: -1, + }, + downloadsLabel: { + fontSize: 16, + marginTop: 4, + }, + communityContainer: { + alignItems: 'center', + marginTop: 16, + paddingHorizontal: 16, + }, + supportButton: { + marginBottom: 16, + }, + kofiImage: { + width: 200, + height: 50, + }, + socialRow: { + flexDirection: 'row', + gap: 12, + flexWrap: 'wrap', + justifyContent: 'center', + }, + socialButton: { + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 12, + }, + socialButtonContent: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + socialLogo: { + width: 24, + height: 24, + }, + socialButtonText: { + fontSize: 15, + fontWeight: '600', + }, + monkeyContainer: { + alignItems: 'center', + marginTop: 32, + }, + monkeyAnimation: { + width: 150, + height: 150, + }, + brandLogoContainer: { + alignItems: 'center', + marginTop: 16, + }, + brandLogo: { + width: 120, + height: 40, + }, + footer: { + alignItems: 'center', + marginTop: 24, + }, + footerText: { + fontSize: 14, + }, +}); + +export default AboutSettingsScreen; diff --git a/src/screens/settings/AppearanceSettingsScreen.tsx b/src/screens/settings/AppearanceSettingsScreen.tsx new file mode 100644 index 0000000..37e57d0 --- /dev/null +++ b/src/screens/settings/AppearanceSettingsScreen.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { View, StyleSheet, ScrollView, StatusBar, Platform, Dimensions } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTheme } from '../../contexts/ThemeContext'; +import { useSettings } from '../../hooks/useSettings'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import ScreenHeader from '../../components/common/ScreenHeader'; +import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents'; + +const { width } = Dimensions.get('window'); +const isTablet = width >= 768; + +const AppearanceSettingsScreen: React.FC = () => { + const navigation = useNavigation>(); + const { currentTheme } = useTheme(); + const { settings, updateSetting } = useSettings(); + const insets = useSafeAreaInsets(); + + return ( + + + navigation.goBack()} /> + + + + } + onPress={() => navigation.navigate('ThemeSettings')} + isLast + /> + + + + ( + updateSetting('episodeLayoutStyle', value ? 'horizontal' : 'vertical')} + /> + )} + isLast={isTablet} + /> + {!isTablet && ( + ( + updateSetting('enableStreamsBackdrop', value)} + /> + )} + isLast + /> + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingTop: 16, + }, +}); + +export default AppearanceSettingsScreen; diff --git a/src/screens/settings/ContentDiscoverySettingsScreen.tsx b/src/screens/settings/ContentDiscoverySettingsScreen.tsx new file mode 100644 index 0000000..09ee996 --- /dev/null +++ b/src/screens/settings/ContentDiscoverySettingsScreen.tsx @@ -0,0 +1,153 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { View, StyleSheet, ScrollView, StatusBar, Platform } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTheme } from '../../contexts/ThemeContext'; +import { useSettings } from '../../hooks/useSettings'; +import { stremioService } from '../../services/stremioService'; +import { mmkvStorage } from '../../services/mmkvStorage'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import ScreenHeader from '../../components/common/ScreenHeader'; +import PluginIcon from '../../components/icons/PluginIcon'; +import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents'; + +const ContentDiscoverySettingsScreen: React.FC = () => { + const navigation = useNavigation>(); + const { currentTheme } = useTheme(); + const { settings, updateSetting } = useSettings(); + const insets = useSafeAreaInsets(); + + const [addonCount, setAddonCount] = useState(0); + const [catalogCount, setCatalogCount] = useState(0); + + const loadData = useCallback(async () => { + try { + const addons = await stremioService.getInstalledAddonsAsync(); + setAddonCount(addons.length); + + let totalCatalogs = 0; + addons.forEach(addon => { + if (addon.catalogs && addon.catalogs.length > 0) { + totalCatalogs += addon.catalogs.length; + } + }); + + 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) { + if (__DEV__) console.error('Error loading content data:', error); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + useEffect(() => { + const unsubscribe = navigation.addListener('focus', () => { + loadData(); + }); + return unsubscribe; + }, [navigation, loadData]); + + return ( + + + navigation.goBack()} /> + + + + } + onPress={() => navigation.navigate('Addons')} + /> + } + onPress={() => navigation.navigate('DebridIntegration')} + /> + } + renderControl={() => } + onPress={() => navigation.navigate('ScraperSettings')} + isLast + /> + + + + } + onPress={() => navigation.navigate('CatalogSettings')} + /> + } + onPress={() => navigation.navigate('HomeScreenSettings')} + /> + } + onPress={() => navigation.navigate('ContinueWatchingSettings')} + isLast + /> + + + + ( + updateSetting('showDiscover', value)} + /> + )} + isLast + /> + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingTop: 16, + }, +}); + +export default ContentDiscoverySettingsScreen; diff --git a/src/screens/settings/DeveloperSettingsScreen.tsx b/src/screens/settings/DeveloperSettingsScreen.tsx new file mode 100644 index 0000000..236389e --- /dev/null +++ b/src/screens/settings/DeveloperSettingsScreen.tsx @@ -0,0 +1,158 @@ +import React, { useState } from 'react'; +import { View, StyleSheet, ScrollView, StatusBar } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTheme } from '../../contexts/ThemeContext'; +import { mmkvStorage } from '../../services/mmkvStorage'; +import { campaignService } from '../../services/campaignService'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import ScreenHeader from '../../components/common/ScreenHeader'; +import CustomAlert from '../../components/CustomAlert'; +import { SettingsCard, SettingItem, ChevronRight } from './SettingsComponents'; + +const DeveloperSettingsScreen: React.FC = () => { + const navigation = useNavigation>(); + const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); + + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + const [alertActions, setAlertActions] = useState void }>>([]); + + const openAlert = ( + title: string, + message: string, + actions?: Array<{ label: string; onPress: () => void }> + ) => { + setAlertTitle(title); + setAlertMessage(message); + setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]); + setAlertVisible(true); + }; + + const handleResetOnboarding = async () => { + 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.'); + } + }; + + const handleResetAnnouncement = async () => { + 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.'); + } + }; + + const handleResetCampaigns = async () => { + await campaignService.resetCampaigns(); + openAlert('Success', 'Campaign history reset. Restart app to see posters again.'); + }; + + const handleClearAllData = () => { + 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.'); + } + } + } + ] + ); + }; + + // Only show in development mode + if (!__DEV__) { + return null; + } + + return ( + + + navigation.goBack()} /> + + + + navigation.navigate('Onboarding')} + renderControl={() => } + /> + } + /> + } + /> + } + isLast + /> + + + + + + + + setAlertVisible(false)} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingTop: 16, + }, +}); + +export default DeveloperSettingsScreen; diff --git a/src/screens/settings/IntegrationsSettingsScreen.tsx b/src/screens/settings/IntegrationsSettingsScreen.tsx new file mode 100644 index 0000000..a7c10aa --- /dev/null +++ b/src/screens/settings/IntegrationsSettingsScreen.tsx @@ -0,0 +1,100 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { View, StyleSheet, ScrollView, StatusBar } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTheme } from '../../contexts/ThemeContext'; +import { mmkvStorage } from '../../services/mmkvStorage'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import ScreenHeader from '../../components/common/ScreenHeader'; +import MDBListIcon from '../../components/icons/MDBListIcon'; +import TMDBIcon from '../../components/icons/TMDBIcon'; +import { SettingsCard, SettingItem, ChevronRight } from './SettingsComponents'; + +const IntegrationsSettingsScreen: React.FC = () => { + const navigation = useNavigation>(); + const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); + + const [mdblistKeySet, setMdblistKeySet] = useState(false); + const [openRouterKeySet, setOpenRouterKeySet] = useState(false); + + const loadData = useCallback(async () => { + try { + const mdblistKey = await mmkvStorage.getItem('mdblist_api_key'); + setMdblistKeySet(!!mdblistKey); + + const openRouterKey = await mmkvStorage.getItem('openrouter_api_key'); + setOpenRouterKeySet(!!openRouterKey); + } catch (error) { + if (__DEV__) console.error('Error loading integration data:', error); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + useEffect(() => { + const unsubscribe = navigation.addListener('focus', () => { + loadData(); + }); + return unsubscribe; + }, [navigation, loadData]); + + return ( + + + navigation.goBack()} /> + + + + } + renderControl={() => } + onPress={() => navigation.navigate('MDBListSettings')} + /> + } + renderControl={() => } + onPress={() => navigation.navigate('TMDBSettings')} + isLast + /> + + + + } + onPress={() => navigation.navigate('AISettings')} + isLast + /> + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingTop: 16, + }, +}); + +export default IntegrationsSettingsScreen; diff --git a/src/screens/settings/PlaybackSettingsScreen.tsx b/src/screens/settings/PlaybackSettingsScreen.tsx new file mode 100644 index 0000000..a82b3d5 --- /dev/null +++ b/src/screens/settings/PlaybackSettingsScreen.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { View, StyleSheet, ScrollView, StatusBar, Platform } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTheme } from '../../contexts/ThemeContext'; +import { useSettings } from '../../hooks/useSettings'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import ScreenHeader from '../../components/common/ScreenHeader'; +import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents'; + +const PlaybackSettingsScreen: React.FC = () => { + const navigation = useNavigation>(); + const { currentTheme } = useTheme(); + const { settings, updateSetting } = useSettings(); + const insets = useSafeAreaInsets(); + + return ( + + + navigation.goBack()} /> + + + + } + onPress={() => navigation.navigate('PlayerSettings')} + isLast + /> + + + + ( + updateSetting('showTrailers', value)} + /> + )} + /> + ( + updateSetting('enableDownloads', value)} + /> + )} + isLast + /> + + + + } + onPress={() => navigation.navigate('NotificationSettings')} + isLast + /> + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingTop: 16, + }, +}); + +export default PlaybackSettingsScreen; diff --git a/src/screens/settings/SettingsComponents.tsx b/src/screens/settings/SettingsComponents.tsx new file mode 100644 index 0000000..ad73e2e --- /dev/null +++ b/src/screens/settings/SettingsComponents.tsx @@ -0,0 +1,268 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Switch, Platform, Dimensions } from 'react-native'; +import { Feather } from '@expo/vector-icons'; +import { useTheme } from '../../contexts/ThemeContext'; + +const { width } = Dimensions.get('window'); +const isTablet = width >= 768; + +// Card component with minimalistic style +interface SettingsCardProps { + children: React.ReactNode; + title?: string; + isTablet?: boolean; +} + +export const SettingsCard: React.FC = ({ children, title, isTablet: isTabletProp = false }) => { + const { currentTheme } = useTheme(); + const useTabletStyle = isTabletProp || isTablet; + + 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; +} + +export const SettingItem: React.FC = ({ + title, + description, + icon, + customIcon, + renderControl, + isLast = false, + onPress, + badge, + isTablet: isTabletProp = false +}) => { + const { currentTheme } = useTheme(); + const useTabletStyle = isTabletProp || isTablet; + + return ( + + + {customIcon ? ( + customIcon + ) : ( + + )} + + + + + {title} + + {description && ( + + {description} + + )} + + {badge && ( + + {String(badge)} + + )} + + {renderControl && ( + + {renderControl()} + + )} + + ); +}; + +// Custom Switch component +interface CustomSwitchProps { + value: boolean; + onValueChange: (value: boolean) => void; +} + +export const CustomSwitch: React.FC = ({ value, onValueChange }) => { + const { currentTheme } = useTheme(); + + return ( + + ); +}; + +// Chevron Right component +export const ChevronRight: React.FC<{ isTablet?: boolean }> = ({ isTablet: isTabletProp = false }) => { + const { currentTheme } = useTheme(); + const useTabletStyle = isTabletProp || isTablet; + + return ( + + ); +}; + +const styles = StyleSheet.create({ + cardContainer: { + marginBottom: 20, + paddingHorizontal: 16, + }, + tabletCardContainer: { + marginBottom: 28, + paddingHorizontal: 0, + }, + cardTitle: { + fontSize: 13, + fontWeight: '600', + marginBottom: 10, + marginLeft: 4, + letterSpacing: 0.8, + }, + tabletCardTitle: { + fontSize: 14, + marginBottom: 12, + }, + card: { + borderRadius: 16, + overflow: 'hidden', + }, + tabletCard: { + borderRadius: 20, + }, + settingItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 16, + minHeight: 60, + }, + tabletSettingItem: { + paddingVertical: 16, + paddingHorizontal: 20, + minHeight: 68, + }, + settingItemBorder: { + borderBottomWidth: StyleSheet.hairlineWidth, + }, + settingIconContainer: { + width: 36, + height: 36, + borderRadius: 10, + justifyContent: 'center', + alignItems: 'center', + marginRight: 14, + }, + tabletSettingIconContainer: { + width: 42, + height: 42, + borderRadius: 12, + marginRight: 16, + }, + settingContent: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + settingTextContainer: { + flex: 1, + }, + settingTitle: { + fontSize: 16, + fontWeight: '500', + marginBottom: 2, + }, + tabletSettingTitle: { + fontSize: 17, + }, + settingDescription: { + fontSize: 13, + marginTop: 2, + }, + tabletSettingDescription: { + fontSize: 14, + }, + settingControl: { + marginLeft: 12, + }, + badge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 10, + marginLeft: 8, + }, + badgeText: { + fontSize: 12, + fontWeight: '600', + }, +}); + +export default SettingsCard; diff --git a/src/screens/settings/index.ts b/src/screens/settings/index.ts new file mode 100644 index 0000000..f914786 --- /dev/null +++ b/src/screens/settings/index.ts @@ -0,0 +1,7 @@ +export { default as ContentDiscoverySettingsScreen } from './ContentDiscoverySettingsScreen'; +export { default as AppearanceSettingsScreen } from './AppearanceSettingsScreen'; +export { default as IntegrationsSettingsScreen } from './IntegrationsSettingsScreen'; +export { default as PlaybackSettingsScreen } from './PlaybackSettingsScreen'; +export { default as AboutSettingsScreen } from './AboutSettingsScreen'; +export { default as DeveloperSettingsScreen } from './DeveloperSettingsScreen'; +export { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';