From 094bc00ea3cb2703494d27f80532f3de8c8a778f Mon Sep 17 00:00:00 2001 From: Nayif Noushad Date: Tue, 22 Apr 2025 14:03:39 +0530 Subject: [PATCH] Add event emitter for addon changes; integrate addon order management in StremioService, allowing reordering of addons in the UI. Update CatalogContext and useHomeCatalogs hooks to listen for addon events and refresh catalogs accordingly. Enhance AddonsScreen with reorder functionality and UI improvements for better user experience. --- package-lock.json | 7 + package.json | 1 + src/contexts/CatalogContext.tsx | 25 ++- src/hooks/useHomeCatalogs.ts | 28 +++ src/screens/AddonsScreen.tsx | 302 ++++++++++++++++++++++++-------- src/services/catalogService.ts | 3 +- src/services/stremioService.ts | 85 ++++++++- 7 files changed, 375 insertions(+), 76 deletions(-) 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();