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() {
|
export function useCollections() {
|
||||||
const [collections, setCollections] = useState<Collection[]>([]);
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
|
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const loadCollections = useCallback(async () => {
|
const loadCollections = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await collectionsService.getCollections();
|
const [data, settings] = await Promise.all([
|
||||||
|
collectionsService.getCollections(),
|
||||||
|
collectionsService.getCollectionSettings(),
|
||||||
|
]);
|
||||||
setCollections(data);
|
setCollections(data);
|
||||||
|
setEnabledMap(settings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (__DEV__) console.error('[useCollections] Error loading:', error);
|
if (__DEV__) console.error('[useCollections] Error loading:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -31,5 +36,5 @@ export function useCollections() {
|
||||||
};
|
};
|
||||||
}, [loadCollections]);
|
}, [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 CollectionManagementScreen from '../screens/CollectionManagementScreen';
|
||||||
import CollectionEditorScreen from '../screens/CollectionEditorScreen';
|
import CollectionEditorScreen from '../screens/CollectionEditorScreen';
|
||||||
import FolderDetailScreen from '../screens/FolderDetailScreen';
|
import FolderDetailScreen from '../screens/FolderDetailScreen';
|
||||||
|
import CatalogOrderScreen from '../screens/CatalogOrderScreen';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ContentDiscoverySettingsScreen,
|
ContentDiscoverySettingsScreen,
|
||||||
|
|
@ -230,6 +231,7 @@ export type RootStackParamList = {
|
||||||
Collections: undefined;
|
Collections: undefined;
|
||||||
CollectionEditor: { collectionId?: string };
|
CollectionEditor: { collectionId?: string };
|
||||||
FolderDetail: { collectionId: string; folderId: string };
|
FolderDetail: { collectionId: string; folderId: string };
|
||||||
|
CatalogOrder: undefined;
|
||||||
|
|
||||||
// New organized settings screens
|
// New organized settings screens
|
||||||
ContentDiscoverySettings: undefined;
|
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>
|
</Stack.Navigator>
|
||||||
</View>
|
</View>
|
||||||
</PaperProvider>
|
</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) => {
|
{editingFolder.catalogSources.map((source, idx) => {
|
||||||
const isMissing = !installedAddonIds.has(source.addonId);
|
const isMissing = !installedAddonIds.has(source.addonId);
|
||||||
|
const catalogInfo = availableCatalogs.find(
|
||||||
|
c => c.addonId === source.addonId && c.type === source.type && c.catalogId === source.catalogId
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
key={`${source.addonId}-${source.type}-${source.catalogId}`}
|
key={`${source.addonId}-${source.type}-${source.catalogId}`}
|
||||||
|
|
@ -420,7 +423,7 @@ const CollectionEditorScreen = () => {
|
||||||
>
|
>
|
||||||
<View style={styles.catalogSourceInfo}>
|
<View style={styles.catalogSourceInfo}>
|
||||||
<Text style={[styles.catalogSourceName, { color: isMissing ? colors.error : colors.text }]} numberOfLines={1}>
|
<Text style={[styles.catalogSourceName, { color: isMissing ? colors.error : colors.text }]} numberOfLines={1}>
|
||||||
{source.catalogId}
|
{catalogInfo?.name || source.catalogId}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.catalogSourceMeta, { color: isMissing ? colors.error : colors.textMuted }]}>
|
<Text style={[styles.catalogSourceMeta, { color: isMissing ? colors.error : colors.textMuted }]}>
|
||||||
{isMissing ? `Addon not installed: ${source.addonId}` : `${source.addonId} · ${source.type}`}
|
{isMissing ? `Addon not installed: ${source.addonId}` : `${source.addonId} · ${source.type}`}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
|
Switch,
|
||||||
Platform,
|
Platform,
|
||||||
Alert,
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
@ -36,13 +37,25 @@ const CollectionManagementScreen = () => {
|
||||||
const [alertTitle, setAlertTitle] = useState('');
|
const [alertTitle, setAlertTitle] = useState('');
|
||||||
const [alertMessage, setAlertMessage] = useState('');
|
const [alertMessage, setAlertMessage] = useState('');
|
||||||
const [alertActions, setAlertActions] = useState<any[]>([]);
|
const [alertActions, setAlertActions] = useState<any[]>([]);
|
||||||
|
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const loadEnabledState = useCallback(async () => {
|
||||||
|
const settings = await collectionsService.getCollectionSettings();
|
||||||
|
setEnabledMap(settings);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
refresh();
|
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(() => {
|
const handleNewCollection = useCallback(() => {
|
||||||
navigation.navigate('CollectionEditor' as any, {});
|
navigation.navigate('CollectionEditor' as any, {});
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
@ -151,45 +164,62 @@ const CollectionManagementScreen = () => {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{collections.map((collection, index) => (
|
{collections.map((collection, index) => {
|
||||||
<View
|
const isEnabled = enabledMap[collection.id] !== false;
|
||||||
key={collection.id}
|
return (
|
||||||
style={[styles.collectionCard, { backgroundColor: colors.elevation1, borderColor: colors.border }]}
|
<View
|
||||||
>
|
key={collection.id}
|
||||||
<View style={styles.cardHeader}>
|
style={[
|
||||||
<View style={styles.cardTitleRow}>
|
styles.collectionCard,
|
||||||
<Text style={[styles.collectionTitle, { color: colors.text }]} numberOfLines={1}>
|
{
|
||||||
{collection.title}
|
backgroundColor: colors.elevation1,
|
||||||
</Text>
|
borderColor: isEnabled ? colors.border : colors.border + '60',
|
||||||
<Text style={[styles.folderCount, { color: colors.textMuted }]}>
|
opacity: isEnabled ? 1 : 0.6,
|
||||||
{collection.folders.length} folder{collection.folders.length !== 1 ? 's' : ''}
|
},
|
||||||
</Text>
|
]}
|
||||||
</View>
|
>
|
||||||
<View style={styles.cardActions}>
|
<View style={styles.cardHeader}>
|
||||||
<TouchableOpacity
|
<View style={styles.cardTitleRow}>
|
||||||
onPress={() => handleMoveUp(collection.id)}
|
<Text style={[styles.collectionTitle, { color: colors.text }]} numberOfLines={1}>
|
||||||
disabled={index === 0}
|
{collection.title}
|
||||||
style={[styles.iconButton, index === 0 && styles.disabledButton]}
|
</Text>
|
||||||
>
|
<Text style={[styles.folderCount, { color: colors.textMuted }]}>
|
||||||
<MaterialIcons name="arrow-upward" size={20} color={index === 0 ? colors.disabled : colors.text} />
|
{collection.folders.length} folder{collection.folders.length !== 1 ? 's' : ''}{!isEnabled ? ' · Hidden' : ''}
|
||||||
</TouchableOpacity>
|
</Text>
|
||||||
<TouchableOpacity
|
</View>
|
||||||
onPress={() => handleMoveDown(collection.id)}
|
<View style={styles.cardActions}>
|
||||||
disabled={index === collections.length - 1}
|
<Switch
|
||||||
style={[styles.iconButton, index === collections.length - 1 && styles.disabledButton]}
|
value={isEnabled}
|
||||||
>
|
onValueChange={(value) => handleToggleEnabled(collection.id, value)}
|
||||||
<MaterialIcons name="arrow-downward" size={20} color={index === collections.length - 1 ? colors.disabled : colors.text} />
|
trackColor={{ false: colors.elevation3, true: colors.primary + '80' }}
|
||||||
</TouchableOpacity>
|
thumbColor={isEnabled ? colors.primary : colors.textMuted}
|
||||||
<TouchableOpacity onPress={() => handleEdit(collection.id)} style={styles.iconButton}>
|
style={styles.switch}
|
||||||
<MaterialIcons name="edit" size={20} color={colors.primary} />
|
/>
|
||||||
</TouchableOpacity>
|
<TouchableOpacity
|
||||||
<TouchableOpacity onPress={() => handleDelete(collection)} style={styles.iconButton}>
|
onPress={() => handleMoveUp(collection.id)}
|
||||||
<MaterialIcons name="delete-outline" size={20} color={colors.error} />
|
disabled={index === 0}
|
||||||
</TouchableOpacity>
|
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>
|
</View>
|
||||||
</View>
|
);
|
||||||
))}
|
})}
|
||||||
|
|
||||||
{collections.length === 0 && (
|
{collections.length === 0 && (
|
||||||
<View style={styles.importOnlyRow}>
|
<View style={styles.importOnlyRow}>
|
||||||
|
|
@ -302,6 +332,9 @@ const styles = StyleSheet.create({
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
},
|
},
|
||||||
|
switch: {
|
||||||
|
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
|
||||||
|
},
|
||||||
iconButton: {
|
iconButton: {
|
||||||
padding: 6,
|
padding: 6,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,18 @@ const FolderDetailScreen = () => {
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
setFolder(f);
|
setFolder(f);
|
||||||
|
|
||||||
// Build tab labels
|
// Build tab labels — resolve human-readable names from addon manifests
|
||||||
const labels = f.catalogSources.map(source => ({
|
const addons = await stremioService.getInstalledAddonsAsync();
|
||||||
name: source.catalogId,
|
const labels = f.catalogSources.map(source => {
|
||||||
type: source.type,
|
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);
|
setTabLabels(labels);
|
||||||
|
|
||||||
// Load first tab
|
// Load first tab
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ import { useScrollToTop } from '../contexts/ScrollToTopContext';
|
||||||
import CollectionRowSection from '../components/home/CollectionRowSection';
|
import CollectionRowSection from '../components/home/CollectionRowSection';
|
||||||
import { useCollections } from '../hooks/useCollections';
|
import { useCollections } from '../hooks/useCollections';
|
||||||
import { collectionsEmitter, COLLECTIONS_EVENTS } from '../services/collectionsService';
|
import { collectionsEmitter, COLLECTIONS_EVENTS } from '../services/collectionsService';
|
||||||
|
import { catalogOrderService, catalogOrderEmitter, CATALOG_ORDER_EVENTS, CatalogOrderService } from '../services/catalogOrderService';
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
||||||
|
|
@ -147,7 +148,8 @@ const HomeScreen = () => {
|
||||||
const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes
|
const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes
|
||||||
const { showInfo } = useToast();
|
const { showInfo } = useToast();
|
||||||
const { setTrailerPlaying } = useTrailer();
|
const { setTrailerPlaying } = useTrailer();
|
||||||
const { collections } = useCollections();
|
const { collections, enabledMap: collectionEnabledMap } = useCollections();
|
||||||
|
const [savedOrder, setSavedOrder] = useState<string[]>([]);
|
||||||
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
|
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
|
||||||
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
|
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
|
||||||
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
@ -196,6 +198,14 @@ const HomeScreen = () => {
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [insets.top]);
|
}, [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 {
|
const {
|
||||||
featuredContent,
|
featuredContent,
|
||||||
allFeaturedContent,
|
allFeaturedContent,
|
||||||
|
|
@ -763,26 +773,74 @@ const HomeScreen = () => {
|
||||||
// Only show a limited number of catalogs initially for performance
|
// Only show a limited number of catalogs initially for performance
|
||||||
const catalogsToShow = catalogs.slice(0, visibleCatalogCount);
|
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) {
|
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) => {
|
catalogsToShow.forEach((catalog, index) => {
|
||||||
if (catalog) {
|
if (catalog) {
|
||||||
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
|
catalogMap.set(CatalogOrderService.catalogKey(catalog.addon, catalog.type, catalog.id), { catalog, index });
|
||||||
} else if (catalogsLoading && pendingCatalogIndexes[index]) {
|
|
||||||
data.push({ type: 'placeholder', key: `placeholder-${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
|
// Add a "Load More" button if there are more catalogs to show
|
||||||
if (catalogs.length > visibleCatalogCount && catalogs.filter(c => c).length > visibleCatalogCount) {
|
if (catalogs.length > visibleCatalogCount && catalogs.filter(c => c).length > visibleCatalogCount) {
|
||||||
data.push({ type: 'loadMore', key: 'load-more' });
|
data.push({ type: 'loadMore', key: 'load-more' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}, [hasAddons, catalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection, collections]);
|
}, [hasAddons, catalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection, collections, collectionEnabledMap, savedOrder]);
|
||||||
|
|
||||||
const handleLoadMoreCatalogs = useCallback(() => {
|
const handleLoadMoreCatalogs = useCallback(() => {
|
||||||
setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length));
|
setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length));
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,14 @@ export const ContentDiscoverySettingsContent: React.FC<ContentDiscoverySettingsC
|
||||||
onPress={() => navigation.navigate('Collections')}
|
onPress={() => navigation.navigate('Collections')}
|
||||||
isTablet={isTablet}
|
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') && (
|
{isItemVisible('home_screen') && (
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title={t('settings.items.home_screen')}
|
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';
|
import EventEmitter from 'eventemitter3';
|
||||||
|
|
||||||
const COLLECTIONS_STORAGE_KEY = 'collections';
|
const COLLECTIONS_STORAGE_KEY = 'collections';
|
||||||
|
const COLLECTION_SETTINGS_KEY = 'collection_settings';
|
||||||
|
|
||||||
export const collectionsEmitter = new EventEmitter();
|
export const collectionsEmitter = new EventEmitter();
|
||||||
export const COLLECTIONS_EVENTS = {
|
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 {
|
generateId(): string {
|
||||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue