Add react-native-draggable-flatlist dependency; enhance CatalogScreen and CatalogSettingsScreen with custom catalog name functionality and modal for renaming; improve loading states across screens for better user experience.

This commit is contained in:
Nayif Noushad 2025-04-20 11:43:11 +05:30
parent 937930540f
commit 2dfb8da36c
11 changed files with 444 additions and 102 deletions

15
package-lock.json generated
View file

@ -38,6 +38,7 @@
"react": "18.3.1",
"react-native": "0.76.9",
"react-native-awesome-slider": "^2.9.0",
"react-native-draggable-flatlist": "^4.0.2",
"react-native-gesture-handler": "~2.20.2",
"react-native-immersive-mode": "^2.0.2",
"react-native-modal": "^14.0.0-rc.1",
@ -10617,6 +10618,20 @@
"react-native-reanimated": ">=3.0.0"
}
},
"node_modules/react-native-draggable-flatlist": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.2.tgz",
"integrity": "sha512-6WN/o3WIRIJdcrQtwGju5vtXjfK8KTFtXds10QIT00MP9SxEFt5VoX7QW+JC22Rpk651cNScOVm+WKs7vDV0iw==",
"license": "MIT",
"dependencies": {
"@babel/preset-typescript": "^7.17.12"
},
"peerDependencies": {
"react-native": ">=0.64.0",
"react-native-gesture-handler": ">=2.0.0",
"react-native-reanimated": ">=2.8.0"
}
},
"node_modules/react-native-gesture-handler": {
"version": "2.20.2",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz",

View file

@ -39,6 +39,7 @@
"react": "18.3.1",
"react-native": "0.76.9",
"react-native-awesome-slider": "^2.9.0",
"react-native-draggable-flatlist": "^4.0.2",
"react-native-gesture-handler": "~2.20.2",
"react-native-immersive-mode": "^2.0.2",
"react-native-modal": "^14.0.0-rc.1",

View file

@ -0,0 +1,57 @@
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { logger } from '../utils/logger';
const CATALOG_CUSTOM_NAMES_KEY = 'catalog_custom_names';
interface CustomNamesCache {
names: { [key: string]: string };
lastUpdate: number;
}
// Simple in-memory cache to avoid repeated AsyncStorage reads within the same session
let cache: CustomNamesCache | null = null;
export function useCustomCatalogNames() {
const [customNames, setCustomNames] = useState<{ [key: string]: string } | null>(cache?.names || null);
const [isLoading, setIsLoading] = useState(!cache); // Only loading if cache is empty
const loadCustomNames = useCallback(async () => {
// Check if cache is recent enough (e.g., within last 5 minutes) - adjust as needed
const now = Date.now();
if (cache && (now - cache.lastUpdate < 5 * 60 * 1000)) {
if (!customNames) setCustomNames(cache.names); // Ensure state is updated if cache existed
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const savedCustomNamesJson = await AsyncStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
const loadedNames = savedCustomNamesJson ? JSON.parse(savedCustomNamesJson) : {};
setCustomNames(loadedNames);
// Update cache
cache = { names: loadedNames, lastUpdate: now };
} catch (error) {
logger.error('Failed to load custom catalog names:', error);
setCustomNames({}); // Set to empty object on error to avoid breaking lookups
} finally {
setIsLoading(false);
}
}, []); // Removed customNames dependency to prevent re-running loop
useEffect(() => {
loadCustomNames();
}, [loadCustomNames]); // Load on mount and if load function changes
const getCustomName = useCallback((addonId: string, type: string, catalogId: string, originalName: string): string => {
if (isLoading || !customNames) {
// Return original name while loading or if loading failed
return originalName;
}
const key = `${addonId}:${type}:${catalogId}`;
return customNames[key] || originalName;
}, [customNames, isLoading]);
return { getCustomName, isLoadingCustomNames: isLoading, refreshCustomNames: loadCustomNames };
}

View file

@ -20,6 +20,7 @@ import { colors } from '../styles';
import { Image } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { logger } from '../utils/logger';
import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames';
type CatalogScreenProps = {
route: RouteProp<RootStackParamList, 'Catalog'>;
@ -44,16 +45,18 @@ const ITEM_MARGIN = SPACING.sm;
const ITEM_WIDTH = (width - (SPACING.lg * 2) - (ITEM_MARGIN * 2 * NUM_COLUMNS)) / NUM_COLUMNS;
const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const { addonId, type, id, name, genreFilter } = route.params;
const { addonId, type, id, name: originalName, genreFilter } = route.params;
const [items, setItems] = useState<Meta[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
// Force dark mode
const isDarkMode = true;
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames();
const displayName = getCustomName(addonId || '', type || '', id || '', originalName || '');
const loadItems = useCallback(async (pageNum: number, shouldRefresh: boolean = false) => {
try {
if (shouldRefresh) {
@ -246,7 +249,9 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
</View>
);
if (loading && items.length === 0) {
const isScreenLoading = loading || isLoadingCustomNames;
if (isScreenLoading && items.length === 0) {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
@ -259,7 +264,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
<Text style={styles.headerTitle}>{displayName || originalName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{renderLoadingState()}
</SafeAreaView>
);
@ -278,7 +283,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{renderErrorState()}
</SafeAreaView>
);
@ -296,7 +301,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{items.length > 0 ? (
<FlatList

View file

@ -10,6 +10,11 @@ import {
SafeAreaView,
StatusBar,
Platform,
Modal,
TextInput,
Pressable,
Button,
Alert,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native';
@ -18,6 +23,7 @@ import { stremioService } from '../services/stremioService';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { useCatalogContext } from '../contexts/CatalogContext';
import { logger } from '../utils/logger';
import { BlurView } from 'expo-blur';
interface CatalogSetting {
addonId: string;
@ -25,6 +31,7 @@ interface CatalogSetting {
type: string;
name: string;
enabled: boolean;
customName?: string;
}
interface CatalogSettingsStorage {
@ -42,6 +49,7 @@ interface GroupedCatalogs {
}
const CATALOG_SETTINGS_KEY = 'catalog_settings';
const CATALOG_CUSTOM_NAMES_KEY = 'catalog_custom_names';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const CatalogSettingsScreen = () => {
@ -52,6 +60,11 @@ const CatalogSettingsScreen = () => {
const { refreshCatalogs } = useCatalogContext();
const isDarkMode = true; // Force dark mode
// Modal State
const [isRenameModalVisible, setIsRenameModalVisible] = useState(false);
const [catalogToRename, setCatalogToRename] = useState<CatalogSetting | null>(null);
const [currentRenameValue, setCurrentRenameValue] = useState('');
// Load saved settings and available catalogs
const loadSettings = useCallback(async () => {
try {
@ -61,24 +74,22 @@ const CatalogSettingsScreen = () => {
const addons = await stremioService.getInstalledAddonsAsync();
const availableCatalogs: CatalogSetting[] = [];
// Get saved settings
const savedSettings = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY);
const savedCatalogs: { [key: string]: boolean } = savedSettings ? JSON.parse(savedSettings) : {};
// Get saved enable/disable settings
const savedSettingsJson = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY);
const savedEnabledSettings: { [key: string]: boolean } = savedSettingsJson ? JSON.parse(savedSettingsJson) : {};
// Get saved custom names
const savedCustomNamesJson = await AsyncStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
const savedCustomNames: { [key: string]: string } = savedCustomNamesJson ? JSON.parse(savedCustomNamesJson) : {};
// Process each addon's catalogs
addons.forEach(addon => {
if (addon.catalogs && addon.catalogs.length > 0) {
// Create a map to store unique catalogs by their type and id
const uniqueCatalogs = new Map<string, CatalogSetting>();
addon.catalogs.forEach(catalog => {
// Create a unique key that includes addon id, type, and catalog id
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
// Format catalog name
let displayName = catalog.name || catalog.id;
// If catalog is a movie or series catalog, make that clear
const catalogType = catalog.type === 'movie' ? 'Movies' : catalog.type === 'series' ? 'TV Shows' : catalog.type.charAt(0).toUpperCase() + catalog.type.slice(1);
uniqueCatalogs.set(settingKey, {
@ -86,18 +97,17 @@ const CatalogSettingsScreen = () => {
catalogId: catalog.id,
type: catalog.type,
name: displayName,
enabled: savedCatalogs[settingKey] !== undefined ? savedCatalogs[settingKey] : true // Enable by default
enabled: savedEnabledSettings[settingKey] !== undefined ? savedEnabledSettings[settingKey] : true,
customName: savedCustomNames[settingKey]
});
});
// Add unique catalogs to the available catalogs array
availableCatalogs.push(...uniqueCatalogs.values());
}
});
// Group settings by addon name
const grouped: GroupedCatalogs = {};
availableCatalogs.forEach(setting => {
const addon = addons.find(a => a.id === setting.addonId);
if (!addon) return;
@ -106,7 +116,7 @@ const CatalogSettingsScreen = () => {
grouped[setting.addonId] = {
name: addon.name,
catalogs: [],
expanded: true, // Start expanded
expanded: true,
enabledCount: 0
};
}
@ -126,8 +136,8 @@ const CatalogSettingsScreen = () => {
}
}, []);
// Save settings when they change
const saveSettings = async (newSettings: CatalogSetting[]) => {
// Save settings when they change (ENABLE/DISABLE ONLY)
const saveEnabledSettings = async (newSettings: CatalogSetting[]) => {
try {
const settingsObj: CatalogSettingsStorage = {
_lastUpdate: Date.now()
@ -139,11 +149,11 @@ const CatalogSettingsScreen = () => {
await AsyncStorage.setItem(CATALOG_SETTINGS_KEY, JSON.stringify(settingsObj));
refreshCatalogs(); // Trigger catalog refresh after saving settings
} catch (error) {
logger.error('Failed to save catalog settings:', error);
logger.error('Failed to save catalog enabled settings:', error);
}
};
// Toggle individual catalog
// Toggle individual catalog enabled state
const toggleCatalog = (addonId: string, index: number) => {
const newSettings = [...settings];
const catalogsForAddon = groupedSettings[addonId].catalogs;
@ -154,7 +164,6 @@ const CatalogSettingsScreen = () => {
enabled: !setting.enabled
};
// Update the setting in the flat list
const flatIndex = newSettings.findIndex(s =>
s.addonId === setting.addonId &&
s.type === setting.type &&
@ -165,14 +174,13 @@ const CatalogSettingsScreen = () => {
newSettings[flatIndex] = updatedSetting;
}
// Update the grouped settings
const newGroupedSettings = { ...groupedSettings };
newGroupedSettings[addonId].catalogs[index] = updatedSetting;
newGroupedSettings[addonId].enabledCount += updatedSetting.enabled ? 1 : -1;
setSettings(newSettings);
setGroupedSettings(newGroupedSettings);
saveSettings(newSettings);
saveEnabledSettings(newSettings); // Use specific save function
};
// Toggle expansion of a group
@ -186,6 +194,47 @@ const CatalogSettingsScreen = () => {
}));
};
// Handle long press on catalog item
const handleLongPress = (setting: CatalogSetting) => {
setCatalogToRename(setting);
setCurrentRenameValue(setting.customName || setting.name);
setIsRenameModalVisible(true);
};
// Handle saving the renamed catalog
const handleSaveRename = async () => {
if (!catalogToRename || !currentRenameValue) return;
const settingKey = `${catalogToRename.addonId}:${catalogToRename.type}:${catalogToRename.catalogId}`;
try {
const savedCustomNamesJson = await AsyncStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
const customNames: { [key: string]: string } = savedCustomNamesJson ? JSON.parse(savedCustomNamesJson) : {};
const trimmedNewName = currentRenameValue.trim();
if (trimmedNewName === catalogToRename.name || trimmedNewName === '') {
delete customNames[settingKey];
} else {
customNames[settingKey] = trimmedNewName;
}
await AsyncStorage.setItem(CATALOG_CUSTOM_NAMES_KEY, JSON.stringify(customNames));
// --- Reload settings to reflect the change ---
await loadSettings();
// --- No need to manually update local state anymore ---
} catch (error) {
logger.error('Failed to save custom catalog name:', error);
Alert.alert('Error', 'Could not save the custom name.'); // Inform user
} finally {
setIsRenameModalVisible(false);
setCatalogToRename(null);
setCurrentRenameValue('');
}
};
useEffect(() => {
loadSettings();
}, [loadSettings]);
@ -252,10 +301,17 @@ const CatalogSettingsScreen = () => {
</TouchableOpacity>
{group.expanded && group.catalogs.map((setting, index) => (
<View key={`${setting.addonId}:${setting.type}:${setting.catalogId}`} style={styles.catalogItem}>
<Pressable
key={`${setting.addonId}:${setting.type}:${setting.catalogId}`}
onLongPress={() => handleLongPress(setting)} // Added long press handler
style={({ pressed }) => [
styles.catalogItem,
pressed && styles.catalogItemPressed, // Optional pressed style
]}
>
<View style={styles.catalogInfo}>
<Text style={styles.catalogName}>
{setting.name}
{setting.customName || setting.name} {/* Display custom or default name */}
</Text>
<Text style={styles.catalogType}>
{setting.type.charAt(0).toUpperCase() + setting.type.slice(1)}
@ -268,26 +324,68 @@ const CatalogSettingsScreen = () => {
thumbColor={Platform.OS === 'android' ? colors.white : undefined}
ios_backgroundColor="#505050"
/>
</View>
</Pressable>
))}
</View>
</View>
))}
<View style={styles.addonSection}>
<Text style={styles.addonTitle}>ORGANIZATION</Text>
<View style={styles.card}>
<TouchableOpacity style={styles.organizationItem}>
<Text style={styles.organizationItemText}>Reorder Sections</Text>
<MaterialIcons name="chevron-right" size={24} color={colors.mediumGray} />
</TouchableOpacity>
<TouchableOpacity style={styles.organizationItem}>
<Text style={styles.organizationItemText}>Customize Names</Text>
<MaterialIcons name="chevron-right" size={24} color={colors.mediumGray} />
</TouchableOpacity>
</View>
</View>
</ScrollView>
{/* Rename Modal */}
<Modal
animationType="fade"
transparent={true}
visible={isRenameModalVisible}
onRequestClose={() => {
setIsRenameModalVisible(false);
setCatalogToRename(null);
}}
>
{Platform.OS === 'ios' ? (
<Pressable style={styles.modalOverlay} onPress={() => setIsRenameModalVisible(false)}>
<BlurView
style={styles.modalContent}
intensity={90}
tint="default"
>
<Pressable onPress={(e) => e.stopPropagation()}>
<Text style={styles.modalTitle}>Rename Catalog</Text>
<TextInput
style={styles.modalInput}
value={currentRenameValue}
onChangeText={setCurrentRenameValue}
placeholder="Enter new catalog name"
placeholderTextColor={colors.mediumGray}
autoFocus={true}
/>
<View style={styles.modalButtons}>
<Button title="Cancel" onPress={() => setIsRenameModalVisible(false)} color={colors.mediumGray} />
<Button title="Save" onPress={handleSaveRename} color={colors.primary} />
</View>
</Pressable>
</BlurView>
</Pressable>
) : (
<Pressable style={styles.modalOverlay} onPress={() => setIsRenameModalVisible(false)}>
<Pressable style={styles.modalContent} onPress={(e) => e.stopPropagation()}>
<Text style={styles.modalTitle}>Rename Catalog</Text>
<TextInput
style={styles.modalInput}
value={currentRenameValue}
onChangeText={setCurrentRenameValue}
placeholder="Enter new catalog name"
placeholderTextColor={colors.mediumGray}
autoFocus={true}
/>
<View style={styles.modalButtons}>
<Button title="Cancel" onPress={() => setIsRenameModalVisible(false)} color={colors.mediumGray} />
<Button title="Save" onPress={handleSaveRename} color={colors.primary} />
</View>
</Pressable>
</Pressable>
)}
</Modal>
</SafeAreaView>
);
};
@ -385,9 +483,14 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
borderBottomWidth: 0.5,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
// Ensure last item doesn't have border if needed (check logic)
},
catalogItemPressed: {
backgroundColor: 'rgba(255, 255, 255, 0.05)', // Subtle feedback for press
},
catalogInfo: {
flex: 1,
marginRight: 8, // Add space before switch
},
catalogName: {
fontSize: 15,
@ -398,18 +501,47 @@ const styles = StyleSheet.create({
fontSize: 13,
color: colors.mediumGray,
},
organizationItem: {
flexDirection: 'row',
justifyContent: 'space-between',
// Modal Styles
modalOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 0.5,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
},
organizationItemText: {
fontSize: 17,
modalContent: {
backgroundColor: Platform.OS === 'ios' ? undefined : colors.elevation3,
borderRadius: 14,
padding: 20,
width: '85%',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 10,
elevation: 10,
overflow: 'hidden',
},
modalTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.white,
marginBottom: 15,
textAlign: 'center',
},
modalInput: {
backgroundColor: colors.elevation1, // Darker input background
color: colors.white,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
marginBottom: 20,
borderWidth: 1,
borderColor: colors.border,
},
modalButtons: {
flexDirection: 'row',
justifyContent: 'space-around', // Adjust as needed (e.g., 'flex-end')
},
});

View file

@ -189,7 +189,6 @@ const CatalogSection = React.memo(({
const { width } = Dimensions.get('window');
const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing
// Only display the first 3 items in the row
const displayItems = useMemo(() =>
catalog.items.slice(0, 3),
[catalog.items]
@ -207,13 +206,34 @@ const CatalogSection = React.memo(({
), [handleContentPress]);
const handleSeeMorePress = useCallback(() => {
// Get addon/catalog info from the first item (assuming homogeneity)
const firstItem = catalog.items[0];
if (!firstItem) return; // Should not happen if section exists
// We need addonId and catalogId. These aren't directly on StreamingContent.
// We might need to fetch this or adjust the GenreCatalog structure.
// FOR NOW: Assuming CatalogScreen can handle potentially missing addonId/catalogId
// OR: We could pass the *genre* as the name and let CatalogScreen figure it out?
// Let's pass the necessary info if available, assuming StreamingContent might have it
// (Requires checking StreamingContent interface or how it's populated)
// --- TEMPORARY/PLACEHOLDER ---
// Ideally, GenreCatalog should contain addonId/catalogId for the group.
// If not, CatalogScreen needs modification or we fetch IDs here.
// Let's stick to passing genre and type for now, CatalogScreen logic might suffice?
navigation.navigate('Catalog', {
id: 'discover',
// We don't have a single catalog ID or Addon ID for a genre section.
// Pass the genre as the 'id' and 'name' for CatalogScreen to potentially filter.
// This might require CatalogScreen to be adapted to handle genre-based views.
addonId: 'genre-based', // Placeholder or identifier
id: catalog.genre,
type: selectedCategory.type,
name: `${catalog.genre} ${selectedCategory.name}`,
genreFilter: catalog.genre
name: `${catalog.genre} ${selectedCategory.name}`, // Pass constructed name for now
genreFilter: catalog.genre // Keep the genre filter
});
}, [navigation, selectedCategory, catalog.genre]);
// --- END TEMPORARY ---
}, [navigation, selectedCategory, catalog.genre, catalog.items]);
const keyExtractor = useCallback((item: StreamingContent) => item.id, []);
const ItemSeparator = useCallback(() => <View style={{ width: 16 }} />, []);

View file

@ -18,6 +18,7 @@ import { useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { colors } from '../styles/colors';
import { catalogService, StreamingAddon } from '../services/catalogService';
import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
@ -36,6 +37,7 @@ const HeroCatalogsScreen: React.FC = () => {
const [loading, setLoading] = useState(true);
const [catalogs, setCatalogs] = useState<CatalogItem[]>([]);
const [selectedCatalogs, setSelectedCatalogs] = useState<string[]>(settings.selectedHeroCatalogs || []);
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames();
const handleBack = useCallback(() => {
navigation.goBack();
@ -125,7 +127,7 @@ const HeroCatalogsScreen: React.FC = () => {
</Text>
</View>
{loading ? (
{loading || isLoadingCustomNames ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
@ -175,30 +177,35 @@ const HeroCatalogsScreen: React.FC = () => {
styles.catalogsContainer,
{ backgroundColor: isDarkMode ? colors.elevation1 : colors.white }
]}>
{addonCatalogs.map(catalog => (
<TouchableOpacity
key={catalog.id}
style={[
styles.catalogItem,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }
]}
onPress={() => toggleCatalog(catalog.id)}
>
<View style={styles.catalogInfo}>
<Text style={[styles.catalogName, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
{catalog.name}
</Text>
<Text style={[styles.catalogType, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
{catalog.type === 'movie' ? 'Movies' : 'TV Shows'}
</Text>
</View>
<MaterialIcons
name={selectedCatalogs.includes(catalog.id) ? "check-box" : "check-box-outline-blank"}
size={24}
color={selectedCatalogs.includes(catalog.id) ? colors.primary : isDarkMode ? colors.mediumEmphasis : colors.textMutedDark}
/>
</TouchableOpacity>
))}
{addonCatalogs.map(catalog => {
const [addonId, type, catalogId] = catalog.id.split(':');
const displayName = getCustomName(addonId, type, catalogId, catalog.name);
return (
<TouchableOpacity
key={catalog.id}
style={[
styles.catalogItem,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }
]}
onPress={() => toggleCatalog(catalog.id)}
>
<View style={styles.catalogInfo}>
<Text style={[styles.catalogName, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
{displayName}
</Text>
<Text style={[styles.catalogType, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
{catalog.type === 'movie' ? 'Movies' : 'TV Shows'}
</Text>
</View>
<MaterialIcons
name={selectedCatalogs.includes(catalog.id) ? "check-box" : "check-box-outline-blank"}
size={24}
color={selectedCatalogs.includes(catalog.id) ? colors.primary : isDarkMode ? colors.mediumEmphasis : colors.textMutedDark}
/>
</TouchableOpacity>
);
})}
</View>
</View>
))}

View file

@ -258,7 +258,7 @@ const MetadataScreen = () => {
// Fetch logo immediately for TMDB content
useEffect(() => {
if (metadata && id.startsWith('tmdb:')) {
if (metadata && id.startsWith('tmdb:') && !metadata.logo) {
const fetchLogo = async () => {
try {
const tmdbId = id.split(':')[1];

View file

@ -3,6 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import axios from 'axios';
import { TMDBService } from './tmdbService';
import { logger } from '../utils/logger';
import { getCatalogDisplayName } from '../utils/catalogNameUtils';
export interface StreamingAddon {
id: string;
@ -55,6 +56,8 @@ export interface CatalogContent {
items: StreamingContent[];
}
const CATALOG_SETTINGS_KEY = 'catalog_settings';
class CatalogService {
private static instance: CatalogService;
private readonly LIBRARY_KEY = 'stremio-library';
@ -137,43 +140,37 @@ class CatalogService {
const addons = await this.getAllAddons();
const catalogs: CatalogContent[] = [];
// Get saved catalog settings
const savedSettings = await AsyncStorage.getItem('catalog_settings');
const catalogSettings: { [key: string]: boolean } = savedSettings ? JSON.parse(savedSettings) : {};
// Load enabled/disabled settings
const catalogSettingsJson = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY);
const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
// Get featured catalogs
for (const addon of addons) {
if (addon.catalogs && addon.catalogs.length > 0) {
// For each catalog, check if it's enabled in settings
if (addon.catalogs) {
for (const catalog of addon.catalogs) {
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
// If setting doesn't exist, default to true for backward compatibility
const isEnabled = catalogSettings[settingKey] ?? true;
if (isEnabled) {
try {
// Get the items for this catalog
const addonManifest = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifest.find(a => a.id === addon.id);
if (!manifest) continue;
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
if (metas && metas.length > 0) {
// Convert Meta to StreamingContent
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
// Format the catalog name
let displayName = catalog.name;
// Get potentially custom display name
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
// Remove duplicate words and clean up the name (case-insensitive)
const words = displayName.split(' ');
const uniqueWords = [];
const seenWords = new Set();
for (const word of words) {
const lowerWord = word.toLowerCase();
if (!seenWords.has(lowerWord)) {
uniqueWords.push(word); // Keep original case
uniqueWords.push(word);
seenWords.add(lowerWord);
}
}
@ -208,7 +205,6 @@ class CatalogService {
const addons = await this.getAllAddons();
const catalogs: CatalogContent[] = [];
// Filter addons with catalogs of the specified type
const typeAddons = addons.filter(addon =>
addon.catalogs && addon.catalogs.some(catalog => catalog.type === type)
);
@ -222,18 +218,20 @@ class CatalogService {
const manifest = addonManifest.find(a => a.id === addon.id);
if (!manifest) continue;
// Apply genre filter if provided
const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (metas && metas.length > 0) {
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
// Get potentially custom display name
const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
catalogs.push({
addon: addon.id,
type,
id: catalog.id,
name: catalog.name,
name: displayName,
genre: genreFilter,
items
});

View file

@ -20,6 +20,9 @@ export class MDBListService {
private static instance: MDBListService;
private apiKey: string | null = null;
private enabled: boolean = true;
private apiKeyErrorCount: number = 0; // Add counter for API key errors
private lastApiKeyErrorTime: number = 0; // To track when last error occurred
private ratingsCache: Map<string, MDBListRatings | null> = new Map(); // Cache for ratings - null values represent known "not found" results
private constructor() {
logger.log('[MDBListService] Service initialized');
@ -36,16 +39,32 @@ export class MDBListService {
try {
// First check if MDBList is enabled
const enabledSetting = await AsyncStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY);
const wasEnabled = this.enabled;
this.enabled = enabledSetting === null || enabledSetting === 'true';
logger.log('[MDBListService] MDBList enabled:', this.enabled);
// Clear cache if enabled state changed
if (wasEnabled !== this.enabled) {
this.clearCache();
logger.log('[MDBListService] Cache cleared due to enabled state change');
}
if (!this.enabled) {
logger.log('[MDBListService] MDBList is disabled, skipping API key loading');
this.apiKey = null;
return;
}
this.apiKey = await AsyncStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY);
const newApiKey = await AsyncStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY);
// Reset error counter when API key changes
if (newApiKey !== this.apiKey) {
this.apiKeyErrorCount = 0;
this.lastApiKeyErrorTime = 0;
// Clear the cache when API key changes
this.clearCache();
logger.log('[MDBListService] Cache cleared due to API key change');
}
this.apiKey = newApiKey;
logger.log('[MDBListService] Initialized with API key:', this.apiKey ? 'Present' : 'Not found');
} catch (error) {
logger.error('[MDBListService] Failed to load settings:', error);
@ -57,6 +76,17 @@ export class MDBListService {
async getRatings(imdbId: string, mediaType: 'movie' | 'show'): Promise<MDBListRatings | null> {
logger.log(`[MDBListService] Fetching ratings for ${mediaType} with IMDB ID:`, imdbId);
// Create cache key
const cacheKey = `${mediaType}:${imdbId}`;
// Check cache first - including null values which mean "no ratings available"
if (this.ratingsCache.has(cacheKey)) {
const cachedRatings = this.ratingsCache.get(cacheKey);
logger.log(`[MDBListService] Retrieved ${cachedRatings ? 'ratings' : 'negative result'} from cache for ${mediaType}:`, imdbId);
// TypeScript knows cachedRatings can't be undefined here since we checked with .has()
return cachedRatings as MDBListRatings | null;
}
// Check if MDBList is enabled before doing anything else
if (!this.enabled) {
// Try to refresh enabled status in case it was changed
@ -139,7 +169,13 @@ export class MDBListService {
try {
const errorJson = JSON.parse(errorText);
if (errorJson.error === "Invalid API key") {
logger.error('[MDBListService] API Key rejected by server:', this.apiKey);
// Only log the error every 5 requests or if more than 10 minutes have passed
const now = Date.now();
this.apiKeyErrorCount++;
if (this.apiKeyErrorCount === 1 || this.apiKeyErrorCount % 5 === 0 || now - this.lastApiKeyErrorTime > 600000) {
logger.error('[MDBListService] API Key rejected by server:', this.apiKey);
this.lastApiKeyErrorTime = now;
}
} else {
logger.warn(`[MDBListService] 403 Forbidden, but not invalid key error:`, errorJson);
}
@ -171,12 +207,37 @@ export class MDBListService {
const ratingCount = Object.keys(ratings).length;
logger.log(`[MDBListService] Fetched ${ratingCount} ratings successfully:`, ratings);
return ratingCount > 0 ? ratings : null;
// Store in cache even if we got no ratings - this prevents repeated API calls for content with no ratings
const result = ratingCount > 0 ? ratings : null;
this.ratingsCache.set(cacheKey, result);
logger.log(`[MDBListService] Stored ${result ? 'ratings' : 'negative result'} in cache for ${mediaType}:`, imdbId);
return result;
} catch (error) {
logger.error('[MDBListService] Error fetching MDBList ratings:', error);
return null;
}
}
// Method to clear the cache
clearCache(): void {
this.ratingsCache.clear();
logger.log('[MDBListService] Cache cleared');
}
// Method to invalidate a specific cache entry
invalidateCache(imdbId: string, mediaType: 'movie' | 'show'): void {
const cacheKey = `${mediaType}:${imdbId}`;
const hadEntry = this.ratingsCache.delete(cacheKey);
logger.log(`[MDBListService] Cache entry ${hadEntry ? 'invalidated' : 'not found'} for ${mediaType}:`, imdbId);
}
// Method to check if a rating is in cache
isCached(imdbId: string, mediaType: 'movie' | 'show'): boolean {
const cacheKey = `${mediaType}:${imdbId}`;
return this.ratingsCache.has(cacheKey);
}
}
export const mdblistService = MDBListService.getInstance();

View file

@ -0,0 +1,46 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { logger } from './logger';
const CATALOG_CUSTOM_NAMES_KEY = 'catalog_custom_names';
// Initialize cache as an empty object
let customNamesCache: { [key: string]: string } = {};
let cacheTimestamp: number = 0; // 0 indicates cache is invalid/empty initially
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
async function loadCustomNamesIfNeeded(): Promise<{ [key: string]: string }> {
const now = Date.now();
// Check if cache is valid based on timestamp
if (cacheTimestamp > 0 && (now - cacheTimestamp < CACHE_DURATION)) {
return customNamesCache; // Cache is valid and guaranteed to be an object
}
try {
logger.info('Loading custom catalog names from storage...');
const savedCustomNamesJson = await AsyncStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
// Assign parsed object or empty object if null/error
customNamesCache = savedCustomNamesJson ? JSON.parse(savedCustomNamesJson) : {};
cacheTimestamp = now; // Set timestamp only on successful load
return customNamesCache;
} catch (error) {
logger.error('Failed to load custom catalog names in utility:', error);
// Invalidate cache timestamp on error
cacheTimestamp = 0;
// Return the last known cache (which might be empty {}), or a fresh empty object
return customNamesCache || {}; // Return cache (could be outdated but non-null) or empty {}
}
}
export async function getCatalogDisplayName(addonId: string, type: string, catalogId: string, originalName: string): Promise<string> {
// Ensure cache is loaded/refreshed before getting name
const customNames = await loadCustomNamesIfNeeded();
const key = `${addonId}:${type}:${catalogId}`;
return customNames[key] || originalName;
}
// Function to clear the cache if settings are updated elsewhere
export function clearCustomNameCache() {
customNamesCache = {}; // Reset to empty object
cacheTimestamp = 0; // Invalidate timestamp
logger.info('Custom catalog name cache cleared.');
}