todo:fix moviesmod

This commit is contained in:
tapframe 2025-07-08 01:01:01 +05:30
parent 3e5a547bd8
commit 335012c792
5 changed files with 404 additions and 72 deletions

View file

@ -39,6 +39,7 @@ import LogoSourceSettings from '../screens/LogoSourceSettings';
import ThemeScreen from '../screens/ThemeScreen';
import ProfilesScreen from '../screens/ProfilesScreen';
import OnboardingScreen from '../screens/OnboardingScreen';
import PluginsScreen from '../screens/PluginsScreen';
// Stack navigator types
export type RootStackParamList = {
@ -104,6 +105,7 @@ export type RootStackParamList = {
LogoSourceSettings: undefined;
ThemeSettings: undefined;
ProfilesSettings: undefined;
Plugins: undefined;
};
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
@ -1013,6 +1015,21 @@ const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStack
},
}}
/>
<Stack.Screen
name="Plugins"
component={PluginsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
</Stack.Navigator>
</View>
</PaperProvider>

View file

@ -0,0 +1,253 @@
import React, { useCallback, useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
SafeAreaView,
StatusBar,
Alert,
Platform,
Dimensions,
TextInput,
ActivityIndicator,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { useTheme } from '../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { pluginManager } from '../services/PluginManager';
import { SettingItem, SettingsCard } from './SettingsScreen'; // Assuming these are exported
const { width } = Dimensions.get('window');
interface LoadedPlugin {
name: string;
version: string;
sourceUrl?: string;
}
const PluginsScreen: React.FC = () => {
const navigation = useNavigation();
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const [pluginUrl, setPluginUrl] = useState('');
const [loadedPlugins, setLoadedPlugins] = useState<LoadedPlugin[]>([]);
const [isLoadingPlugin, setIsLoadingPlugin] = useState(false);
const refreshPluginsList = useCallback(() => {
const plugins = pluginManager.getScraperPlugins();
setLoadedPlugins(
plugins.map(p => ({
name: p.name,
version: p.version,
sourceUrl: p.sourceUrl,
}))
);
}, []);
useEffect(() => {
refreshPluginsList();
}, [refreshPluginsList]);
const handleLoadPlugin = useCallback(async () => {
if (!pluginUrl.trim() || !pluginUrl.startsWith('http')) {
Alert.alert('Invalid URL', 'Please enter a valid plugin URL.');
return;
}
setIsLoadingPlugin(true);
const success = await pluginManager.loadPluginFromUrl(pluginUrl.trim());
setIsLoadingPlugin(false);
if (success) {
Alert.alert('Success', 'Plugin loaded successfully.');
setPluginUrl('');
refreshPluginsList();
} else {
Alert.alert(
'Error',
'Failed to load the plugin. Check the URL and console for errors.'
);
}
}, [pluginUrl, refreshPluginsList]);
const handleRemovePlugin = useCallback(
(sourceUrl: string) => {
if (!sourceUrl) return;
Alert.alert(
'Remove Plugin',
'Are you sure you want to remove this plugin?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => {
pluginManager.removePlugin(sourceUrl);
refreshPluginsList();
},
},
]
);
},
[refreshPluginsList]
);
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
const topSpacing = Platform.OS === 'android' ? StatusBar.currentHeight || 0 : insets.top;
const headerHeight = headerBaseHeight + topSpacing;
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle={'light-content'} />
<View style={{ flex: 1 }}>
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
Plugins
</Text>
</View>
<View style={styles.contentContainer}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<SettingsCard title="Add Plugin">
<View style={[styles.pluginInputContainer, { borderBottomColor: currentTheme.colors.elevation2 }]}>
<TextInput
style={[styles.pluginInput, { color: currentTheme.colors.highEmphasis }]}
placeholder="Enter plugin URL..."
placeholderTextColor={currentTheme.colors.mediumEmphasis}
value={pluginUrl}
onChangeText={setPluginUrl}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="done"
onSubmitEditing={handleLoadPlugin}
/>
<TouchableOpacity
style={[styles.loadButton, { backgroundColor: isLoadingPlugin ? currentTheme.colors.mediumGray : currentTheme.colors.primary }]}
onPress={handleLoadPlugin}
disabled={isLoadingPlugin}
>
{isLoadingPlugin ? (
<ActivityIndicator size="small" color={currentTheme.colors.white} />
) : (
<Text style={[styles.loadButtonText, { color: currentTheme.colors.white }]}>Load</Text>
)}
</TouchableOpacity>
</View>
</SettingsCard>
<SettingsCard title="Loaded Scrapers">
{loadedPlugins.length > 0 ? (
loadedPlugins.map((plugin, index) => (
<SettingItem
key={plugin.sourceUrl || index}
icon="extension"
title={`${plugin.name} v${plugin.version}`}
description={plugin.sourceUrl ? 'External' : 'Built-in'}
isLast={index === loadedPlugins.length - 1}
renderControl={() =>
plugin.sourceUrl ? (
<TouchableOpacity onPress={() => handleRemovePlugin(plugin.sourceUrl!)} style={styles.removeButton}>
<MaterialIcons name="close" size={20} color={currentTheme.colors.warning} />
</TouchableOpacity>
) : null
}
/>
))
) : (
<SettingItem
icon="extension"
title="No Custom Scrapers"
description="Add a plugin URL above to get started"
isLast={true}
/>
)}
</SettingsCard>
</ScrollView>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
paddingHorizontal: Math.max(1, width * 0.05),
flexDirection: 'row',
alignItems: 'flex-end',
paddingBottom: 8,
backgroundColor: 'transparent',
zIndex: 2,
},
backButton: {
position: 'absolute',
left: Math.max(1, width * 0.05),
bottom: 8,
zIndex: 10,
},
headerTitle: {
flex: 1,
textAlign: 'center',
fontSize: Math.min(24, width * 0.06),
fontWeight: '700',
letterSpacing: 0.3,
},
contentContainer: {
flex: 1,
zIndex: 1,
width: '100%',
},
scrollView: {
flex: 1,
width: '100%',
},
scrollContent: {
flexGrow: 1,
width: '100%',
paddingBottom: 100,
},
pluginInputContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
borderBottomWidth: 1,
},
pluginInput: {
flex: 1,
fontSize: 16,
paddingVertical: 10,
},
loadButton: {
marginLeft: 12,
paddingHorizontal: 16,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
minWidth: 80,
},
loadButtonText: {
fontSize: 15,
fontWeight: '600',
},
removeButton: {
padding: 8,
borderRadius: 20,
marginLeft: 8,
},
});
export default PluginsScreen;

