diff --git a/package-lock.json b/package-lock.json index 86d2d58e..c6cd9c40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@types/react-native-video": "^5.0.20", "axios": "^1.8.4", "date-fns": "^4.1.0", + "eventemitter3": "^5.0.1", "expo": "~52.0.43", "expo-auth-session": "^6.0.3", "expo-blur": "^14.0.3", @@ -6485,6 +6486,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/exec-async": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz", diff --git a/package.json b/package.json index 1f685154..f0c2cae9 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@types/react-native-video": "^5.0.20", "axios": "^1.8.4", "date-fns": "^4.1.0", + "eventemitter3": "^5.0.1", "expo": "~52.0.43", "expo-auth-session": "^6.0.3", "expo-blur": "^14.0.3", diff --git a/src/contexts/CatalogContext.tsx b/src/contexts/CatalogContext.tsx index 8abf254d..41e734c8 100644 --- a/src/contexts/CatalogContext.tsx +++ b/src/contexts/CatalogContext.tsx @@ -1,5 +1,7 @@ -import React, { createContext, useContext, useState, useCallback } from 'react'; +import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; import { StreamingContent } from '../services/catalogService'; +import { addonEmitter, ADDON_EVENTS } from '../services/stremioService'; +import { logger } from '../utils/logger'; interface CatalogContextType { lastUpdate: number; @@ -25,8 +27,29 @@ export const CatalogProvider: React.FC<{ children: React.ReactNode }> = ({ child const refreshCatalogs = useCallback(() => { setLastUpdate(Date.now()); + logger.info('Refreshing catalogs, timestamp:', Date.now()); }, []); + // Listen for addon changes to update catalog data + useEffect(() => { + const handleAddonChange = () => { + logger.info('Addon changed, triggering catalog refresh'); + refreshCatalogs(); + }; + + // Subscribe to all addon events to refresh catalogs + addonEmitter.on(ADDON_EVENTS.ORDER_CHANGED, handleAddonChange); + addonEmitter.on(ADDON_EVENTS.ADDON_ADDED, handleAddonChange); + addonEmitter.on(ADDON_EVENTS.ADDON_REMOVED, handleAddonChange); + + return () => { + // Clean up event listeners + addonEmitter.off(ADDON_EVENTS.ORDER_CHANGED, handleAddonChange); + addonEmitter.off(ADDON_EVENTS.ADDON_ADDED, handleAddonChange); + addonEmitter.off(ADDON_EVENTS.ADDON_REMOVED, handleAddonChange); + }; + }, [refreshCatalogs]); + const addToLibrary = useCallback((content: StreamingContent) => { setLibraryItems(prev => [...prev, content]); }, []); diff --git a/src/hooks/useHomeCatalogs.ts b/src/hooks/useHomeCatalogs.ts index 60811a90..429fdbfd 100644 --- a/src/hooks/useHomeCatalogs.ts +++ b/src/hooks/useHomeCatalogs.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { CatalogContent, catalogService } from '../services/catalogService'; import { logger } from '../utils/logger'; import { useCatalogContext } from '../contexts/CatalogContext'; +import { addonEmitter, ADDON_EVENTS } from '../services/stremioService'; export function useHomeCatalogs() { const [catalogs, setCatalogs] = useState([]); @@ -72,6 +73,33 @@ export function useHomeCatalogs() { loadCatalogs(); }, [loadCatalogs, lastUpdate]); + // Subscribe to addon events to refresh catalogs when addons change + useEffect(() => { + // Handler for addon order changes + const handleAddonOrderChange = () => { + logger.info('Addon order changed, refreshing catalogs'); + loadCatalogs(); + }; + + // Handler for addon added/removed + const handleAddonChange = () => { + logger.info('Addon added or removed, refreshing catalogs'); + loadCatalogs(); + }; + + // Subscribe to addon events + addonEmitter.on(ADDON_EVENTS.ORDER_CHANGED, handleAddonOrderChange); + addonEmitter.on(ADDON_EVENTS.ADDON_ADDED, handleAddonChange); + addonEmitter.on(ADDON_EVENTS.ADDON_REMOVED, handleAddonChange); + + // Cleanup on unmount + return () => { + addonEmitter.off(ADDON_EVENTS.ORDER_CHANGED, handleAddonOrderChange); + addonEmitter.off(ADDON_EVENTS.ADDON_ADDED, handleAddonChange); + addonEmitter.off(ADDON_EVENTS.ADDON_REMOVED, handleAddonChange); + }; + }, [loadCatalogs]); + // Cleanup on unmount useEffect(() => { return () => { diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index e6c28824..6956c900 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -31,7 +31,7 @@ import { logger } from '../utils/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { BlurView } from 'expo-blur'; -// Extend Manifest type to include logo +// Extend Manifest type to include logo only (remove disabled status) interface ExtendedManifest extends Manifest { logo?: string; } @@ -49,7 +49,8 @@ const AddonsScreen = () => { const [showConfirmModal, setShowConfirmModal] = useState(false); const [installing, setInstalling] = useState(false); const [catalogCount, setCatalogCount] = useState(0); - const [activeAddons, setActiveAddons] = useState(0); + // Add state for reorder mode + const [reorderMode, setReorderMode] = useState(false); // Force dark mode const isDarkMode = true; @@ -60,9 +61,9 @@ const AddonsScreen = () => { const loadAddons = async () => { try { setLoading(true); + // Use the regular method without disabled state const installedAddons = await stremioService.getInstalledAddonsAsync(); - setAddons(installedAddons); - setActiveAddons(installedAddons.length); + setAddons(installedAddons as ExtendedManifest[]); // Count catalogs let totalCatalogs = 0; @@ -130,28 +131,27 @@ const AddonsScreen = () => { } }; - const handleToggleAddon = (addon: ExtendedManifest, enabled: boolean) => { - // Logic to enable/disable an addon - Alert.alert( - enabled ? 'Disable Addon' : 'Enable Addon', - `Are you sure you want to ${enabled ? 'disable' : 'enable'} ${addon.name}?`, - [ - { text: 'Cancel', style: 'cancel' }, - { - text: enabled ? 'Disable' : 'Enable', - style: enabled ? 'destructive' : 'default', - onPress: () => { - // TODO: Implement actual toggle functionality - Alert.alert('Success', `${addon.name} ${enabled ? 'disabled' : 'enabled'}`); - }, - }, - ] - ); + const refreshAddons = async () => { + loadAddons(); + }; + + const moveAddonUp = (addon: ExtendedManifest) => { + if (stremioService.moveAddonUp(addon.id)) { + // Refresh the list to reflect the new order + loadAddons(); + } + }; + + const moveAddonDown = (addon: ExtendedManifest) => { + if (stremioService.moveAddonDown(addon.id)) { + // Refresh the list to reflect the new order + loadAddons(); + } }; const handleRemoveAddon = (addon: ExtendedManifest) => { Alert.alert( - 'Uninstall', + 'Uninstall Addon', `Are you sure you want to uninstall ${addon.name}?`, [ { text: 'Cancel', style: 'cancel' }, @@ -160,14 +160,20 @@ const AddonsScreen = () => { style: 'destructive', onPress: () => { stremioService.removeAddon(addon.id); - loadAddons(); + + // Remove from addons list + setAddons(prev => prev.filter(a => a.id !== addon.id)); }, }, ] ); }; - const renderAddonItem = ({ item }: { item: ExtendedManifest }) => { + const toggleReorderMode = () => { + setReorderMode(!reorderMode); + }; + + const renderAddonItem = ({ item, index }: { item: ExtendedManifest, index: number }) => { const types = item.types || []; const description = item.description || ''; // @ts-ignore - some addons might have logo property even though it's not in the type @@ -177,9 +183,39 @@ const AddonsScreen = () => { const categoryText = types.length > 0 ? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ') : 'No categories'; + + const isFirstItem = index === 0; + const isLastItem = index === addons.length - 1; return ( - + + {reorderMode && ( + + moveAddonUp(item)} + disabled={isFirstItem} + > + + + moveAddonDown(item)} + disabled={isLastItem} + > + + + + )} + {logo ? ( { {categoryText} - handleToggleAddon(item, !value)} - trackColor={{ false: colors.elevation1, true: colors.primary }} - thumbColor={colors.white} - ios_backgroundColor={colors.elevation1} - /> + + {!reorderMode ? ( + handleRemoveAddon(item)} + > + + + ) : ( + + #{index + 1} + + )} + @@ -236,9 +279,48 @@ const AddonsScreen = () => { Settings + + + {/* Reorder Mode Toggle Button */} + + + + + {/* Refresh Button */} + + + + - Addons + + Addons + {reorderMode && (Reorder Mode)} + + + {reorderMode && ( + + + + Addons at the top have higher priority when loading content + + + )} {loading ? ( @@ -256,40 +338,44 @@ const AddonsScreen = () => { - + - {/* Add Addon Section */} - - ADD NEW ADDON - - - - - {installing ? 'Loading...' : 'Add Addon'} - - + {/* Hide Add Addon Section in reorder mode */} + {!reorderMode && ( + + ADD NEW ADDON + + + + + {installing ? 'Loading...' : 'Add Addon'} + + + - + )} {/* Installed Addons Section */} - INSTALLED ADDONS + + {reorderMode ? "DRAG ADDONS TO REORDER" : "INSTALLED ADDONS"} + {addons.length === 0 ? ( @@ -297,20 +383,14 @@ const AddonsScreen = () => { No addons installed ) : ( - addons.map((addon, index) => { - const isLast = index === addons.length - 1; - return ( - - {renderAddonItem({ item: addon })} - - ); - }) + addons.map((addon, index) => ( + + {renderAddonItem({ item: addon, index })} + + )) )} @@ -440,9 +520,76 @@ const styles = StyleSheet.create({ header: { flexDirection: 'row', alignItems: 'center', + justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + }, + headerButton: { + padding: 8, + marginLeft: 8, + }, + activeHeaderButton: { + backgroundColor: 'rgba(45, 156, 219, 0.2)', + borderRadius: 6, + }, + reorderModeText: { + color: colors.primary, + fontSize: 18, + fontWeight: '400', + }, + reorderInfoBanner: { + backgroundColor: 'rgba(45, 156, 219, 0.15)', + paddingHorizontal: 16, + paddingVertical: 10, + marginHorizontal: 16, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + reorderInfoText: { + color: colors.white, + fontSize: 14, + marginLeft: 8, + }, + reorderButtons: { + position: 'absolute', + left: -12, + top: '50%', + marginTop: -40, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + zIndex: 10, + }, + reorderButton: { + backgroundColor: colors.elevation3, + width: 30, + height: 30, + borderRadius: 15, + justifyContent: 'center', + alignItems: 'center', + marginVertical: 4, + }, + disabledButton: { + opacity: 0.5, + backgroundColor: colors.elevation2, + }, + priorityBadge: { + backgroundColor: colors.primary, + borderRadius: 12, + paddingHorizontal: 8, + paddingVertical: 3, + }, + priorityText: { + color: colors.white, + fontSize: 12, + fontWeight: 'bold', + }, backButton: { flexDirection: 'row', alignItems: 'center', @@ -569,6 +716,7 @@ const styles = StyleSheet.create({ shadowOpacity: 0.1, shadowRadius: 4, elevation: 2, + marginBottom: 16, }, addonHeader: { flexDirection: 'row', @@ -754,6 +902,16 @@ const styles = StyleSheet.create({ color: colors.white, fontWeight: '600', }, + addonActions: { + flexDirection: 'row', + alignItems: 'center', + }, + deleteButton: { + padding: 6, + }, + refreshButton: { + padding: 8, + }, }); export default AddonsScreen; \ No newline at end of file diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index f60d0f69..e76bef41 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -153,6 +153,7 @@ class CatalogService { const catalogSettingsJson = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY); const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {}; + // Process addons in order (they're already returned in order from getAllAddons) for (const addon of addons) { if (addon.catalogs) { for (const catalog of addon.catalogs) { @@ -200,7 +201,7 @@ class CatalogService { }); } } catch (error) { - logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error); + logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error); } } } diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index fc890bbf..bad89ff2 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -1,6 +1,15 @@ import axios from 'axios'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { logger } from '../utils/logger'; +import EventEmitter from 'eventemitter3'; + +// Create an event emitter for addon changes +export const addonEmitter = new EventEmitter(); +export const ADDON_EVENTS = { + ORDER_CHANGED: 'order_changed', + ADDON_ADDED: 'addon_added', + ADDON_REMOVED: 'addon_removed' +}; // Basic types for Stremio export interface Meta { @@ -137,7 +146,9 @@ export interface AddonCapabilities { class StremioService { private static instance: StremioService; private installedAddons: Map = new Map(); + private addonOrder: string[] = []; private readonly STORAGE_KEY = 'stremio-addons'; + private readonly ADDON_ORDER_KEY = 'stremio-addon-order'; private readonly DEFAULT_ADDONS = [ 'https://v3-cinemeta.strem.io/manifest.json', 'https://opensubtitles-v3.strem.io/manifest.json' @@ -177,11 +188,27 @@ class StremioService { } } + // Load addon order if exists + const storedOrder = await AsyncStorage.getItem(this.ADDON_ORDER_KEY); + if (storedOrder) { + this.addonOrder = JSON.parse(storedOrder); + // Filter out any ids that aren't in installedAddons + this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id)); + } + + // Add any missing addons to the order + const installedIds = Array.from(this.installedAddons.keys()); + const missingIds = installedIds.filter(id => !this.addonOrder.includes(id)); + this.addonOrder = [...this.addonOrder, ...missingIds]; + // If no addons, install defaults if (this.installedAddons.size === 0) { await this.installDefaultAddons(); } + // Ensure order is saved + await this.saveAddonOrder(); + this.initialized = true; } catch (error) { logger.error('Failed to initialize addons:', error); @@ -245,6 +272,14 @@ class StremioService { } } + private async saveAddonOrder(): Promise { + try { + await AsyncStorage.setItem(this.ADDON_ORDER_KEY, JSON.stringify(this.addonOrder)); + } catch (error) { + logger.error('Failed to save addon order:', error); + } + } + async getManifest(url: string): Promise { try { // Clean up URL - ensure it ends with manifest.json @@ -278,7 +313,16 @@ class StremioService { const manifest = await this.getManifest(url); if (manifest && manifest.id) { this.installedAddons.set(manifest.id, manifest); + + // Add to order if not already present (new addons go to the end) + if (!this.addonOrder.includes(manifest.id)) { + this.addonOrder.push(manifest.id); + } + await this.saveInstalledAddons(); + await this.saveAddonOrder(); + // Emit an event that an addon was added + addonEmitter.emit(ADDON_EVENTS.ADDON_ADDED, manifest.id); } else { throw new Error('Invalid addon manifest'); } @@ -287,12 +331,20 @@ class StremioService { removeAddon(id: string): void { if (this.installedAddons.has(id)) { this.installedAddons.delete(id); + // Remove from order + this.addonOrder = this.addonOrder.filter(addonId => addonId !== id); this.saveInstalledAddons(); + this.saveAddonOrder(); + // Emit an event that an addon was removed + addonEmitter.emit(ADDON_EVENTS.ADDON_REMOVED, id); } } getInstalledAddons(): Manifest[] { - return Array.from(this.installedAddons.values()); + // Return addons in the specified order + return this.addonOrder + .filter(id => this.installedAddons.has(id)) + .map(id => this.installedAddons.get(id)!); } async getInstalledAddonsAsync(): Promise { @@ -476,7 +528,7 @@ class StremioService { } } - // Modify getStreams to use the new callback signature and rely on callbacks for results + // Modify getStreams to use this.getInstalledAddons() instead of getEnabledAddons async getStreams(type: string, id: string, callback?: StreamCallback): Promise { await this.ensureInitialized(); @@ -793,6 +845,35 @@ class StremioService { return []; } + + // Add methods to move addons in the order + moveAddonUp(id: string): boolean { + const index = this.addonOrder.indexOf(id); + if (index > 0) { + // Swap with the previous item + [this.addonOrder[index - 1], this.addonOrder[index]] = + [this.addonOrder[index], this.addonOrder[index - 1]]; + this.saveAddonOrder(); + // Emit an event that the order has changed + addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED); + return true; + } + return false; + } + + moveAddonDown(id: string): boolean { + const index = this.addonOrder.indexOf(id); + if (index >= 0 && index < this.addonOrder.length - 1) { + // Swap with the next item + [this.addonOrder[index], this.addonOrder[index + 1]] = + [this.addonOrder[index + 1], this.addonOrder[index]]; + this.saveAddonOrder(); + // Emit an event that the order has changed + addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED); + return true; + } + return false; + } } export const stremioService = StremioService.getInstance();