mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +00:00
Add catalog/collection reorder, enable/disable toggle, and fix catalog name display
- Add CatalogOrderScreen for reordering catalogs and collections on home screen - Add catalogOrderService for persisting display order in MMKV - Add enable/disable toggle for collections in CollectionManagementScreen - Fix catalog name display in FolderDetailScreen tabs and CollectionEditorScreen - Filter disabled collections from reorder screen and home screen - Integrate display order into HomeScreen listData memo Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
611a6b49e9
commit
2262889479
10 changed files with 637 additions and 53 deletions
|
|
@ -4,12 +4,17 @@ import { collectionsService, collectionsEmitter, COLLECTIONS_EVENTS } from '../s
|
|||
|
||||
export function useCollections() {
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadCollections = useCallback(async () => {
|
||||
try {
|
||||
const data = await collectionsService.getCollections();
|
||||
const [data, settings] = await Promise.all([
|
||||
collectionsService.getCollections(),
|
||||
collectionsService.getCollectionSettings(),
|
||||
]);
|
||||
setCollections(data);
|
||||
setEnabledMap(settings);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('[useCollections] Error loading:', error);
|
||||
} finally {
|
||||
|
|
@ -31,5 +36,5 @@ export function useCollections() {
|
|||
};
|
||||
}, [loadCollections]);
|
||||
|
||||
return { collections, loading, refresh: loadCollections };
|
||||
return { collections, enabledMap, loading, refresh: loadCollections };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ import ContributorsScreen from '../screens/ContributorsScreen';
|
|||
import CollectionManagementScreen from '../screens/CollectionManagementScreen';
|
||||
import CollectionEditorScreen from '../screens/CollectionEditorScreen';
|
||||
import FolderDetailScreen from '../screens/FolderDetailScreen';
|
||||
import CatalogOrderScreen from '../screens/CatalogOrderScreen';
|
||||
|
||||
import {
|
||||
ContentDiscoverySettingsScreen,
|
||||
|
|
@ -230,6 +231,7 @@ export type RootStackParamList = {
|
|||
Collections: undefined;
|
||||
CollectionEditor: { collectionId?: string };
|
||||
FolderDetail: { collectionId: string; folderId: string };
|
||||
CatalogOrder: undefined;
|
||||
|
||||
// New organized settings screens
|
||||
ContentDiscoverySettings: undefined;
|
||||
|
|
@ -1939,6 +1941,18 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CatalogOrder"
|
||||
component={CatalogOrderScreen as any}
|
||||
options={{
|
||||
animation: 'slide_from_right',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</View>
|
||||
</PaperProvider>
|
||||
|
|
|
|||
350
src/screens/CatalogOrderScreen.tsx
Normal file
350
src/screens/CatalogOrderScreen.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { useNavigation, useFocusEffect, NavigationProp } from '@react-navigation/native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useCollections } from '../hooks/useCollections';
|
||||
import { stremioService } from '../services/stremioService';
|
||||
import { mmkvStorage } from '../services/mmkvStorage';
|
||||
import { catalogOrderService, CatalogOrderService } from '../services/catalogOrderService';
|
||||
import ScreenHeader from '../components/common/ScreenHeader';
|
||||
import LoadingSpinner from '../components/common/LoadingSpinner';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
|
||||
interface OrderItem {
|
||||
key: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
isCollection: boolean;
|
||||
}
|
||||
|
||||
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
||||
|
||||
const CatalogOrderScreen = () => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const colors = currentTheme.colors;
|
||||
const { collections, enabledMap: collectionEnabledMap } = useCollections();
|
||||
|
||||
const [orderedKeys, setOrderedKeys] = useState<string[]>([]);
|
||||
const [catalogItems, setCatalogItems] = useState<OrderItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Build collection items from hook
|
||||
const collectionItems = useMemo(() => {
|
||||
const items: OrderItem[] = [];
|
||||
for (const collection of collections) {
|
||||
if (collectionEnabledMap[collection.id] === false) continue;
|
||||
items.push({
|
||||
key: CatalogOrderService.collectionKey(collection.id),
|
||||
label: collection.title,
|
||||
subtitle: `${collection.folders.length} folder${collection.folders.length !== 1 ? 's' : ''}`,
|
||||
isCollection: true,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [collections]);
|
||||
|
||||
// Build the full item map
|
||||
const itemMap = useMemo(() => {
|
||||
const map = new Map<string, OrderItem>();
|
||||
for (const item of collectionItems) map.set(item.key, item);
|
||||
for (const item of catalogItems) map.set(item.key, item);
|
||||
return map;
|
||||
}, [collectionItems, catalogItems]);
|
||||
|
||||
// Load catalog metadata from installed addons (same filtering as HomeScreen)
|
||||
const loadCatalogs = useCallback(async () => {
|
||||
try {
|
||||
const addons = await stremioService.getInstalledAddonsAsync();
|
||||
const savedSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY);
|
||||
const catalogSettings: Record<string, boolean> = savedSettingsJson ? JSON.parse(savedSettingsJson) : {};
|
||||
|
||||
const items: OrderItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const addon of addons) {
|
||||
if (!addon.catalogs) continue;
|
||||
const addonUsesShowInHome = addon.catalogs.some((c: any) => c.showInHome === true);
|
||||
|
||||
for (const catalog of addon.catalogs) {
|
||||
// Skip search catalogs
|
||||
if (
|
||||
(catalog.id && catalog.id.startsWith('search.')) ||
|
||||
(catalog.type && catalog.type.startsWith('search'))
|
||||
) continue;
|
||||
|
||||
// Skip catalogs with required extras
|
||||
const requiredExtras = (catalog.extra || []).filter((e: any) => e.isRequired);
|
||||
if (requiredExtras.length > 0) continue;
|
||||
|
||||
// Respect showInHome flag
|
||||
if (addonUsesShowInHome && !(catalog as any).showInHome) continue;
|
||||
|
||||
// Respect user enable/disable setting
|
||||
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
|
||||
const isEnabled = catalogSettings[settingKey] ?? true;
|
||||
if (!isEnabled) continue;
|
||||
|
||||
// Deduplicate
|
||||
const key = CatalogOrderService.catalogKey(addon.id, catalog.type, catalog.id);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
items.push({
|
||||
key,
|
||||
label: catalog.name || catalog.id,
|
||||
subtitle: `${addon.name || addon.id} · ${catalog.type}`,
|
||||
isCollection: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setCatalogItems(items);
|
||||
} catch {
|
||||
// silent fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load everything on focus
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
loadCatalogs(),
|
||||
catalogOrderService.getOrder(),
|
||||
]).then(([_, savedOrder]) => {
|
||||
// itemMap might not be updated yet, so we'll set keys and let the
|
||||
// ordered list rebuild via the items memo below
|
||||
setOrderedKeys(savedOrder);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [loadCatalogs])
|
||||
);
|
||||
|
||||
// Merge saved order with available items
|
||||
const items = useMemo(() => {
|
||||
const allItems = [...collectionItems, ...catalogItems];
|
||||
const allMap = new Map<string, OrderItem>();
|
||||
for (const item of allItems) allMap.set(item.key, item);
|
||||
|
||||
if (orderedKeys.length === 0) return allItems;
|
||||
|
||||
const result: OrderItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// First: items in saved order
|
||||
for (const key of orderedKeys) {
|
||||
const item = allMap.get(key);
|
||||
if (item && !seen.has(key)) {
|
||||
result.push(item);
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
// Then: any new items not in saved order
|
||||
for (const item of allItems) {
|
||||
if (!seen.has(item.key)) {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [orderedKeys, collectionItems, catalogItems]);
|
||||
|
||||
const handleMoveUp = useCallback((index: number) => {
|
||||
if (index <= 0) return;
|
||||
const currentKeys = items.map(i => i.key);
|
||||
[currentKeys[index - 1], currentKeys[index]] = [currentKeys[index], currentKeys[index - 1]];
|
||||
setOrderedKeys(currentKeys);
|
||||
catalogOrderService.saveOrder(currentKeys);
|
||||
}, [items]);
|
||||
|
||||
const handleMoveDown = useCallback((index: number) => {
|
||||
if (index >= items.length - 1) return;
|
||||
const currentKeys = items.map(i => i.key);
|
||||
[currentKeys[index], currentKeys[index + 1]] = [currentKeys[index + 1], currentKeys[index]];
|
||||
setOrderedKeys(currentKeys);
|
||||
catalogOrderService.saveOrder(currentKeys);
|
||||
}, [items]);
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
await catalogOrderService.resetOrder();
|
||||
setOrderedKeys([]);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.darkBackground }]}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
<ScreenHeader title="Display Order" showBackButton onBackPress={() => navigation.goBack()} />
|
||||
<View style={styles.loadingContainer}>
|
||||
<LoadingSpinner size="large" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.darkBackground }]}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
<ScreenHeader
|
||||
title="Display Order"
|
||||
showBackButton
|
||||
onBackPress={() => navigation.goBack()}
|
||||
/>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<TouchableOpacity
|
||||
style={[styles.resetButton, { backgroundColor: colors.elevation3 }]}
|
||||
activeOpacity={0.7}
|
||||
onPress={handleReset}
|
||||
>
|
||||
<MaterialIcons name="refresh" size={18} color={colors.text} />
|
||||
<Text style={[styles.resetButtonText, { color: colors.text }]}>Reset to Default</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{items.length === 0 && (
|
||||
<View style={styles.emptyContainer}>
|
||||
<MaterialIcons name="reorder" size={48} color={colors.textMuted} />
|
||||
<Text style={[styles.emptyText, { color: colors.textMuted }]}>
|
||||
No catalogs or collections to reorder
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{items.map((item, index) => (
|
||||
<View
|
||||
key={item.key}
|
||||
style={[
|
||||
styles.itemCard,
|
||||
{
|
||||
backgroundColor: colors.elevation1,
|
||||
borderColor: item.isCollection ? colors.primary + '40' : colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.itemInfo}>
|
||||
<MaterialIcons
|
||||
name={item.isCollection ? 'folder' : 'view-list'}
|
||||
size={20}
|
||||
color={item.isCollection ? colors.primary : colors.textMuted}
|
||||
/>
|
||||
<View style={styles.itemTextContainer}>
|
||||
<Text style={[styles.itemLabel, { color: colors.text }]} numberOfLines={1}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text style={[styles.itemSubtitle, { color: colors.textMuted }]} numberOfLines={1}>
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.itemActions}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
style={[styles.iconButton, index === 0 && styles.disabledButton]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="arrow-upward"
|
||||
size={20}
|
||||
color={index === 0 ? colors.disabled : colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleMoveDown(index)}
|
||||
disabled={index === items.length - 1}
|
||||
style={[styles.iconButton, index === items.length - 1 && styles.disabledButton]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="arrow-downward"
|
||||
size={20}
|
||||
color={index === items.length - 1 ? colors.disabled : colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
resetButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
marginBottom: 16,
|
||||
gap: 6,
|
||||
},
|
||||
resetButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
gap: 12,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 15,
|
||||
},
|
||||
itemCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
marginBottom: 8,
|
||||
},
|
||||
itemInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
gap: 10,
|
||||
},
|
||||
itemTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
itemLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
itemSubtitle: {
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
itemActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 2,
|
||||
},
|
||||
iconButton: {
|
||||
padding: 6,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
});
|
||||
|
||||
export default CatalogOrderScreen;
|
||||
|
|
@ -407,6 +407,9 @@ const CollectionEditorScreen = () => {
|
|||
|
||||
{editingFolder.catalogSources.map((source, idx) => {
|
||||
const isMissing = !installedAddonIds.has(source.addonId);
|
||||
const catalogInfo = availableCatalogs.find(
|
||||
c => c.addonId === source.addonId && c.type === source.type && c.catalogId === source.catalogId
|
||||
);
|
||||
return (
|
||||
<View
|
||||
key={`${source.addonId}-${source.type}-${source.catalogId}`}
|
||||
|
|
@ -420,7 +423,7 @@ const CollectionEditorScreen = () => {
|
|||
>
|
||||
<View style={styles.catalogSourceInfo}>
|
||||
<Text style={[styles.catalogSourceName, { color: isMissing ? colors.error : colors.text }]} numberOfLines={1}>
|
||||
{source.catalogId}
|
||||
{catalogInfo?.name || source.catalogId}
|
||||
</Text>
|
||||
<Text style={[styles.catalogSourceMeta, { color: isMissing ? colors.error : colors.textMuted }]}>
|
||||
{isMissing ? `Addon not installed: ${source.addonId}` : `${source.addonId} · ${source.type}`}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
TouchableOpacity,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Switch,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
|
|
@ -36,13 +37,25 @@ const CollectionManagementScreen = () => {
|
|||
const [alertTitle, setAlertTitle] = useState('');
|
||||
const [alertMessage, setAlertMessage] = useState('');
|
||||
const [alertActions, setAlertActions] = useState<any[]>([]);
|
||||
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
const loadEnabledState = useCallback(async () => {
|
||||
const settings = await collectionsService.getCollectionSettings();
|
||||
setEnabledMap(settings);
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refresh();
|
||||
}, [refresh])
|
||||
loadEnabledState();
|
||||
}, [refresh, loadEnabledState])
|
||||
);
|
||||
|
||||
const handleToggleEnabled = useCallback(async (id: string, value: boolean) => {
|
||||
setEnabledMap(prev => ({ ...prev, [id]: value }));
|
||||
await collectionsService.setCollectionEnabled(id, value);
|
||||
}, []);
|
||||
|
||||
const handleNewCollection = useCallback(() => {
|
||||
navigation.navigate('CollectionEditor' as any, {});
|
||||
}, [navigation]);
|
||||
|
|
@ -151,45 +164,62 @@ const CollectionManagementScreen = () => {
|
|||
</View>
|
||||
)}
|
||||
|
||||
{collections.map((collection, index) => (
|
||||
<View
|
||||
key={collection.id}
|
||||
style={[styles.collectionCard, { backgroundColor: colors.elevation1, borderColor: colors.border }]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardTitleRow}>
|
||||
<Text style={[styles.collectionTitle, { color: colors.text }]} numberOfLines={1}>
|
||||
{collection.title}
|
||||
</Text>
|
||||
<Text style={[styles.folderCount, { color: colors.textMuted }]}>
|
||||
{collection.folders.length} folder{collection.folders.length !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.cardActions}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleMoveUp(collection.id)}
|
||||
disabled={index === 0}
|
||||
style={[styles.iconButton, index === 0 && styles.disabledButton]}
|
||||
>
|
||||
<MaterialIcons name="arrow-upward" size={20} color={index === 0 ? colors.disabled : colors.text} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleMoveDown(collection.id)}
|
||||
disabled={index === collections.length - 1}
|
||||
style={[styles.iconButton, index === collections.length - 1 && styles.disabledButton]}
|
||||
>
|
||||
<MaterialIcons name="arrow-downward" size={20} color={index === collections.length - 1 ? colors.disabled : colors.text} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleEdit(collection.id)} style={styles.iconButton}>
|
||||
<MaterialIcons name="edit" size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleDelete(collection)} style={styles.iconButton}>
|
||||
<MaterialIcons name="delete-outline" size={20} color={colors.error} />
|
||||
</TouchableOpacity>
|
||||
{collections.map((collection, index) => {
|
||||
const isEnabled = enabledMap[collection.id] !== false;
|
||||
return (
|
||||
<View
|
||||
key={collection.id}
|
||||
style={[
|
||||
styles.collectionCard,
|
||||
{
|
||||
backgroundColor: colors.elevation1,
|
||||
borderColor: isEnabled ? colors.border : colors.border + '60',
|
||||
opacity: isEnabled ? 1 : 0.6,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardTitleRow}>
|
||||
<Text style={[styles.collectionTitle, { color: colors.text }]} numberOfLines={1}>
|
||||
{collection.title}
|
||||
</Text>
|
||||
<Text style={[styles.folderCount, { color: colors.textMuted }]}>
|
||||
{collection.folders.length} folder{collection.folders.length !== 1 ? 's' : ''}{!isEnabled ? ' · Hidden' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.cardActions}>
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={(value) => handleToggleEnabled(collection.id, value)}
|
||||
trackColor={{ false: colors.elevation3, true: colors.primary + '80' }}
|
||||
thumbColor={isEnabled ? colors.primary : colors.textMuted}
|
||||
style={styles.switch}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleMoveUp(collection.id)}
|
||||
disabled={index === 0}
|
||||
style={[styles.iconButton, index === 0 && styles.disabledButton]}
|
||||
>
|
||||
<MaterialIcons name="arrow-upward" size={20} color={index === 0 ? colors.disabled : colors.text} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleMoveDown(collection.id)}
|
||||
disabled={index === collections.length - 1}
|
||||
style={[styles.iconButton, index === collections.length - 1 && styles.disabledButton]}
|
||||
>
|
||||
<MaterialIcons name="arrow-downward" size={20} color={index === collections.length - 1 ? colors.disabled : colors.text} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleEdit(collection.id)} style={styles.iconButton}>
|
||||
<MaterialIcons name="edit" size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleDelete(collection)} style={styles.iconButton}>
|
||||
<MaterialIcons name="delete-outline" size={20} color={colors.error} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{collections.length === 0 && (
|
||||
<View style={styles.importOnlyRow}>
|
||||
|
|
@ -302,6 +332,9 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
switch: {
|
||||
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
|
||||
},
|
||||
iconButton: {
|
||||
padding: 6,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -55,11 +55,18 @@ const FolderDetailScreen = () => {
|
|||
if (!f) return;
|
||||
setFolder(f);
|
||||
|
||||
// Build tab labels
|
||||
const labels = f.catalogSources.map(source => ({
|
||||
name: source.catalogId,
|
||||
type: source.type,
|
||||
}));
|
||||
// Build tab labels — resolve human-readable names from addon manifests
|
||||
const addons = await stremioService.getInstalledAddonsAsync();
|
||||
const labels = f.catalogSources.map(source => {
|
||||
const addon = addons.find((a: any) => a.id === source.addonId);
|
||||
const catalog = addon?.catalogs?.find(
|
||||
(c: any) => c.id === source.catalogId && c.type === source.type
|
||||
);
|
||||
return {
|
||||
name: catalog?.name || source.catalogId,
|
||||
type: source.type,
|
||||
};
|
||||
});
|
||||
setTabLabels(labels);
|
||||
|
||||
// Load first tab
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ import { useScrollToTop } from '../contexts/ScrollToTopContext';
|
|||
import CollectionRowSection from '../components/home/CollectionRowSection';
|
||||
import { useCollections } from '../hooks/useCollections';
|
||||
import { collectionsEmitter, COLLECTIONS_EVENTS } from '../services/collectionsService';
|
||||
import { catalogOrderService, catalogOrderEmitter, CATALOG_ORDER_EVENTS, CatalogOrderService } from '../services/catalogOrderService';
|
||||
|
||||
// Constants
|
||||
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
||||
|
|
@ -147,7 +148,8 @@ const HomeScreen = () => {
|
|||
const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes
|
||||
const { showInfo } = useToast();
|
||||
const { setTrailerPlaying } = useTrailer();
|
||||
const { collections } = useCollections();
|
||||
const { collections, enabledMap: collectionEnabledMap } = useCollections();
|
||||
const [savedOrder, setSavedOrder] = useState<string[]>([]);
|
||||
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
|
||||
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
|
||||
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
|
@ -196,6 +198,14 @@ const HomeScreen = () => {
|
|||
return () => clearTimeout(timer);
|
||||
}, [insets.top]);
|
||||
|
||||
// Load saved catalog/collection display order
|
||||
useEffect(() => {
|
||||
catalogOrderService.getOrder().then(setSavedOrder);
|
||||
const handler = () => catalogOrderService.getOrder().then(setSavedOrder);
|
||||
catalogOrderEmitter.on(CATALOG_ORDER_EVENTS.CHANGED, handler);
|
||||
return () => { catalogOrderEmitter.off(CATALOG_ORDER_EVENTS.CHANGED, handler); };
|
||||
}, []);
|
||||
|
||||
const {
|
||||
featuredContent,
|
||||
allFeaturedContent,
|
||||
|
|
@ -763,26 +773,74 @@ const HomeScreen = () => {
|
|||
// Only show a limited number of catalogs initially for performance
|
||||
const catalogsToShow = catalogs.slice(0, visibleCatalogCount);
|
||||
|
||||
// Show collections at the top, before catalog rows
|
||||
// Build maps of available items keyed for ordering (filter out disabled collections)
|
||||
const collectionMap = new Map<string, import('../types/collections').Collection>();
|
||||
for (const collection of collections) {
|
||||
data.push({ type: 'collection', collection, key: `collection-${collection.id}` });
|
||||
if (collectionEnabledMap[collection.id] === false) continue;
|
||||
collectionMap.set(CatalogOrderService.collectionKey(collection.id), collection);
|
||||
}
|
||||
|
||||
const catalogMap = new Map<string, { catalog: CatalogContent; index: number }>();
|
||||
catalogsToShow.forEach((catalog, index) => {
|
||||
if (catalog) {
|
||||
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
|
||||
} else if (catalogsLoading && pendingCatalogIndexes[index]) {
|
||||
data.push({ type: 'placeholder', key: `placeholder-${index}` });
|
||||
catalogMap.set(CatalogOrderService.catalogKey(catalog.addon, catalog.type, catalog.id), { catalog, index });
|
||||
}
|
||||
});
|
||||
|
||||
// Apply saved order if available
|
||||
if (savedOrder.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
for (const key of savedOrder) {
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (collectionMap.has(key)) {
|
||||
const col = collectionMap.get(key)!;
|
||||
data.push({ type: 'collection', collection: col, key: `collection-${col.id}` });
|
||||
} else if (catalogMap.has(key)) {
|
||||
const { catalog, index } = catalogMap.get(key)!;
|
||||
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
|
||||
}
|
||||
}
|
||||
// Append any items not in the saved order
|
||||
for (const [key, col] of collectionMap) {
|
||||
if (!seen.has(key)) {
|
||||
data.push({ type: 'collection', collection: col, key: `collection-${col.id}` });
|
||||
}
|
||||
}
|
||||
for (const [key, { catalog, index }] of catalogMap) {
|
||||
if (!seen.has(key)) {
|
||||
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Default order: enabled collections first, then catalogs
|
||||
for (const collection of collections) {
|
||||
if (collectionEnabledMap[collection.id] === false) continue;
|
||||
data.push({ type: 'collection', collection, key: `collection-${collection.id}` });
|
||||
}
|
||||
catalogsToShow.forEach((catalog, index) => {
|
||||
if (catalog) {
|
||||
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show placeholders for loading catalogs (not part of ordering)
|
||||
if (catalogsLoading) {
|
||||
catalogsToShow.forEach((catalog, index) => {
|
||||
if (!catalog && pendingCatalogIndexes[index]) {
|
||||
data.push({ type: 'placeholder', key: `placeholder-${index}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add a "Load More" button if there are more catalogs to show
|
||||
if (catalogs.length > visibleCatalogCount && catalogs.filter(c => c).length > visibleCatalogCount) {
|
||||
data.push({ type: 'loadMore', key: 'load-more' });
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [hasAddons, catalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection, collections]);
|
||||
}, [hasAddons, catalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection, collections, collectionEnabledMap, savedOrder]);
|
||||
|
||||
const handleLoadMoreCatalogs = useCallback(() => {
|
||||
setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length));
|
||||
|
|
|
|||
|
|
@ -128,6 +128,14 @@ export const ContentDiscoverySettingsContent: React.FC<ContentDiscoverySettingsC
|
|||
onPress={() => navigation.navigate('Collections')}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Display Order"
|
||||
description="Reorder catalogs and collections on home"
|
||||
icon="menu"
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => navigation.navigate('CatalogOrder')}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
{isItemVisible('home_screen') && (
|
||||
<SettingItem
|
||||
title={t('settings.items.home_screen')}
|
||||
|
|
|
|||
72
src/services/catalogOrderService.ts
Normal file
72
src/services/catalogOrderService.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { mmkvStorage } from './mmkvStorage';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
const ORDER_STORAGE_KEY = 'catalog_display_order';
|
||||
|
||||
export const catalogOrderEmitter = new EventEmitter();
|
||||
export const CATALOG_ORDER_EVENTS = {
|
||||
CHANGED: 'catalog_order_changed',
|
||||
} as const;
|
||||
|
||||
export class CatalogOrderService {
|
||||
private static instance: CatalogOrderService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): CatalogOrderService {
|
||||
if (!CatalogOrderService.instance) {
|
||||
CatalogOrderService.instance = new CatalogOrderService();
|
||||
}
|
||||
return CatalogOrderService.instance;
|
||||
}
|
||||
|
||||
private async getStorageKey(): Promise<string> {
|
||||
const scope = await mmkvStorage.getItem('@user:current') || 'local';
|
||||
return `@user:${scope}:${ORDER_STORAGE_KEY}`;
|
||||
}
|
||||
|
||||
async getOrder(): Promise<string[]> {
|
||||
try {
|
||||
const key = await this.getStorageKey();
|
||||
const json = await mmkvStorage.getItem(key);
|
||||
if (!json) return [];
|
||||
const parsed = JSON.parse(json);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async saveOrder(order: string[]): Promise<void> {
|
||||
try {
|
||||
const key = await this.getStorageKey();
|
||||
await mmkvStorage.setItem(key, JSON.stringify(order));
|
||||
catalogOrderEmitter.emit(CATALOG_ORDER_EVENTS.CHANGED);
|
||||
} catch {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
async resetOrder(): Promise<void> {
|
||||
try {
|
||||
const key = await this.getStorageKey();
|
||||
await mmkvStorage.setItem(key, JSON.stringify([]));
|
||||
catalogOrderEmitter.emit(CATALOG_ORDER_EVENTS.CHANGED);
|
||||
} catch {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a catalog key from addon/type/id */
|
||||
static catalogKey(addonId: string, type: string, catalogId: string): string {
|
||||
return `${addonId}:${type}:${catalogId}`;
|
||||
}
|
||||
|
||||
/** Build a collection key from collection id */
|
||||
static collectionKey(collectionId: string): string {
|
||||
return `collection_${collectionId}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const catalogOrderService = CatalogOrderService.getInstance();
|
||||
|
|
@ -4,6 +4,7 @@ import { logger } from '../utils/logger';
|
|||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
const COLLECTIONS_STORAGE_KEY = 'collections';
|
||||
const COLLECTION_SETTINGS_KEY = 'collection_settings';
|
||||
|
||||
export const collectionsEmitter = new EventEmitter();
|
||||
export const COLLECTIONS_EVENTS = {
|
||||
|
|
@ -120,6 +121,39 @@ class CollectionsService {
|
|||
}
|
||||
}
|
||||
|
||||
private async getSettingsKey(): Promise<string> {
|
||||
const scope = await mmkvStorage.getItem('@user:current') || 'local';
|
||||
return `@user:${scope}:${COLLECTION_SETTINGS_KEY}`;
|
||||
}
|
||||
|
||||
async getCollectionSettings(): Promise<Record<string, boolean>> {
|
||||
try {
|
||||
const key = await this.getSettingsKey();
|
||||
const json = await mmkvStorage.getItem(key);
|
||||
if (!json) return {};
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async isCollectionEnabled(id: string): Promise<boolean> {
|
||||
const settings = await this.getCollectionSettings();
|
||||
return settings[id] !== false; // default enabled
|
||||
}
|
||||
|
||||
async setCollectionEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
try {
|
||||
const settings = await this.getCollectionSettings();
|
||||
settings[id] = enabled;
|
||||
const key = await this.getSettingsKey();
|
||||
await mmkvStorage.setItem(key, JSON.stringify(settings));
|
||||
collectionsEmitter.emit(COLLECTIONS_EVENTS.CHANGED);
|
||||
} catch {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue