import React, { useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { View, Text, TextInput, TouchableOpacity, ScrollView, ActivityIndicator, Alert, KeyboardAvoidingView, Platform, Modal, FlatList } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useNavigation } from '@react-navigation/native'; import { useTheme } from '../../contexts/ThemeContext'; import { pluginService } from '../../services/pluginService'; import axios from 'axios'; import { getPluginTesterStyles, useIsLargeScreen } from './styles'; import { Header, MainTabBar } from './components'; import type { RootStackNavigationProp } from '../../navigation/AppNavigator'; interface IndividualTesterProps { onSwitchTab: (tab: 'individual' | 'repo') => void; } export const IndividualTester = ({ onSwitchTab }: IndividualTesterProps) => { const navigation = useNavigation(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); const { currentTheme } = useTheme(); const isLargeScreen = useIsLargeScreen(); const styles = getPluginTesterStyles(currentTheme, isLargeScreen); // State const [code, setCode] = useState(''); const [url, setUrl] = useState(''); const [tmdbId, setTmdbId] = useState('550'); // Fight Club default const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie'); const [season, setSeason] = useState('1'); const [episode, setEpisode] = useState('1'); const [logs, setLogs] = useState([]); const [streams, setStreams] = useState([]); const [isRunning, setIsRunning] = useState(false); const [activeTab, setActiveTab] = useState<'code' | 'logs' | 'results'>('code'); const [rightPanelTab, setRightPanelTab] = useState<'logs' | 'results'>('logs'); const [isEditorFocused, setIsEditorFocused] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const [matches, setMatches] = useState>([]); const focusedEditorScrollRef = useRef(null); const CODE_LINE_HEIGHT = 18; const CODE_PADDING_V = 12; const MIN_EDITOR_HEIGHT = 240; const logsScrollRef = useRef(null); const codeInputRefFocused = useRef(null); // Calculate matches when code or search query changes React.useEffect(() => { if (!searchQuery.trim()) { setMatches([]); setCurrentMatchIndex(0); return; } const query = searchQuery.toLowerCase(); const codeToSearch = code.toLowerCase(); const foundMatches: Array<{ start: number; end: number }> = []; let index = 0; while ((index = codeToSearch.indexOf(query, index)) !== -1) { foundMatches.push({ start: index, end: index + query.length }); index += 1; } setMatches(foundMatches); setCurrentMatchIndex(0); }, [searchQuery, code]); const jumpToMatch = (matchIndex: number) => { if (!isEditorFocused) return; if (!searchQuery.trim()) return; if (matches.length === 0) return; const safeIndex = Math.min(Math.max(matchIndex, 0), matches.length - 1); const match = matches[safeIndex]; requestAnimationFrame(() => { // Scroll the ScrollView so the highlighted match becomes visible. const before = code.slice(0, match.start); const lineIndex = before.split('\n').length - 1; const y = Math.max(0, (lineIndex - 2) * CODE_LINE_HEIGHT); focusedEditorScrollRef.current?.scrollTo({ y, animated: true }); }); }; const getEditorHeight = () => { const lineCount = Math.max(1, code.split('\n').length); const contentHeight = lineCount * CODE_LINE_HEIGHT + CODE_PADDING_V * 2; return Math.max(MIN_EDITOR_HEIGHT, contentHeight); }; const renderHighlightedCode = () => { if (!searchQuery.trim() || matches.length === 0) { return {code || ' '}; } const safeIndex = Math.min(Math.max(currentMatchIndex, 0), matches.length - 1); const match = matches[safeIndex]; const start = Math.max(0, Math.min(match.start, code.length)); const end = Math.max(start, Math.min(match.end, code.length)); return ( {code.slice(0, start)} {code.slice(start, end) || ' '} {code.slice(end) || ' '} ); }; const fetchFromUrl = async () => { if (!url) { Alert.alert(t('plugin_tester.common.error'), t('plugin_tester.individual.enter_url_error')); return; } try { const response = await axios.get(url, { headers: { 'Cache-Control': 'no-cache' } }); const content = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2); setCode(content); Alert.alert(t('plugin_tester.common.success'), t('plugin_tester.individual.code_loaded')); } catch (error: any) { Alert.alert(t('plugin_tester.common.error'), t('plugin_tester.individual.fetch_error', { message: error.message })); } }; const runTest = async () => { if (!code.trim()) { Alert.alert(t('plugin_tester.common.error'), t('plugin_tester.individual.no_code_error')); return; } setIsRunning(true); setLogs([]); setStreams([]); if (isLargeScreen) { setRightPanelTab('logs'); } else { setActiveTab('logs'); } try { const params = { tmdbId, mediaType, season: mediaType === 'tv' ? parseInt(season) || 1 : undefined, episode: mediaType === 'tv' ? parseInt(episode) || 1 : undefined, }; const result = await pluginService.testPlugin(code, params, { onLog: (line) => { setLogs(prev => [...prev, line]); }, }); // Logs were already appended in real-time via onLog setStreams(result.streams); if (result.streams.length > 0) { if (isLargeScreen) { setRightPanelTab('results'); } else { setActiveTab('results'); } } } catch (error: any) { setLogs(prev => [...prev, `[FATAL] ${error.message}`]); } finally { setIsRunning(false); } }; const renderCodeTab = () => { // On large screens, show code + logs/results side by side if (isLargeScreen) { return ( {t('plugin_tester.individual.load_from_url')} {t('plugin_tester.individual.load_from_url_desc')} {t('plugin_tester.individual.plugin_code')} setIsEditorFocused(true)} accessibilityLabel={t('plugin_tester.individual.focus_editor')} > {/* Sticky footer on large screens (match mobile behavior) */} {t('plugin_tester.individual.test_parameters')} setMediaType('movie')} > {t('plugin_tester.common.movie')} setMediaType('tv')} > {t('plugin_tester.common.tv')} {t('plugin_tester.common.tmdb_id')} {mediaType === 'tv' && ( <> {t('plugin_tester.common.season')} {t('plugin_tester.common.episode')} )} {isRunning ? ( ) : ( )} {isRunning ? t('plugin_tester.common.running') : t('plugin_tester.common.run_test')} {/* Right side: Logs and Results */} setRightPanelTab('logs')} > {t('plugin_tester.tabs.logs')} setRightPanelTab('results')} > {t('plugin_tester.tabs.results')} ({streams.length}) {rightPanelTab === 'logs' ? ( (logsScrollRef.current = r)} style={[styles.logContainer, { flex: 1, minHeight: 400 }]} contentContainerStyle={{ paddingBottom: 20 }} onContentSizeChange={() => { logsScrollRef.current?.scrollToEnd({ animated: true }); }} > {logs.length === 0 ? ( {t('plugin_tester.individual.no_logs')} ) : ( logs.map((log, i) => { let style = styles.logItem; if (log.includes('[ERROR]') || log.includes('[FATAL')) style = { ...style, ...styles.logError }; else if (log.includes('[WARN]')) style = { ...style, ...styles.logWarn }; else if (log.includes('[INFO]')) style = { ...style, ...styles.logInfo }; else if (log.includes('[DEBUG]')) style = { ...style, ...styles.logDebug }; return ( {log} ); }) )} ) : ( renderResultsTab() )} ); } // Original mobile layout return ( {t('plugin_tester.individual.load_from_url')} {t('plugin_tester.individual.load_from_url_desc')} {t('plugin_tester.individual.plugin_code')} setIsEditorFocused(true)} accessibilityLabel={t('plugin_tester.individual.focus_editor')} > {t('plugin_tester.individual.test_parameters')} setMediaType('movie')} > Movie setMediaType('tv')} > {t('plugin_tester.common.tv')} {t('plugin_tester.common.tmdb_id')} {mediaType === 'tv' && ( <> {t('plugin_tester.common.season')} {t('plugin_tester.common.episode')} )} {isRunning ? ( ) : ( )} {isRunning ? t('plugin_tester.common.running') : t('plugin_tester.common.run_test')} ); }; const renderLogsTab = () => ( (logsScrollRef.current = r)} style={styles.content} onContentSizeChange={() => { if (activeTab === 'logs') { logsScrollRef.current?.scrollToEnd({ animated: true }); } }} > {logs.length === 0 ? ( {t('plugin_tester.individual.no_logs')} ) : ( {logs.map((log, i) => { let style = styles.logItem; if (log.includes('[ERROR]') || log.includes('[FATAL')) style = { ...style, ...styles.logError }; else if (log.includes('[WARN]')) style = { ...style, ...styles.logWarn }; else if (log.includes('[INFO]')) style = { ...style, ...styles.logInfo }; else if (log.includes('[DEBUG]')) style = { ...style, ...styles.logDebug }; return ( {log} ); })} )} ); const playStream = (stream: any) => { if (!stream.url) { Alert.alert(t('plugin_tester.common.error'), t('plugin_tester.individual.no_url_stream_error')); return; } const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; const streamName = stream.name || stream.title || 'Test Stream'; const quality = (stream.title?.match(/(\d+)p/) || stream.name?.match(/(\d+)p/) || [])[1] || undefined; // Build headers from stream object if present const headers = stream.headers || stream.behaviorHints?.proxyHeaders?.request || {}; navigation.navigate(playerRoute as any, { uri: stream.url, title: `Plugin Tester - ${streamName}`, streamName, quality, headers, // Pass any additional stream properties videoType: stream.videoType || undefined, } as any); }; const renderResultsTab = () => { if (streams.length === 0) { return ( {t('plugin_tester.individual.no_streams')} ); } return ( item.url + index} ListHeaderComponent={ {streams.length === 1 ? t('plugin_tester.individual.streams_found', { count: streams.length }) : t('plugin_tester.individual.streams_found_plural', { count: streams.length })} {t('plugin_tester.individual.tap_play_hint')} } renderItem={({ item: stream }) => ( playStream(stream)} activeOpacity={0.7} > {stream.name || stream.title || t('plugin_tester.individual.unnamed_stream')} {t('plugin_tester.individual.quality', { quality: stream.quality || 'Unknown' })} {stream.description ? {t('plugin_tester.individual.size', { size: stream.description })} : null} {t('plugin_tester.individual.url_label', { url: stream.url })} {stream.headers && Object.keys(stream.headers).length > 0 && ( {t('plugin_tester.individual.headers_info', { count: Object.keys(stream.headers).length })} )} playStream(stream)} > {t('plugin_tester.common.play')} {(() => { try { return JSON.stringify(stream, null, 2); } catch { return String(stream); } })()} )} /> ); }; const renderFocusedEditor = () => ( jumpToMatch(currentMatchIndex)} placeholder={t('plugin_tester.individual.find_placeholder')} placeholderTextColor={currentTheme.colors.mediumEmphasis} autoCapitalize="none" autoCorrect={false} /> {matches.length === 0 ? '–' : `${currentMatchIndex + 1}/${matches.length}`} 0 && styles.findButtonActive]} onPress={() => { if (matches.length === 0) return; const nextIndex = currentMatchIndex === 0 ? matches.length - 1 : currentMatchIndex - 1; setCurrentMatchIndex(nextIndex); jumpToMatch(nextIndex); }} disabled={matches.length === 0} > 0 ? currentTheme.colors.primary : currentTheme.colors.mediumEmphasis} /> 0 && styles.findButtonActive]} onPress={() => { if (matches.length === 0) return; const nextIndex = currentMatchIndex === matches.length - 1 ? 0 : currentMatchIndex + 1; setCurrentMatchIndex(nextIndex); jumpToMatch(nextIndex); }} disabled={matches.length === 0} > 0 ? currentTheme.colors.primary : currentTheme.colors.mediumEmphasis} /> { setSearchQuery(''); setMatches([]); setCurrentMatchIndex(0); }} > (focusedEditorScrollRef.current = r)} style={styles.content} contentContainerStyle={{ paddingBottom: 24 }} keyboardShouldPersistTaps="handled" > {renderHighlightedCode()} ); return ( {isEditorFocused ? ( setIsEditorFocused(false)} >
setIsEditorFocused(false)} rightElement={ setIsEditorFocused(false)}> {t('plugin_tester.common.done')} } /> {renderFocusedEditor()} ) : ( <>
navigation.goBack()} /> {!isLargeScreen && ( setActiveTab('code')} > {t('plugin_tester.tabs.code')} setActiveTab('logs')} > {t('plugin_tester.tabs.logs')} setActiveTab('results')} > {t('plugin_tester.tabs.results')} )} {activeTab === 'code' && renderCodeTab()} {activeTab === 'logs' && renderLogsTab()} {activeTab === 'results' && renderResultsTab()} )} ); };