diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index a69e8a3..b45e059 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -63,6 +63,7 @@ import AccountManageScreen from '../screens/AccountManageScreen'; import { useAccount } from '../contexts/AccountContext'; import { LoadingProvider, useLoading } from '../contexts/LoadingContext'; import PluginsScreen from '../screens/PluginsScreen'; +import PluginTesterScreen from '../screens/PluginTesterScreen'; import CastMoviesScreen from '../screens/CastMoviesScreen'; import UpdateScreen from '../screens/UpdateScreen'; import AISettingsScreen from '../screens/AISettingsScreen'; @@ -127,6 +128,7 @@ export type RootStackParamList = { duration?: number; addonId?: string; }; + PluginTester: undefined; PlayerIOS: { uri: string; title?: string; @@ -1628,6 +1630,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> + { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { currentTheme } = useTheme(); + + // 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 [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) || ' '} + + ); + }; + + // Styles + const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: currentTheme.colors.darkBackground, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: currentTheme.colors.elevation3, + }, + headerTitle: { + fontSize: 20, + fontWeight: 'bold', + color: currentTheme.colors.text, + }, + headerSubtitle: { + fontSize: 12, + color: currentTheme.colors.mediumEmphasis, + marginTop: 2, + }, + tabBar: { + flexDirection: 'row', + backgroundColor: currentTheme.colors.elevation1, + padding: 6, + marginHorizontal: 16, + marginTop: 12, + borderRadius: 12, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + }, + tab: { + flex: 1, + paddingVertical: 10, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 10, + flexDirection: 'row', + gap: 6, + }, + activeTab: { + backgroundColor: currentTheme.colors.primary + '20', + }, + tabText: { + fontSize: 14, + fontWeight: '600', + color: currentTheme.colors.mediumEmphasis, + }, + activeTabText: { + color: currentTheme.colors.primary, + }, + tabBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + backgroundColor: currentTheme.colors.elevation3, + }, + tabBadgeText: { + fontSize: 11, + fontWeight: '700', + color: currentTheme.colors.highEmphasis, + }, + content: { + flex: 1, + paddingHorizontal: 16, + paddingTop: 12, + }, + card: { + backgroundColor: currentTheme.colors.elevation2, + borderRadius: 12, + padding: 14, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + marginBottom: 12, + }, + cardTitleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 10, + }, + cardTitle: { + fontSize: 15, + fontWeight: '700', + color: currentTheme.colors.white, + letterSpacing: 0.2, + }, + helperText: { + fontSize: 12, + color: currentTheme.colors.mediumEmphasis, + lineHeight: 16, + }, + input: { + backgroundColor: currentTheme.colors.elevation1, + borderRadius: 8, + paddingVertical: 12, + paddingHorizontal: 12, + color: currentTheme.colors.white, + fontSize: 14, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + minHeight: 48, + }, + codeInput: { + backgroundColor: currentTheme.colors.elevation1, + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 12, + color: currentTheme.colors.highEmphasis, + fontSize: 13, + lineHeight: 18, + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + minHeight: 240, + textAlignVertical: 'top', + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + }, + focusedEditorShell: { + borderRadius: 12, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + backgroundColor: currentTheme.colors.elevation1, + overflow: 'hidden', + }, + highlightLayer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + paddingVertical: 12, + paddingHorizontal: 12, + }, + highlightText: { + color: currentTheme.colors.highEmphasis, + fontSize: 13, + lineHeight: 18, + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + }, + highlightActive: { + backgroundColor: '#FFD400', + color: currentTheme.colors.black, + }, + codeInputTransparent: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + paddingVertical: 12, + paddingHorizontal: 12, + color: 'transparent', + backgroundColor: 'transparent', + fontSize: 13, + lineHeight: 18, + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + }, + row: { + flexDirection: 'row', + gap: 12, + }, + fieldLabel: { + fontSize: 12, + fontWeight: '600', + color: currentTheme.colors.mediumEmphasis, + marginBottom: 6, + }, + segment: { + flexDirection: 'row', + backgroundColor: currentTheme.colors.elevation1, + borderRadius: 12, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + overflow: 'hidden', + }, + segmentItem: { + flex: 1, + paddingVertical: 10, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: 8, + }, + segmentItemActive: { + backgroundColor: currentTheme.colors.primary + '20', + }, + segmentText: { + fontSize: 14, + fontWeight: '700', + color: currentTheme.colors.highEmphasis, + }, + segmentTextActive: { + color: currentTheme.colors.primary, + }, + button: { + backgroundColor: currentTheme.colors.primary, + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 16, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: 8, + }, + buttonText: { + color: currentTheme.colors.white, + fontWeight: '700', + fontSize: 15, + }, + secondaryButton: { + backgroundColor: currentTheme.colors.elevation1, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + }, + secondaryButtonText: { + color: currentTheme.colors.highEmphasis, + }, + stickyFooter: { + paddingHorizontal: 16, + paddingTop: 10, + borderTopWidth: 1, + borderTopColor: currentTheme.colors.elevation3, + backgroundColor: currentTheme.colors.darkBackground, + }, + footerCard: { + backgroundColor: currentTheme.colors.elevation2, + borderRadius: 12, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + padding: 12, + marginBottom: 10, + }, + footerTitleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 10, + }, + footerTitle: { + fontSize: 13, + fontWeight: '700', + color: currentTheme.colors.white, + }, + headerRightButton: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 10, + backgroundColor: currentTheme.colors.elevation2, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + }, + headerRightButtonText: { + fontSize: 13, + fontWeight: '700', + color: currentTheme.colors.highEmphasis, + }, + codeInputFocused: { + flex: 1, + minHeight: 0, + }, + cardActionsRow: { + flexDirection: 'row', + alignItems: 'center', + }, + cardActionButton: { + padding: 6, + marginRight: 6, + borderRadius: 10, + backgroundColor: currentTheme.colors.elevation1, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + }, + findToolbar: { + backgroundColor: currentTheme.colors.elevation2, + borderBottomWidth: 1, + borderBottomColor: currentTheme.colors.elevation3, + paddingHorizontal: 12, + paddingVertical: 10, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + findInput: { + flex: 1, + backgroundColor: currentTheme.colors.elevation1, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 8, + color: currentTheme.colors.white, + fontSize: 13, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + }, + findCounter: { + fontSize: 12, + color: currentTheme.colors.mediumEmphasis, + minWidth: 40, + textAlign: 'right', + fontWeight: '600', + }, + findButton: { + padding: 8, + borderRadius: 8, + backgroundColor: currentTheme.colors.elevation1, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + }, + findButtonActive: { + backgroundColor: currentTheme.colors.primary + '20', + borderColor: currentTheme.colors.primary, + }, + logItem: { + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + fontSize: 12, + marginBottom: 4, + color: currentTheme.colors.mediumEmphasis, + }, + logError: { + color: currentTheme.colors.error, + }, + logWarn: { + color: currentTheme.colors.warning, + }, + logInfo: { + color: currentTheme.colors.info, + }, + logDebug: { + color: currentTheme.colors.lightGray, + }, + logContainer: { + backgroundColor: currentTheme.colors.elevation2, + borderRadius: 12, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + padding: 12, + }, + resultItem: { + backgroundColor: currentTheme.colors.elevation2, + borderRadius: 12, + padding: 12, + marginBottom: 8, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + }, + resultTitle: { + fontSize: 16, + fontWeight: '600', + color: currentTheme.colors.white, + marginBottom: 4, + }, + resultMeta: { + fontSize: 12, + color: currentTheme.colors.mediumGray, + marginBottom: 2, + }, + resultUrl: { + fontSize: 12, + color: currentTheme.colors.mediumEmphasis, + marginBottom: 2, + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + padding: 32, + }, + emptyText: { + color: currentTheme.colors.mediumGray, + marginTop: 8, + }, + }); + + const fetchFromUrl = async () => { + if (!url) { + Alert.alert('Error', 'Please enter a URL'); + 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('Success', 'Code loaded from URL'); + } catch (error: any) { + Alert.alert('Error', `Failed to fetch: ${error.message}`); + } + }; + + const runTest = async () => { + if (!code.trim()) { + Alert.alert('Error', 'No code to run'); + return; + } + + setIsRunning(true); + setLogs([]); + setStreams([]); + 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) { + setActiveTab('results'); + } + } catch (error: any) { + setLogs(prev => [...prev, `[FATAL] ${error.message}`]); + } finally { + setIsRunning(false); + } + }; + + const renderCodeTab = () => ( + + + + + Load from URL + + + + Paste a raw GitHub URL or local IP and tap download. + + + + + + + + + + + + Plugin Code + + setIsEditorFocused(true)} + accessibilityLabel="Focus code editor" + > + + + + + + + + + + + + + Test Parameters + + + + + setMediaType('movie')} + > + + Movie + + setMediaType('tv')} + > + + TV + + + + + + TMDB ID + + + + {mediaType === 'tv' && ( + <> + + Season + + + + Episode + + + + )} + + + + {isRunning ? ( + + ) : ( + + )} + {isRunning ? 'Running…' : 'Run Test'} + + + + ); + + const renderFocusedEditor = () => ( + + + jumpToMatch(currentMatchIndex)} + placeholder="Find in code…" + 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()} + + + + + + + ); + + const renderLogsTab = () => ( + (logsScrollRef.current = r)} + style={styles.content} + onContentSizeChange={() => { + if (activeTab === 'logs') { + logsScrollRef.current?.scrollToEnd({ animated: true }); + } + }} + > + {logs.length === 0 ? ( + + + No logs yet. Run a test to see output. + + ) : ( + + {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 renderResultsTab = () => ( + + {streams.length === 0 ? ( + + + No streams found yet. + + ) : ( + streams.map((stream, i) => ( + + {stream.title || stream.name} + Quality: {stream.quality || 'Unknown'} + Size: {stream.description || 'Unknown'} + URL: {stream.url} + + + {(() => { + try { + return JSON.stringify(stream, null, 2); + } catch { + return String(stream); + } + })()} + + + )) + )} + + ); + + return ( + + + (isEditorFocused ? setIsEditorFocused(false) : navigation.goBack())}> + + + + {isEditorFocused ? 'Edit Code' : 'Plugin Tester'} + {!isEditorFocused && ( + Run scrapers and inspect logs in real-time + )} + + {isEditorFocused ? ( + { + setIsEditorFocused(false); + setSearchQuery(''); + setMatches([]); + setCurrentMatchIndex(0); + }}> + Done + + ) : ( + + )} + + + {isEditorFocused ? ( + renderFocusedEditor() + ) : ( + <> + + setActiveTab('code')} + > + + Code + + setActiveTab('logs')} + > + + Logs + + {logs.length} + + + setActiveTab('results')} + > + + Results + + {streams.length} + + + + + {activeTab === 'code' && renderCodeTab()} + {activeTab === 'logs' && renderLogsTab()} + {activeTab === 'results' && renderResultsTab()} + + )} + + ); +}; + +export default PluginTesterScreen; diff --git a/src/screens/PluginsScreen.tsx b/src/screens/PluginsScreen.tsx index 9d32e26..fcb60a6 100644 --- a/src/screens/PluginsScreen.tsx +++ b/src/screens/PluginsScreen.tsx @@ -1602,6 +1602,7 @@ const PluginsScreen: React.FC = () => { > {t('plugins.add_new_repo')} + {/* Available Plugins */} diff --git a/src/screens/settings/ContentDiscoverySettingsScreen.tsx b/src/screens/settings/ContentDiscoverySettingsScreen.tsx index a0279ce..4ecaf01 100644 --- a/src/screens/settings/ContentDiscoverySettingsScreen.tsx +++ b/src/screens/settings/ContentDiscoverySettingsScreen.tsx @@ -80,7 +80,7 @@ export const ContentDiscoverySettingsContent: React.FC - {hasVisibleItems(['addons', 'debrid', 'plugins']) && ( + {hasVisibleItems(['addons', 'debrid']) && ( {isItemVisible('addons') && ( } onPress={() => navigation.navigate('Addons')} + isLast={!isItemVisible('debrid')} isTablet={isTablet} /> )} @@ -99,16 +100,6 @@ export const ContentDiscoverySettingsContent: React.FC } onPress={() => navigation.navigate('DebridIntegration')} - isTablet={isTablet} - /> - )} - {isItemVisible('plugins') && ( - } - renderControl={() => } - onPress={() => navigation.navigate('ScraperSettings')} isLast isTablet={isTablet} /> diff --git a/src/screens/settings/DeveloperSettingsScreen.tsx b/src/screens/settings/DeveloperSettingsScreen.tsx index 8d1cef2..5ce51fe 100644 --- a/src/screens/settings/DeveloperSettingsScreen.tsx +++ b/src/screens/settings/DeveloperSettingsScreen.tsx @@ -94,6 +94,13 @@ const DeveloperSettingsScreen: React.FC = () => { contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 24 }]} > + navigation.navigate('PluginTester')} + renderControl={() => } + /> { + private async executePlugin(code: string, params: any, consoleOverride?: any): Promise { try { // Get URL validation setting from storage const settingsData = await mmkvStorage.getItem('app_settings'); @@ -1326,7 +1326,7 @@ class LocalScraperService { CryptoJS, cheerio, logger, - console, // Expose console to plugins for debugging + consoleOverride || console, // Expose console (or override) to plugins for debugging params, MOVIEBOX_PRIMARY_KEY, MOVIEBOX_TMDB_API_KEY, @@ -1542,6 +1542,73 @@ class LocalScraperService { } } + // Test a plugin independently with log capturing. + // If onLog is provided, each formatted log line is emitted as it happens. + async testPlugin( + code: string, + params: { tmdbId: string; mediaType: string; season?: number; episode?: number }, + options?: { onLog?: (line: string) => void } + ): Promise<{ streams: Stream[]; logs: string[] }> { + const logs: string[] = []; + const emit = (line: string) => { + logs.push(line); + options?.onLog?.(line); + }; + + // Create a console proxy to capture logs + const consoleProxy = { + log: (...args: any[]) => { + const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); + emit(`[LOG] ${msg}`); + console.log('[PluginTest]', msg); + }, + error: (...args: any[]) => { + const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); + emit(`[ERROR] ${msg}`); + console.error('[PluginTest]', msg); + }, + warn: (...args: any[]) => { + const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); + emit(`[WARN] ${msg}`); + console.warn('[PluginTest]', msg); + }, + info: (...args: any[]) => { + const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); + emit(`[INFO] ${msg}`); + console.info('[PluginTest]', msg); + }, + debug: (...args: any[]) => { + const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); + emit(`[DEBUG] ${msg}`); + console.debug('[PluginTest]', msg); + } + }; + + try { + const results = await this.executePlugin(code, params, consoleProxy); + + // Convert results using a dummy scraper info since we don't have one for ad-hoc tests + const dummyScraperInfo: ScraperInfo = { + id: 'test-plugin', + name: 'Test Plugin', + version: '1.0.0', + description: 'Test', + filename: 'test.js', + supportedTypes: ['movie', 'tv'], + enabled: true + }; + + const streams = this.convertToStreams(results, dummyScraperInfo); + return { streams, logs }; + } catch (error: any) { + emit(`[FATAL ERROR] ${error.message || String(error)}`); + if (error.stack) { + emit(`[STACK] ${error.stack}`); + } + return { streams: [], logs }; + } + } + } export const localScraperService = LocalScraperService.getInstance();