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.

This commit is contained in:
Nayif Noushad 2025-04-22 14:03:39 +05:30
parent 869bedba72
commit 094bc00ea3
7 changed files with 375 additions and 76 deletions

7
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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]);
}, []);

View file

@ -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<CatalogContent[]>([]);
@ -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 () => {

View file

@ -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 (
<View>
<View style={styles.addonItem}>
{reorderMode && (
<View style={styles.reorderButtons}>
<TouchableOpacity
style={[styles.reorderButton, isFirstItem && styles.disabledButton]}
onPress={() => moveAddonUp(item)}
disabled={isFirstItem}
>
<MaterialIcons
name="arrow-upward"
size={20}
color={isFirstItem ? colors.mediumGray : colors.white}
/>
</TouchableOpacity>
<TouchableOpacity
style={[styles.reorderButton, isLastItem && styles.disabledButton]}
onPress={() => moveAddonDown(item)}
disabled={isLastItem}
>
<MaterialIcons
name="arrow-downward"
size={20}
color={isLastItem ? colors.mediumGray : colors.white}
/>
</TouchableOpacity>
</View>
)}
<View style={styles.addonHeader}>
{logo ? (
<ExpoImage
@ -200,13 +236,20 @@ const AddonsScreen = () => {
<Text style={styles.addonCategory}>{categoryText}</Text>
</View>
</View>
<Switch
value={true} // Default to enabled
onValueChange={(value) => handleToggleAddon(item, !value)}
trackColor={{ false: colors.elevation1, true: colors.primary }}
thumbColor={colors.white}
ios_backgroundColor={colors.elevation1}
/>
<View style={styles.addonActions}>
{!reorderMode ? (
<TouchableOpacity
style={styles.deleteButton}
onPress={() => handleRemoveAddon(item)}
>
<MaterialIcons name="delete" size={20} color={colors.error} />
</TouchableOpacity>
) : (
<View style={styles.priorityBadge}>
<Text style={styles.priorityText}>#{index + 1}</Text>
</View>
)}
</View>
</View>
<Text style={styles.addonDescription}>
@ -236,9 +279,48 @@ const AddonsScreen = () => {
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
<View style={styles.headerActions}>
{/* Reorder Mode Toggle Button */}
<TouchableOpacity
style={[styles.headerButton, reorderMode && styles.activeHeaderButton]}
onPress={toggleReorderMode}
>
<MaterialIcons
name="swap-vert"
size={24}
color={reorderMode ? colors.primary : colors.white}
/>
</TouchableOpacity>
{/* Refresh Button */}
<TouchableOpacity
style={styles.headerButton}
onPress={refreshAddons}
disabled={loading}
>
<MaterialIcons
name="refresh"
size={24}
color={loading ? colors.mediumGray : colors.white}
/>
</TouchableOpacity>
</View>
</View>
<Text style={styles.headerTitle}>Addons</Text>
<Text style={styles.headerTitle}>
Addons
{reorderMode && <Text style={styles.reorderModeText}> (Reorder Mode)</Text>}
</Text>
{reorderMode && (
<View style={styles.reorderInfoBanner}>
<MaterialIcons name="info-outline" size={18} color={colors.primary} />
<Text style={styles.reorderInfoText}>
Addons at the top have higher priority when loading content
</Text>
</View>
)}
{loading ? (
<View style={styles.loadingContainer}>
@ -256,40 +338,44 @@ const AddonsScreen = () => {
<View style={styles.statsContainer}>
<StatsCard value={addons.length} label="Addons" />
<View style={styles.statsDivider} />
<StatsCard value={activeAddons} label="Active" />
<StatsCard value={addons.length} label="Active" />
<View style={styles.statsDivider} />
<StatsCard value={catalogCount} label="Catalogs" />
</View>
</View>
{/* Add Addon Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>ADD NEW ADDON</Text>
<View style={styles.addAddonContainer}>
<TextInput
style={styles.addonInput}
placeholder="Addon URL"
placeholderTextColor={colors.mediumGray}
value={addonUrl}
onChangeText={setAddonUrl}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
style={[styles.addButton, {opacity: installing || !addonUrl ? 0.6 : 1}]}
onPress={handleAddAddon}
disabled={installing || !addonUrl}
>
<Text style={styles.addButtonText}>
{installing ? 'Loading...' : 'Add Addon'}
</Text>
</TouchableOpacity>
{/* Hide Add Addon Section in reorder mode */}
{!reorderMode && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>ADD NEW ADDON</Text>
<View style={styles.addAddonContainer}>
<TextInput
style={styles.addonInput}
placeholder="Addon URL"
placeholderTextColor={colors.mediumGray}
value={addonUrl}
onChangeText={setAddonUrl}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
style={[styles.addButton, {opacity: installing || !addonUrl ? 0.6 : 1}]}
onPress={handleAddAddon}
disabled={installing || !addonUrl}
>
<Text style={styles.addButtonText}>
{installing ? 'Loading...' : 'Add Addon'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
)}
{/* Installed Addons Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>INSTALLED ADDONS</Text>
<Text style={styles.sectionTitle}>
{reorderMode ? "DRAG ADDONS TO REORDER" : "INSTALLED ADDONS"}
</Text>
<View style={styles.addonList}>
{addons.length === 0 ? (
<View style={styles.emptyContainer}>
@ -297,20 +383,14 @@ const AddonsScreen = () => {
<Text style={styles.emptyText}>No addons installed</Text>
</View>
) : (
addons.map((addon, index) => {
const isLast = index === addons.length - 1;
return (
<View
key={addon.id}
style={[
styles.addonItem,
{ marginBottom: isLast ? 32 : 16 }
]}
>
{renderAddonItem({ item: addon })}
</View>
);
})
addons.map((addon, index) => (
<View
key={addon.id}
style={{ marginBottom: index === addons.length - 1 ? 32 : 0 }}
>
{renderAddonItem({ item: addon, index })}
</View>
))
)}
</View>
</View>
@ -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;

View file

@ -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);
}
}
}

View file

@ -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<string, Manifest> = 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<void> {
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<Manifest> {
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<Manifest[]> {
@ -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<void> {
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();