View file

@ -38,12 +38,12 @@ const { width } = Dimensions.get('window');
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Card component with minimalistic style
interface SettingsCardProps {
export interface SettingsCardProps {
children: React.ReactNode;
title?: string;
}
const SettingsCard: React.FC<SettingsCardProps> = ({ children, title }) => {
export const SettingsCard: React.FC<SettingsCardProps> = ({ children, title }) => {
const { currentTheme } = useTheme();
return (
@ -68,7 +68,7 @@ const SettingsCard: React.FC<SettingsCardProps> = ({ children, title }) => {
);
};
interface SettingItemProps {
export interface SettingItemProps {
title: string;
description?: string;
icon: string;
@ -78,7 +78,7 @@ interface SettingItemProps {
badge?: string | number;
}
const SettingItem: React.FC<SettingItemProps> = ({
export const SettingItem: React.FC<SettingItemProps> = ({
title,
description,
icon,
@ -131,6 +131,12 @@ const SettingItem: React.FC<SettingItemProps> = ({
);
};
interface LoadedPlugin {
name: string;
version: string;
sourceUrl?: string;
}
const SettingsScreen: React.FC = () => {
const { settings, updateSetting } = useSettings();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -160,32 +166,14 @@ const SettingsScreen: React.FC = () => {
const [addonCount, setAddonCount] = useState<number>(0);
const [catalogCount, setCatalogCount] = useState<number>(0);
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
const [pluginUrl, setPluginUrl] = useState('');
const [loadedPlugins, setLoadedPlugins] = useState<string[]>([]);
const [isLoadingPlugin, setIsLoadingPlugin] = useState(false);
const refreshPluginsList = useCallback(() => {
const plugins = pluginManager.getScraperPlugins();
setLoadedPlugins(plugins.map(p => `${p.name} v${p.version}`));
}, []);
const handleLoadPlugin = useCallback(async () => {
if (!pluginUrl.trim() || !pluginUrl.startsWith('http')) {
Alert.alert('Invalid URL', 'Please enter a valid plugin URL.');
return;
}
setIsLoadingPlugin(true);
const success = await pluginManager.loadPluginFromUrl(pluginUrl.trim());
setIsLoadingPlugin(false);
if (success) {
Alert.alert('Success', 'Plugin loaded successfully.');
setPluginUrl('');
refreshPluginsList();
} else {
Alert.alert('Error', 'Failed to load the plugin. Check the URL and console for errors.');
}
}, [pluginUrl, refreshPluginsList]);
const loadData = useCallback(async () => {
try {
// Load addon count and get their catalogs
@ -418,6 +406,17 @@ const SettingsScreen: React.FC = () => {
isLast={true}
/>
</SettingsCard>
<SettingsCard title="Plugins">
<SettingItem
title="Manage Plugins"
description="Add or remove external plugins"
icon="extension"
onPress={() => navigation.navigate('Plugins')}
renderControl={ChevronRight}
isLast={true}
/>
</SettingsCard>
{/* Playback & Experience */}
<SettingsCard title="PLAYBACK">
@ -526,40 +525,6 @@ const SettingsScreen: React.FC = () => {
</SettingsCard>
)}
<SettingsCard title="Plugins">
<View style={[styles.pluginInputContainer, { borderBottomColor: currentTheme.colors.elevation2 }]}>
<TextInput
style={[styles.pluginInput, { color: currentTheme.colors.highEmphasis }]}
placeholder="Enter plugin URL..."
placeholderTextColor={currentTheme.colors.mediumEmphasis}
value={pluginUrl}
onChangeText={setPluginUrl}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="done"
onSubmitEditing={handleLoadPlugin}
/>
<TouchableOpacity
style={[styles.loadButton, { backgroundColor: isLoadingPlugin ? currentTheme.colors.mediumGray : currentTheme.colors.primary }]}
onPress={handleLoadPlugin}
disabled={isLoadingPlugin}
>
{isLoadingPlugin ? (
<ActivityIndicator size="small" color={currentTheme.colors.white} />
) : (
<Text style={[styles.loadButtonText, { color: currentTheme.colors.white }]}>Load</Text>
)}
</TouchableOpacity>
</View>
<SettingItem
icon="extension"
title="Loaded Scrapers"
description={loadedPlugins.length > 0 ? loadedPlugins.join(', ') : 'No custom plugins loaded'}
isLast={true}
/>
</SettingsCard>
<View style={styles.footer}>
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
Made with by the Nuvio team
@ -741,6 +706,11 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '600',
},
removeButton: {
padding: 8,
borderRadius: 20,
marginLeft: 8,
},
});
export default SettingsScreen;

View file

@ -1,13 +1,11 @@
// Export all screens from a single file
export { default as HomeScreen } from './HomeScreen';
export { default as PlayerSettingsScreen } from './PlayerSettingsScreen';
export { default as ProfilesScreen } from './ProfilesScreen';
export { default as SearchScreen } from './SearchScreen';
export { default as AddonsScreen } from './AddonsScreen';
export { default as SettingsScreen } from './SettingsScreen';
export { default as MetadataScreen } from './MetadataScreen';
export { default as CatalogScreen } from './CatalogScreen';
export { default as DiscoverScreen } from './DiscoverScreen';
export { default as LibraryScreen } from './LibraryScreen';
export { default as ShowRatingsScreen } from './ShowRatingsScreen';
export { default as CatalogSettingsScreen } from './CatalogSettingsScreen';
export { default as StreamsScreen } from './StreamsScreen';
export { default as OnboardingScreen } from './OnboardingScreen';
export { default as ThemeScreen } from './ThemeScreen';
export { default as TMDBSettingsScreen } from './TMDBSettingsScreen';
export { default as TraktSettingsScreen } from './TraktSettingsScreen';
export { default as PluginsScreen } from './PluginsScreen';

View file

@ -1,9 +1,12 @@
import { logger } from '../utils/logger';
import * as cheerio from 'cheerio';
import AsyncStorage from '@react-native-async-storage/async-storage';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore no types for Babel standalone
const Babel = require('@babel/standalone');
const PLUGIN_URLS_STORAGE_KEY = '@plugin_urls';
// --- Type Definitions ---
interface Plugin {
@ -13,6 +16,7 @@ interface Plugin {
description: string;
type: 'scraper' | 'other';
getStreams: (options: GetStreamsOptions) => Promise<Stream[]>;
sourceUrl?: string; // To track the origin of the plugin
}
interface Stream {
@ -104,6 +108,9 @@ class PluginManager {
private constructor() {
this.loadBuiltInPlugins();
this.loadPersistedPlugins().catch(err => {
logger.error('[PluginManager] Error during async initialization', err);
});
}
public static getInstance(): PluginManager {
@ -113,20 +120,89 @@ class PluginManager {
return PluginManager.instance;
}
public async loadPluginFromUrl(url: string): Promise<boolean> {
private async loadPersistedPlugins() {
try {
const storedUrlsJson = await AsyncStorage.getItem(PLUGIN_URLS_STORAGE_KEY);
if (storedUrlsJson) {
const urls = JSON.parse(storedUrlsJson);
if (Array.isArray(urls)) {
logger.log('[PluginManager] Loading persisted plugins...', urls);
for (const url of urls) {
await this.loadPluginFromUrl(url, false);
}
}
}
} catch (error) {
logger.error('[PluginManager] Failed to load persisted plugins:', error);
}
}
private async persistPluginUrl(url: string) {
try {
const storedUrlsJson = await AsyncStorage.getItem(PLUGIN_URLS_STORAGE_KEY);
let urls: string[] = [];
if (storedUrlsJson) {
urls = JSON.parse(storedUrlsJson);
}
if (!urls.includes(url)) {
urls.push(url);
await AsyncStorage.setItem(PLUGIN_URLS_STORAGE_KEY, JSON.stringify(urls));
logger.log(`[PluginManager] Persisted plugin URL: ${url}`);
}
} catch (error) {
logger.error('[PluginManager] Failed to persist plugin URL:', error);
}
}
private async removePersistedPluginUrl(url: string) {
try {
const storedUrlsJson = await AsyncStorage.getItem(PLUGIN_URLS_STORAGE_KEY);
if (storedUrlsJson) {
let urls: string[] = JSON.parse(storedUrlsJson);
const index = urls.indexOf(url);
if (index > -1) {
urls.splice(index, 1);
await AsyncStorage.setItem(PLUGIN_URLS_STORAGE_KEY, JSON.stringify(urls));
logger.log(`[PluginManager] Removed persisted plugin URL: ${url}`);
}
}
} catch (error) {
logger.error('[PluginManager] Failed to remove persisted plugin URL:', error);
}
}
public async loadPluginFromUrl(url: string, persist = true): Promise<boolean> {
logger.log(`[PluginManager] Attempting to load plugin from URL: ${url}`);
if (this.plugins.some(p => p.sourceUrl === url)) {
logger.log(`[PluginManager] Plugin from URL ${url} is already loaded.`);
return true;
}
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch plugin from URL: ${response.statusText}`);
}
const pluginCode = await response.text();
this.runPlugin(pluginCode);
// Assuming runPlugin is synchronous for registration purposes.
// A more robust system might have runPlugin return success/failure.
return true;
const newPlugin = this.runPlugin(pluginCode, url);
if (newPlugin) {
if (persist) {
await this.persistPluginUrl(url);
}
return true;
}
logger.error(`[PluginManager] Plugin from ${url} executed but failed to register.`);
return false;
} catch (error) {
logger.error(`[PluginManager] Failed to load plugin from URL ${url}:`, error);
if (persist === false) {
logger.log(`[PluginManager] Removing failed persisted plugin URL: ${url}`);
await this.removePersistedPluginUrl(url);
}
return false;
}
}
@ -144,7 +220,7 @@ class PluginManager {
};
// Require and execute the built-in MoviesMod plugin module (IIFE)
require('./plugins/moviesmod.plugin.js');
//require('./plugins/moviesmod.plugin.js');
delete (global as any).registerPlugin;
} catch (error) {
@ -152,7 +228,22 @@ class PluginManager {
}
}
private runPlugin(pluginCode: string) {
public removePlugin(sourceUrl: string) {
const pluginIndex = this.plugins.findIndex(p => p.sourceUrl === sourceUrl);
if (pluginIndex > -1) {
const plugin = this.plugins[pluginIndex];
this.plugins.splice(pluginIndex, 1);
logger.log(`[PluginManager] Removed plugin: ${plugin.name}`);
if (plugin.sourceUrl) {
this.removePersistedPluginUrl(plugin.sourceUrl).catch(err => {
logger.error(`[PluginManager] Failed to remove persisted URL: ${plugin.sourceUrl}`, err);
});
}
}
}
private runPlugin(pluginCode: string, sourceUrl?: string): Plugin | null {
let registeredPlugin: Plugin | null = null;
const pluginsBefore = this.plugins.length;
// Attempt to strip the JSDoc-style header comment which may cause parsing issues in some JS engines.
@ -164,7 +255,9 @@ class PluginManager {
// This is simpler and more reliable than using `with` or the Function constructor's scope.
(global as any).registerPlugin = (plugin: Plugin) => {
if (plugin && typeof plugin.getStreams === 'function') {
if (sourceUrl) plugin.sourceUrl = sourceUrl;
this.plugins.push(plugin);
registeredPlugin = plugin;
logger.log(`[PluginManager] Successfully registered plugin: ${plugin.name} v${plugin.version}`);
} else {
logger.error('[PluginManager] An invalid plugin was passed to registerPlugin.');
@ -192,6 +285,7 @@ class PluginManager {
// Clean up the global scope to prevent pollution
delete (global as any).registerPlugin;
}
return registeredPlugin;
}
public getScraperPlugins(): Plugin[] {