Add Collections feature for organizing catalogs into folders

Collections let users group addon catalogs into named folders that appear
as horizontal rows on the home screen. Each folder supports emoji or image
URL covers, tile shape selection (poster/wide/square), and multiple catalog
sources with tabbed browsing in the detail view. Includes emoji picker with
12 categories and all country flags, export/import via clipboard (compatible
with NuvioTV format), and missing addon warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Amarjit Singh 2026-03-21 16:14:03 -07:00
parent d4977c2fb0
commit c6216cb95d
10 changed files with 1944 additions and 4 deletions

View file

@ -0,0 +1,173 @@
import React, { useCallback } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, FlatList, Dimensions } from 'react-native';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import FastImage from '@d11/react-native-fast-image';
import { useTheme } from '../../contexts/ThemeContext';
import { Collection, CollectionFolder } from '../../types/collections';
import { RootStackParamList } from '../../navigation/AppNavigator';
interface CollectionRowSectionProps {
collection: Collection;
}
const { width: screenWidth } = Dimensions.get('window');
const TILE_SPACING = 10;
const LEFT_PADDING = 16;
const getTileSize = (shape: 'poster' | 'wide' | 'square') => {
switch (shape) {
case 'poster':
return { width: 120, aspectRatio: 2 / 3 };
case 'wide':
return { width: 200, aspectRatio: 16 / 9 };
case 'square':
return { width: 140, aspectRatio: 1 };
}
};
const FolderTile = React.memo(({ folder, collectionId }: { folder: CollectionFolder; collectionId: string }) => {
const { currentTheme } = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const tileSize = getTileSize(folder.tileShape);
const handlePress = useCallback(() => {
navigation.navigate('FolderDetail', { collectionId, folderId: folder.id });
}, [navigation, collectionId, folder.id]);
const renderCover = () => {
if (folder.coverImageUrl) {
return (
<FastImage
source={{ uri: folder.coverImageUrl, priority: FastImage.priority.normal }}
style={[styles.tileImage, { borderRadius: 12 }]}
resizeMode={FastImage.resizeMode.cover}
/>
);
}
if (folder.coverEmoji) {
return (
<View style={[styles.emojiCover, { backgroundColor: currentTheme.colors.elevation3 }]}>
<Text style={styles.emojiText}>{folder.coverEmoji}</Text>
</View>
);
}
// Initials fallback
const initials = folder.title
.split(' ')
.slice(0, 2)
.map(w => w[0]?.toUpperCase() || '')
.join('');
return (
<View style={[styles.initialsCover, { backgroundColor: currentTheme.colors.elevation3 }]}>
<Text style={[styles.initialsText, { color: currentTheme.colors.text }]}>{initials}</Text>
</View>
);
};
return (
<TouchableOpacity
activeOpacity={0.7}
onPress={handlePress}
style={[styles.tileContainer, { width: tileSize.width }]}
>
<View style={[styles.tileImageContainer, { aspectRatio: tileSize.aspectRatio, borderRadius: 12 }]}>
{renderCover()}
</View>
{!folder.hideTitle && (
<Text
numberOfLines={1}
style={[styles.tileTitle, { color: currentTheme.colors.text }]}
>
{folder.title}
</Text>
)}
</TouchableOpacity>
);
});
const CollectionRowSection = React.memo(({ collection }: CollectionRowSectionProps) => {
const { currentTheme } = useTheme();
const renderFolder = useCallback(({ item }: { item: CollectionFolder }) => (
<FolderTile folder={item} collectionId={collection.id} />
), [collection.id]);
const keyExtractor = useCallback((item: CollectionFolder) => item.id, []);
if (!collection.folders.length) return null;
return (
<View style={styles.container}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>
{collection.title}
</Text>
<FlatList
data={collection.folders}
renderItem={renderFolder}
keyExtractor={keyExtractor}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.listContent}
ItemSeparatorComponent={() => <View style={{ width: TILE_SPACING }} />}
/>
</View>
);
});
const styles = StyleSheet.create({
container: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
paddingHorizontal: LEFT_PADDING,
marginBottom: 12,
},
listContent: {
paddingHorizontal: LEFT_PADDING,
},
tileContainer: {
marginRight: 2,
},
tileImageContainer: {
width: '100%',
overflow: 'hidden',
borderRadius: 12,
},
tileImage: {
width: '100%',
height: '100%',
},
emojiCover: {
width: '100%',
height: '100%',
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
emojiText: {
fontSize: 48,
},
initialsCover: {
width: '100%',
height: '100%',
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
initialsText: {
fontSize: 28,
fontWeight: '700',
},
tileTitle: {
fontSize: 13,
fontWeight: '500',
marginTop: 6,
},
});
export default CollectionRowSection;

View file

@ -0,0 +1,35 @@
import { useState, useEffect, useCallback } from 'react';
import { Collection } from '../types/collections';
import { collectionsService, collectionsEmitter, COLLECTIONS_EVENTS } from '../services/collectionsService';
export function useCollections() {
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
const loadCollections = useCallback(async () => {
try {
const data = await collectionsService.getCollections();
setCollections(data);
} catch (error) {
if (__DEV__) console.error('[useCollections] Error loading:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadCollections();
}, [loadCollections]);
useEffect(() => {
const handler = () => {
loadCollections();
};
collectionsEmitter.on(COLLECTIONS_EVENTS.CHANGED, handler);
return () => {
collectionsEmitter.off(COLLECTIONS_EVENTS.CHANGED, handler);
};
}, [loadCollections]);
return { collections, loading, refresh: loadCollections };
}

View file

@ -77,6 +77,9 @@ import BackdropGalleryScreen from '../screens/BackdropGalleryScreen';
import BackupScreen from '../screens/BackupScreen';
import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen';
import ContributorsScreen from '../screens/ContributorsScreen';
import CollectionManagementScreen from '../screens/CollectionManagementScreen';
import CollectionEditorScreen from '../screens/CollectionEditorScreen';
import FolderDetailScreen from '../screens/FolderDetailScreen';
import {
ContentDiscoverySettingsScreen,
@ -224,6 +227,9 @@ export type RootStackParamList = {
};
ContinueWatchingSettings: undefined;
Contributors: undefined;
Collections: undefined;
CollectionEditor: { collectionId?: string };
FolderDetail: { collectionId: string; folderId: string };
// New organized settings screens
ContentDiscoverySettings: undefined;
@ -1897,6 +1903,42 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
component={SyncSettingsScreen}
options={{ headerShown: false }}
/>
<Stack.Screen
name="Collections"
component={CollectionManagementScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="CollectionEditor"
component={CollectionEditorScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="FolderDetail"
component={FolderDetailScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
</Stack.Navigator>
</View>
</PaperProvider>

View file

@ -0,0 +1,907 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
TextInput,
StatusBar,
Platform,
Modal,
FlatList,
Dimensions,
} from 'react-native';
import { useNavigation, useRoute, NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image';
import { useTheme } from '../contexts/ThemeContext';
import { useToast } from '../contexts/ToastContext';
import { Collection, CollectionFolder, CollectionCatalogSource } from '../types/collections';
import { collectionsService } from '../services/collectionsService';
import { stremioService } from '../services/stremioService';
import ScreenHeader from '../components/common/ScreenHeader';
import CustomAlert from '../components/CustomAlert';
import { RootStackParamList } from '../navigation/AppNavigator';
const { width: screenWidth } = Dimensions.get('window');
// ─── Emoji Data ───────────────────────────────────────────
const EMOJI_CATEGORIES: { name: string; emojis: string[] }[] = [
{
name: 'Streaming',
emojis: ['🎬', '🎥', '📺', '🎞️', '📽️', '🎭', '🎪', '🍿', '📡', '🔴', '▶️', '⏯️', '🎙️', '📻', '📹', '🖥️', '💿', '📀', '🎧', '🎤'],
},
{
name: 'Genres',
emojis: ['💀', '👻', '🧟', '🧛', '🦇', '🔪', '💣', '🔫', '🚀', '👽', '🤖', '🧙', '🧚', '🐉', '🦸', '🦹', '💕', '💋', '😂', '😈', '🎯', '🔥', '⚡', '🌪️', '🌊', '🏆', '🎲', '🃏'],
},
{
name: 'Sports',
emojis: ['⚽', '🏀', '🏈', '⚾', '🎾', '🏐', '🏉', '🎱', '🏓', '🏸', '🏒', '⛳', '🏹', '🥊', '🥋', '⛷️', '🏂', '🏄', '🚴', '🏊', '🤸', '🏋️', '🤺', '🏇'],
},
{
name: 'Music',
emojis: ['🎵', '🎶', '🎸', '🎹', '🥁', '🎺', '🎻', '🪕', '🎷', '🎼', '🎤', '🎧', '📯', '🪗', '🪘', '🪇'],
},
{
name: 'Nature',
emojis: ['🌍', '🌎', '🌏', '🌋', '🏔️', '🏕️', '🏖️', '🏜️', '🌅', '🌄', '🌠', '🌌', '🌈', '☀️', '🌙', '⭐', '🌸', '🌺', '🌻', '🌲', '🌴', '🍂', '❄️', '🔥'],
},
{
name: 'Animals',
emojis: ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🐔', '🐧', '🐦', '🦅', '🦈', '🐙', '🦋', '🐢', '🐍', '🦎', '🐊'],
},
{
name: 'Food',
emojis: ['🍕', '🍔', '🌮', '🍣', '🍜', '🍱', '🍩', '🍪', '🎂', '🍰', '🧁', '🍫', '🍬', '🍿', '☕', '🍵', '🍷', '🍺', '🧋', '🥤', '🍹', '🧃'],
},
{
name: 'Travel',
emojis: ['✈️', '🚀', '🚂', '🚢', '🏰', '🗼', '🗽', '🏛️', '⛩️', '🕌', '🕍', '⛪', '🏠', '🏢', '🏙️', '🌃', '🌉', '🗺️', '🧭', '⛺'],
},
{
name: 'People',
emojis: ['👶', '👦', '👧', '👨', '👩', '👴', '👵', '👮', '🕵️', '👷', '👸', '🤴', '🧑‍🚀', '🧑‍🔬', '🧑‍🎨', '🧑‍🍳', '🧑‍✈️', '🧑‍🚒', '🧑‍⚕️', '🧑‍🏫', '🧑‍💻', '🤹', '💃', '🕺'],
},
{
name: 'Objects',
emojis: ['📱', '💻', '⌨️', '🖨️', '📷', '📸', '🔭', '🔬', '💡', '🔦', '🕯️', '📚', '📖', '📝', '✏️', '🖊️', '📌', '📎', '🔑', '🗝️', '🔒', '💎', '👑', '🏅'],
},
{
name: 'Flags',
emojis: [
'🏁', '🏳️‍🌈', '🏴‍☠️',
'🇦🇫','🇦🇱','🇩🇿','🇦🇸','🇦🇩','🇦🇴','🇦🇬','🇦🇷','🇦🇲','🇦🇺','🇦🇹','🇦🇿',
'🇧🇸','🇧🇭','🇧🇩','🇧🇧','🇧🇾','🇧🇪','🇧🇿','🇧🇯','🇧🇹','🇧🇴','🇧🇦','🇧🇼','🇧🇷','🇧🇳','🇧🇬','🇧🇫','🇧🇮',
'🇨🇻','🇰🇭','🇨🇲','🇨🇦','🇨🇫','🇹🇩','🇨🇱','🇨🇳','🇨🇴','🇰🇲','🇨🇬','🇨🇩','🇨🇷','🇭🇷','🇨🇺','🇨🇾','🇨🇿',
'🇩🇰','🇩🇯','🇩🇲','🇩🇴','🇪🇨','🇪🇬','🇸🇻','🇬🇶','🇪🇷','🇪🇪','🇸🇿','🇪🇹',
'🇫🇯','🇫🇮','🇫🇷','🇬🇦','🇬🇲','🇬🇪','🇩🇪','🇬🇭','🇬🇷','🇬🇩','🇬🇹','🇬🇳','🇬🇼','🇬🇾',
'🇭🇹','🇭🇳','🇭🇺','🇮🇸','🇮🇳','🇮🇩','🇮🇷','🇮🇶','🇮🇪','🇮🇱','🇮🇹','🇨🇮',
'🇯🇲','🇯🇵','🇯🇴','🇰🇿','🇰🇪','🇰🇮','🇰🇵','🇰🇷','🇰🇼','🇰🇬',
'🇱🇦','🇱🇻','🇱🇧','🇱🇸','🇱🇷','🇱🇾','🇱🇮','🇱🇹','🇱🇺',
'🇲🇬','🇲🇼','🇲🇾','🇲🇻','🇲🇱','🇲🇹','🇲🇭','🇲🇷','🇲🇺','🇲🇽','🇫🇲','🇲🇩','🇲🇨','🇲🇳','🇲🇪','🇲🇦','🇲🇿','🇲🇲',
'🇳🇦','🇳🇷','🇳🇵','🇳🇱','🇳🇿','🇳🇮','🇳🇪','🇳🇬','🇲🇰','🇳🇴',
'🇴🇲','🇵🇰','🇵🇼','🇵🇸','🇵🇦','🇵🇬','🇵🇾','🇵🇪','🇵🇭','🇵🇱','🇵🇹',
'🇶🇦','🇷🇴','🇷🇺','🇷🇼',
'🇰🇳','🇱🇨','🇻🇨','🇼🇸','🇸🇲','🇸🇹','🇸🇦','🇸🇳','🇷🇸','🇸🇨','🇸🇱','🇸🇬','🇸🇰','🇸🇮','🇸🇧','🇸🇴','🇿🇦','🇸🇸','🇪🇸','🇱🇰','🇸🇩','🇸🇷','🇸🇪','🇨🇭','🇸🇾',
'🇹🇼','🇹🇯','🇹🇿','🇹🇭','🇹🇱','🇹🇬','🇹🇴','🇹🇹','🇹🇳','🇹🇷','🇹🇲','🇹🇻',
'🇺🇬','🇺🇦','🇦🇪','🇬🇧','🇺🇸','🇺🇾','🇺🇿',
'🇻🇺','🇻🇪','🇻🇳',
'🇾🇪','🇿🇲','🇿🇼',
],
},
{
name: 'Symbols',
emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '💯', '✅', '❌', '⭕', '🔴', '🟠', '🟡', '🟢', '🔵', '🟣', '⚫', '⚪', '♠️', '♥️', '♦️', '♣️', '🔔', '🔕', '💤', '🏳️'],
},
];
// ─── Main Screen ──────────────────────────────────────────
const CollectionEditorScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const route = useRoute();
const { currentTheme } = useTheme();
const { showInfo, showError } = useToast();
const colors = currentTheme.colors;
const collectionId = (route.params as any)?.collectionId as string | undefined;
const isNew = !collectionId;
const [title, setTitle] = useState('');
const [folders, setFolders] = useState<CollectionFolder[]>([]);
const [editingFolder, setEditingFolder] = useState<CollectionFolder | null>(null);
const [editingFolderIndex, setEditingFolderIndex] = useState<number>(-1);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [showCatalogPicker, setShowCatalogPicker] = useState(false);
// Catalog picker state
const [availableCatalogs, setAvailableCatalogs] = useState<{ addonId: string; addonName: string; type: string; catalogId: string; name: string }[]>([]);
const [installedAddonIds, setInstalledAddonIds] = useState<Set<string>>(new Set());
useEffect(() => {
if (collectionId) {
loadCollection();
}
loadAvailableCatalogs();
}, [collectionId]);
const loadCollection = async () => {
const collections = await collectionsService.getCollections();
const found = collections.find(c => c.id === collectionId);
if (found) {
setTitle(found.title);
setFolders(found.folders);
}
};
const loadAvailableCatalogs = async () => {
try {
const addons = await stremioService.getInstalledAddonsAsync();
const addonIds = new Set(addons.map((a: any) => a.id));
setInstalledAddonIds(addonIds);
const catalogs: typeof availableCatalogs = [];
for (const addon of addons) {
if (addon.catalogs) {
for (const catalog of addon.catalogs) {
catalogs.push({
addonId: addon.id,
addonName: addon.name,
type: catalog.type,
catalogId: catalog.id,
name: catalog.name || catalog.id,
});
}
}
}
setAvailableCatalogs(catalogs);
} catch (error) {
if (__DEV__) console.error('Error loading catalogs:', error);
}
};
const handleSave = useCallback(async () => {
const trimmedTitle = title.trim();
if (!trimmedTitle) {
showError('Please enter a collection title');
return;
}
if (folders.length === 0) {
showError('Add at least one folder');
return;
}
const collection: Collection = {
id: collectionId || collectionsService.generateId(),
title: trimmedTitle,
folders,
};
if (isNew) {
await collectionsService.addCollection(collection);
} else {
await collectionsService.updateCollection(collection);
}
navigation.goBack();
}, [title, folders, collectionId, isNew, navigation, showError]);
const handleAddFolder = useCallback(() => {
const newFolder: CollectionFolder = {
id: collectionsService.generateId(),
title: '',
tileShape: 'poster',
hideTitle: false,
catalogSources: [],
};
setEditingFolder(newFolder);
setEditingFolderIndex(-1);
}, []);
const handleEditFolder = useCallback((index: number) => {
setEditingFolder({ ...folders[index] });
setEditingFolderIndex(index);
}, [folders]);
const handleDeleteFolder = useCallback((index: number) => {
setFolders(prev => prev.filter((_, i) => i !== index));
}, []);
const handleMoveFolderUp = useCallback((index: number) => {
if (index === 0) return;
setFolders(prev => {
const arr = [...prev];
[arr[index - 1], arr[index]] = [arr[index], arr[index - 1]];
return arr;
});
}, []);
const handleMoveFolderDown = useCallback((index: number) => {
setFolders(prev => {
if (index >= prev.length - 1) return prev;
const arr = [...prev];
[arr[index], arr[index + 1]] = [arr[index + 1], arr[index]];
return arr;
});
}, []);
const handleSaveFolder = useCallback(() => {
if (!editingFolder) return;
if (!editingFolder.title.trim()) {
showError('Please enter a folder title');
return;
}
if (editingFolder.catalogSources.length === 0) {
showError('Add at least one catalog');
return;
}
setFolders(prev => {
if (editingFolderIndex >= 0) {
const arr = [...prev];
arr[editingFolderIndex] = editingFolder;
return arr;
}
return [...prev, editingFolder];
});
setEditingFolder(null);
setEditingFolderIndex(-1);
}, [editingFolder, editingFolderIndex, showError]);
const handleSelectEmoji = useCallback((emoji: string) => {
if (editingFolder) {
setEditingFolder({ ...editingFolder, coverEmoji: emoji, coverImageUrl: undefined });
}
setShowEmojiPicker(false);
}, [editingFolder]);
const handleToggleCatalogSource = useCallback((source: CollectionCatalogSource) => {
if (!editingFolder) return;
const existing = editingFolder.catalogSources.find(
s => s.addonId === source.addonId && s.type === source.type && s.catalogId === source.catalogId
);
if (existing) {
setEditingFolder({
...editingFolder,
catalogSources: editingFolder.catalogSources.filter(
s => !(s.addonId === source.addonId && s.type === source.type && s.catalogId === source.catalogId)
),
});
} else {
setEditingFolder({
...editingFolder,
catalogSources: [...editingFolder.catalogSources, source],
});
}
}, [editingFolder]);
// ─── Folder Editor View ──────────────────────────
if (editingFolder) {
const missingAddons = editingFolder.catalogSources.filter(s => !installedAddonIds.has(s.addonId));
return (
<View style={[styles.container, { backgroundColor: colors.darkBackground }]}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<ScreenHeader
title={editingFolderIndex >= 0 ? 'Edit Folder' : 'New Folder'}
showBackButton
onBackPress={() => { setEditingFolder(null); setEditingFolderIndex(-1); }}
/>
<ScrollView contentContainerStyle={styles.scrollContent}>
{/* Folder Title */}
<Text style={[styles.label, { color: colors.textMuted }]}>Folder Title</Text>
<TextInput
style={[styles.textInput, { backgroundColor: colors.elevation1, color: colors.text, borderColor: colors.border }]}
value={editingFolder.title}
onChangeText={text => setEditingFolder({ ...editingFolder, title: text })}
placeholder="Enter folder title"
placeholderTextColor={colors.disabled}
autoFocus
/>
{/* Cover */}
<Text style={[styles.label, { color: colors.textMuted }]}>Cover</Text>
<View style={styles.coverRow}>
<TouchableOpacity
style={[styles.coverOption, { backgroundColor: colors.elevation1, borderColor: editingFolder.coverEmoji ? colors.primary : colors.border }]}
onPress={() => setShowEmojiPicker(true)}
>
{editingFolder.coverEmoji ? (
<Text style={styles.coverEmojiPreview}>{editingFolder.coverEmoji}</Text>
) : (
<MaterialIcons name="emoji-emotions" size={28} color={colors.textMuted} />
)}
<Text style={[styles.coverOptionLabel, { color: colors.text }]}>Emoji</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.coverOption, { backgroundColor: colors.elevation1, borderColor: editingFolder.coverImageUrl ? colors.primary : colors.border }]}
onPress={() => {
setEditingFolder({
...editingFolder,
coverImageUrl: editingFolder.coverImageUrl || '',
coverEmoji: undefined,
});
}}
>
{editingFolder.coverImageUrl ? (
<FastImage
source={{ uri: editingFolder.coverImageUrl }}
style={styles.coverImagePreview}
resizeMode={FastImage.resizeMode.cover}
/>
) : (
<MaterialIcons name="image" size={28} color={colors.textMuted} />
)}
<Text style={[styles.coverOptionLabel, { color: colors.text }]}>Image URL</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.coverOption, { backgroundColor: colors.elevation1, borderColor: !editingFolder.coverEmoji && !editingFolder.coverImageUrl ? colors.primary : colors.border }]}
onPress={() => setEditingFolder({ ...editingFolder, coverEmoji: undefined, coverImageUrl: undefined })}
>
<MaterialIcons name="block" size={28} color={colors.textMuted} />
<Text style={[styles.coverOptionLabel, { color: colors.text }]}>None</Text>
</TouchableOpacity>
</View>
{/* Image URL input */}
{editingFolder.coverImageUrl !== undefined && (
<TextInput
style={[styles.textInput, { backgroundColor: colors.elevation1, color: colors.text, borderColor: colors.border, marginTop: 8 }]}
value={editingFolder.coverImageUrl}
onChangeText={text => setEditingFolder({ ...editingFolder, coverImageUrl: text })}
placeholder="Paste image URL"
placeholderTextColor={colors.disabled}
autoCapitalize="none"
autoCorrect={false}
/>
)}
{/* Tile Shape */}
<Text style={[styles.label, { color: colors.textMuted }]}>Tile Shape</Text>
<View style={styles.shapeRow}>
{(['poster', 'wide', 'square'] as const).map(shape => (
<TouchableOpacity
key={shape}
style={[
styles.shapeButton,
{
backgroundColor: editingFolder.tileShape === shape ? colors.primary : colors.elevation1,
borderColor: editingFolder.tileShape === shape ? colors.primary : colors.border,
},
]}
onPress={() => setEditingFolder({ ...editingFolder, tileShape: shape })}
>
<Text style={[styles.shapeButtonText, { color: editingFolder.tileShape === shape ? '#fff' : colors.text }]}>
{shape.charAt(0).toUpperCase() + shape.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
{/* Hide Title */}
<TouchableOpacity
style={[styles.toggleRow, { backgroundColor: colors.elevation1, borderColor: colors.border }]}
onPress={() => setEditingFolder({ ...editingFolder, hideTitle: !editingFolder.hideTitle })}
>
<Text style={[styles.toggleLabel, { color: colors.text }]}>Hide Title</Text>
<View style={[styles.toggleSwitch, { backgroundColor: editingFolder.hideTitle ? colors.primary : colors.disabled }]}>
<View style={[styles.toggleThumb, editingFolder.hideTitle && styles.toggleThumbActive]} />
</View>
</TouchableOpacity>
{/* Catalog Sources */}
<View style={styles.catalogsHeader}>
<Text style={[styles.label, { color: colors.textMuted }]}>Catalogs</Text>
<TouchableOpacity onPress={() => setShowCatalogPicker(true)}>
<MaterialIcons name="add-circle" size={24} color={colors.primary} />
</TouchableOpacity>
</View>
{editingFolder.catalogSources.length === 0 && (
<Text style={[styles.noCatalogs, { color: colors.disabled }]}>
No catalogs added. Tap + to add catalogs from installed addons.
</Text>
)}
{editingFolder.catalogSources.map((source, idx) => {
const isMissing = !installedAddonIds.has(source.addonId);
return (
<View
key={`${source.addonId}-${source.type}-${source.catalogId}`}
style={[
styles.catalogSourceRow,
{
backgroundColor: colors.elevation1,
borderColor: isMissing ? colors.error : colors.border,
},
]}
>
<View style={styles.catalogSourceInfo}>
<Text style={[styles.catalogSourceName, { color: isMissing ? colors.error : colors.text }]} numberOfLines={1}>
{source.catalogId}
</Text>
<Text style={[styles.catalogSourceMeta, { color: isMissing ? colors.error : colors.textMuted }]}>
{isMissing ? `Addon not installed: ${source.addonId}` : `${source.addonId} · ${source.type}`}
</Text>
</View>
<TouchableOpacity
onPress={() => handleToggleCatalogSource(source)}
style={styles.removeCatalogButton}
>
<MaterialIcons name="close" size={20} color={colors.error} />
</TouchableOpacity>
</View>
);
})}
{/* Save/Cancel Buttons */}
<View style={styles.folderActions}>
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: colors.primary }]}
onPress={handleSaveFolder}
>
<Text style={styles.saveButtonText}>Save Folder</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.cancelButton, { borderColor: colors.border }]}
onPress={() => { setEditingFolder(null); setEditingFolderIndex(-1); }}
>
<Text style={[styles.cancelButtonText, { color: colors.text }]}>Cancel</Text>
</TouchableOpacity>
</View>
</ScrollView>
{/* Emoji Picker Modal */}
<Modal visible={showEmojiPicker} animationType="slide" transparent>
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.6)' }]}>
<View style={[styles.modalContent, { backgroundColor: colors.darkBackground }]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: colors.text }]}>Select Emoji</Text>
<TouchableOpacity onPress={() => setShowEmojiPicker(false)}>
<MaterialIcons name="close" size={24} color={colors.text} />
</TouchableOpacity>
</View>
<ScrollView>
{EMOJI_CATEGORIES.map(category => (
<View key={category.name} style={styles.emojiCategory}>
<Text style={[styles.emojiCategoryName, { color: colors.textMuted }]}>{category.name}</Text>
<View style={styles.emojiGrid}>
{category.emojis.map((emoji, idx) => (
<TouchableOpacity
key={`${category.name}-${idx}`}
style={[
styles.emojiButton,
editingFolder?.coverEmoji === emoji && { backgroundColor: colors.primary + '40', borderColor: colors.primary, borderWidth: 2 },
]}
onPress={() => handleSelectEmoji(emoji)}
>
<Text style={styles.emojiButtonText}>{emoji}</Text>
</TouchableOpacity>
))}
</View>
</View>
))}
</ScrollView>
</View>
</View>
</Modal>
{/* Catalog Picker Modal */}
<Modal visible={showCatalogPicker} animationType="slide" transparent>
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.6)' }]}>
<View style={[styles.modalContent, { backgroundColor: colors.darkBackground }]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: colors.text }]}>Select Catalogs</Text>
<TouchableOpacity onPress={() => setShowCatalogPicker(false)}>
<Text style={[styles.doneText, { color: colors.primary }]}>Done</Text>
</TouchableOpacity>
</View>
<FlatList
data={availableCatalogs}
keyExtractor={(item, idx) => `${item.addonId}-${item.type}-${item.catalogId}-${idx}`}
renderItem={({ item }) => {
const isSelected = editingFolder?.catalogSources.some(
s => s.addonId === item.addonId && s.type === item.type && s.catalogId === item.catalogId
);
return (
<TouchableOpacity
style={[
styles.catalogPickerItem,
{
backgroundColor: isSelected ? colors.primary + '20' : colors.elevation1,
borderColor: isSelected ? colors.primary : colors.border,
},
]}
onPress={() => handleToggleCatalogSource({ addonId: item.addonId, type: item.type, catalogId: item.catalogId })}
>
<View style={styles.catalogPickerInfo}>
<Text style={[styles.catalogPickerName, { color: colors.text }]} numberOfLines={1}>
{item.name}
</Text>
<Text style={[styles.catalogPickerMeta, { color: colors.textMuted }]}>
{item.addonName} · {item.type}
</Text>
</View>
{isSelected && <MaterialIcons name="check-circle" size={22} color={colors.primary} />}
</TouchableOpacity>
);
}}
/>
</View>
</View>
</Modal>
</View>
);
}
// ─── Main Collection Editor View ──────────────────
return (
<View style={[styles.container, { backgroundColor: colors.darkBackground }]}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<ScreenHeader
title={isNew ? 'New Collection' : 'Edit Collection'}
showBackButton
onBackPress={() => navigation.goBack()}
rightActionComponent={
<TouchableOpacity onPress={handleSave} style={styles.headerSaveButton}>
<Text style={[styles.headerSaveText, { color: colors.primary }]}>Save</Text>
</TouchableOpacity>
}
/>
<ScrollView contentContainerStyle={styles.scrollContent}>
{/* Collection Title */}
<Text style={[styles.label, { color: colors.textMuted }]}>Collection Title</Text>
<TextInput
style={[styles.textInput, { backgroundColor: colors.elevation1, color: colors.text, borderColor: colors.border }]}
value={title}
onChangeText={setTitle}
placeholder="e.g. My Movie Channels"
placeholderTextColor={colors.disabled}
autoFocus={isNew}
/>
{/* Folders */}
<View style={styles.foldersHeader}>
<Text style={[styles.label, { color: colors.textMuted }]}>Folders</Text>
<TouchableOpacity onPress={handleAddFolder}>
<MaterialIcons name="add-circle" size={24} color={colors.primary} />
</TouchableOpacity>
</View>
{folders.length === 0 && (
<Text style={[styles.noCatalogs, { color: colors.disabled }]}>
No folders yet. Tap + to add a folder.
</Text>
)}
{folders.map((folder, index) => (
<View
key={folder.id}
style={[styles.folderCard, { backgroundColor: colors.elevation1, borderColor: colors.border }]}
>
<View style={styles.folderCardHeader}>
<View style={styles.folderCardLeft}>
{folder.coverEmoji ? (
<Text style={styles.folderCardEmoji}>{folder.coverEmoji}</Text>
) : folder.coverImageUrl ? (
<FastImage
source={{ uri: folder.coverImageUrl }}
style={styles.folderCardImage}
resizeMode={FastImage.resizeMode.cover}
/>
) : (
<MaterialIcons name="folder" size={24} color={colors.textMuted} />
)}
<View style={styles.folderCardInfo}>
<Text style={[styles.folderCardTitle, { color: colors.text }]} numberOfLines={1}>
{folder.title || 'Untitled'}
</Text>
<Text style={[styles.folderCardMeta, { color: colors.textMuted }]}>
{folder.catalogSources.length} catalog{folder.catalogSources.length !== 1 ? 's' : ''} · {folder.tileShape}
</Text>
</View>
</View>
<View style={styles.folderCardActions}>
<TouchableOpacity onPress={() => handleMoveFolderUp(index)} disabled={index === 0} style={styles.iconBtn}>
<MaterialIcons name="arrow-upward" size={18} color={index === 0 ? colors.disabled : colors.text} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleMoveFolderDown(index)} disabled={index === folders.length - 1} style={styles.iconBtn}>
<MaterialIcons name="arrow-downward" size={18} color={index === folders.length - 1 ? colors.disabled : colors.text} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleEditFolder(index)} style={styles.iconBtn}>
<MaterialIcons name="edit" size={18} color={colors.primary} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDeleteFolder(index)} style={styles.iconBtn}>
<MaterialIcons name="delete-outline" size={18} color={colors.error} />
</TouchableOpacity>
</View>
</View>
</View>
))}
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
padding: 16,
paddingBottom: 40,
},
label: {
fontSize: 13,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
marginTop: 16,
},
textInput: {
fontSize: 16,
padding: 14,
borderRadius: 10,
borderWidth: 1,
},
coverRow: {
flexDirection: 'row',
gap: 10,
},
coverOption: {
flex: 1,
aspectRatio: 1,
borderRadius: 12,
borderWidth: 2,
justifyContent: 'center',
alignItems: 'center',
gap: 6,
},
coverEmojiPreview: {
fontSize: 36,
},
coverImagePreview: {
width: 40,
height: 40,
borderRadius: 8,
},
coverOptionLabel: {
fontSize: 12,
fontWeight: '500',
},
shapeRow: {
flexDirection: 'row',
gap: 10,
},
shapeButton: {
flex: 1,
padding: 12,
borderRadius: 10,
borderWidth: 1,
alignItems: 'center',
},
shapeButtonText: {
fontSize: 14,
fontWeight: '600',
},
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 14,
borderRadius: 10,
borderWidth: 1,
marginTop: 16,
},
toggleLabel: {
fontSize: 16,
fontWeight: '500',
},
toggleSwitch: {
width: 48,
height: 28,
borderRadius: 14,
justifyContent: 'center',
paddingHorizontal: 2,
},
toggleThumb: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: '#fff',
},
toggleThumbActive: {
alignSelf: 'flex-end',
},
catalogsHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 16,
marginBottom: 8,
},
foldersHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 16,
marginBottom: 8,
},
noCatalogs: {
fontSize: 14,
textAlign: 'center',
paddingVertical: 24,
},
catalogSourceRow: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 10,
borderWidth: 1,
marginBottom: 8,
},
catalogSourceInfo: {
flex: 1,
},
catalogSourceName: {
fontSize: 15,
fontWeight: '500',
},
catalogSourceMeta: {
fontSize: 12,
marginTop: 2,
},
removeCatalogButton: {
padding: 6,
},
folderActions: {
marginTop: 24,
gap: 10,
},
saveButton: {
padding: 14,
borderRadius: 12,
alignItems: 'center',
},
saveButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
cancelButton: {
padding: 14,
borderRadius: 12,
alignItems: 'center',
borderWidth: 1,
},
cancelButtonText: {
fontSize: 16,
fontWeight: '500',
},
headerSaveButton: {
padding: 8,
},
headerSaveText: {
fontSize: 17,
fontWeight: '600',
},
folderCard: {
borderRadius: 12,
padding: 12,
marginBottom: 8,
borderWidth: 1,
},
folderCardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
folderCardLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
gap: 10,
},
folderCardEmoji: {
fontSize: 24,
},
folderCardImage: {
width: 32,
height: 32,
borderRadius: 6,
},
folderCardInfo: {
flex: 1,
},
folderCardTitle: {
fontSize: 15,
fontWeight: '600',
},
folderCardMeta: {
fontSize: 12,
marginTop: 1,
},
folderCardActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
iconBtn: {
padding: 5,
},
// Modal styles
modalOverlay: {
flex: 1,
justifyContent: 'flex-end',
},
modalContent: {
height: '75%',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 16,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
paddingTop: 4,
},
modalTitle: {
fontSize: 20,
fontWeight: '700',
},
doneText: {
fontSize: 17,
fontWeight: '600',
},
emojiCategory: {
marginBottom: 20,
},
emojiCategoryName: {
fontSize: 14,
fontWeight: '600',
marginBottom: 8,
},
emojiGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
},
emojiButton: {
width: 44,
height: 44,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 10,
borderWidth: 1,
borderColor: 'transparent',
},
emojiButtonText: {
fontSize: 26,
},
catalogPickerItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderRadius: 10,
borderWidth: 1,
marginBottom: 6,
},
catalogPickerInfo: {
flex: 1,
},
catalogPickerName: {
fontSize: 15,
fontWeight: '500',
},
catalogPickerMeta: {
fontSize: 12,
marginTop: 2,
},
});
export default CollectionEditorScreen;

View file

@ -0,0 +1,313 @@
import React, { useState, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
StatusBar,
Platform,
Alert,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { useNavigation, useFocusEffect, NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext';
import { useToast } from '../contexts/ToastContext';
import { Collection } from '../types/collections';
import { collectionsService } from '../services/collectionsService';
import { useCollections } from '../hooks/useCollections';
import ScreenHeader from '../components/common/ScreenHeader';
import CustomAlert from '../components/CustomAlert';
import { RootStackParamList } from '../navigation/AppNavigator';
import { useTranslation } from 'react-i18next';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const CollectionManagementScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { showInfo, showError } = useToast();
const { collections, refresh } = useCollections();
const colors = currentTheme.colors;
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
const [alertActions, setAlertActions] = useState<any[]>([]);
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh])
);
const handleNewCollection = useCallback(() => {
navigation.navigate('CollectionEditor' as any, {});
}, [navigation]);
const handleEdit = useCallback((id: string) => {
navigation.navigate('CollectionEditor' as any, { collectionId: id });
}, [navigation]);
const handleDelete = useCallback((collection: Collection) => {
setAlertTitle('Delete Collection');
setAlertMessage(`Are you sure you want to delete "${collection.title}"?`);
setAlertActions([
{ label: 'Cancel', onPress: () => setAlertVisible(false) },
{
label: 'Delete',
onPress: async () => {
setAlertVisible(false);
await collectionsService.deleteCollection(collection.id);
},
style: 'destructive',
},
]);
setAlertVisible(true);
}, []);
const handleMoveUp = useCallback(async (id: string) => {
await collectionsService.moveCollection(id, 'up');
}, []);
const handleMoveDown = useCallback(async (id: string) => {
await collectionsService.moveCollection(id, 'down');
}, []);
const handleExport = useCallback(async () => {
try {
const json = await collectionsService.exportToJson();
await Clipboard.setStringAsync(json);
showInfo('Copied to clipboard');
} catch {
showError('Export failed');
}
}, [showInfo, showError]);
const handleImport = useCallback(async () => {
try {
const text = await Clipboard.getStringAsync();
if (!text?.trim()) {
showError('Clipboard is empty');
return;
}
const result = await collectionsService.importFromJson(text);
showInfo(`Imported: ${result.added} added, ${result.updated} updated`);
} catch {
showError('Invalid collection data');
}
}, [showInfo, showError]);
return (
<View style={[styles.container, { backgroundColor: colors.darkBackground }]}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<ScreenHeader
title="Collections"
showBackButton
onBackPress={() => navigation.goBack()}
/>
<ScrollView contentContainerStyle={styles.scrollContent}>
<TouchableOpacity
style={[styles.newButton, { backgroundColor: colors.primary }]}
activeOpacity={0.7}
onPress={handleNewCollection}
>
<MaterialIcons name="add" size={22} color="#fff" />
<Text style={styles.newButtonText}>New Collection</Text>
</TouchableOpacity>
{collections.length > 0 && (
<View style={styles.exportImportRow}>
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: colors.elevation3 }]}
activeOpacity={0.7}
onPress={handleExport}
>
<MaterialIcons name="file-upload" size={18} color={colors.text} />
<Text style={[styles.actionButtonText, { color: colors.text }]}>Export</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: colors.elevation3 }]}
activeOpacity={0.7}
onPress={handleImport}
>
<MaterialIcons name="file-download" size={18} color={colors.text} />
<Text style={[styles.actionButtonText, { color: colors.text }]}>Import</Text>
</TouchableOpacity>
</View>
)}
{collections.length === 0 && (
<View style={styles.emptyContainer}>
<MaterialIcons name="folder-open" size={48} color={colors.textMuted} />
<Text style={[styles.emptyText, { color: colors.textMuted }]}>
No collections yet
</Text>
<Text style={[styles.emptySubtext, { color: colors.textMuted }]}>
Create a collection to organize your catalogs into folders
</Text>
</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>
</View>
</View>
</View>
))}
{collections.length === 0 && (
<View style={styles.importOnlyRow}>
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: colors.elevation3 }]}
activeOpacity={0.7}
onPress={handleImport}
>
<MaterialIcons name="file-download" size={18} color={colors.text} />
<Text style={[styles.actionButtonText, { color: colors.text }]}>Import from Clipboard</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
actions={alertActions}
onClose={() => setAlertVisible(false)}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
padding: 16,
paddingBottom: 40,
},
newButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 14,
borderRadius: 12,
marginBottom: 16,
},
newButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
marginLeft: 8,
},
exportImportRow: {
flexDirection: 'row',
gap: 10,
marginBottom: 16,
},
importOnlyRow: {
marginTop: 16,
},
actionButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 10,
borderRadius: 10,
gap: 6,
},
actionButtonText: {
fontSize: 14,
fontWeight: '500',
},
emptyContainer: {
alignItems: 'center',
paddingVertical: 48,
},
emptyText: {
fontSize: 18,
fontWeight: '600',
marginTop: 12,
},
emptySubtext: {
fontSize: 14,
marginTop: 6,
textAlign: 'center',
paddingHorizontal: 32,
},
collectionCard: {
borderRadius: 12,
padding: 14,
marginBottom: 10,
borderWidth: 1,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
cardTitleRow: {
flex: 1,
marginRight: 8,
},
collectionTitle: {
fontSize: 16,
fontWeight: '600',
},
folderCount: {
fontSize: 12,
marginTop: 2,
},
cardActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
iconButton: {
padding: 6,
},
disabledButton: {
opacity: 0.4,
},
});
export default CollectionManagementScreen;

View file

@ -0,0 +1,281 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
StatusBar,
Dimensions,
Platform,
FlatList,
} from 'react-native';
import { useNavigation, useRoute, NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image';
import { useTheme } from '../contexts/ThemeContext';
import { Collection, CollectionFolder, CollectionCatalogSource } from '../types/collections';
import { collectionsService } from '../services/collectionsService';
import { stremioService } from '../services/stremioService';
import { StreamingContent } from '../services/catalogService';
import ScreenHeader from '../components/common/ScreenHeader';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { RootStackParamList } from '../navigation/AppNavigator';
const { width: screenWidth } = Dimensions.get('window');
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const NUM_COLUMNS = screenWidth >= 1024 ? 5 : screenWidth >= 768 ? 4 : 3;
const ITEM_SPACING = 8;
const HORIZONTAL_PADDING = 16;
const ITEM_WIDTH = (screenWidth - HORIZONTAL_PADDING * 2 - ITEM_SPACING * (NUM_COLUMNS - 1)) / NUM_COLUMNS;
const FolderDetailScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const route = useRoute();
const { currentTheme } = useTheme();
const colors = currentTheme.colors;
const { collectionId, folderId } = route.params as { collectionId: string; folderId: string };
const [folder, setFolder] = useState<CollectionFolder | null>(null);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const [items, setItems] = useState<StreamingContent[]>([]);
const [loading, setLoading] = useState(true);
const [tabLabels, setTabLabels] = useState<{ name: string; type: string }[]>([]);
useEffect(() => {
loadFolder();
}, [collectionId, folderId]);
const loadFolder = async () => {
const collections = await collectionsService.getCollections();
const collection = collections.find(c => c.id === collectionId);
if (!collection) return;
const f = collection.folders.find(f => f.id === folderId);
if (!f) return;
setFolder(f);
// Build tab labels
const labels = f.catalogSources.map(source => ({
name: source.catalogId,
type: source.type,
}));
setTabLabels(labels);
// Load first tab
if (f.catalogSources.length > 0) {
loadCatalogData(f.catalogSources[0]);
}
};
const loadCatalogData = async (source: CollectionCatalogSource) => {
setLoading(true);
setItems([]);
try {
const addons = await stremioService.getInstalledAddonsAsync();
const addon = addons.find((a: any) => a.id === source.addonId);
if (!addon) {
setLoading(false);
return;
}
const metas = await stremioService.getCatalog(addon, source.type, source.catalogId, 1);
if (metas && metas.length > 0) {
const mapped: StreamingContent[] = metas.map((meta: any) => ({
id: meta.id,
type: meta.type,
name: meta.name,
poster: meta.poster,
posterShape: meta.posterShape,
imdbRating: meta.imdbRating,
year: meta.year,
genres: meta.genres,
description: meta.description,
}));
setItems(mapped);
}
} catch (error) {
if (__DEV__) console.error('[FolderDetail] Error loading catalog:', error);
} finally {
setLoading(false);
}
};
const handleTabPress = useCallback((index: number) => {
if (!folder) return;
setSelectedTabIndex(index);
loadCatalogData(folder.catalogSources[index]);
}, [folder]);
const handleItemPress = useCallback((id: string, type: string) => {
navigation.navigate('Metadata', { id, type });
}, [navigation]);
const renderItem = useCallback(({ item }: { item: StreamingContent }) => (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => handleItemPress(item.id, item.type)}
style={[styles.gridItem, { width: ITEM_WIDTH }]}
>
<FastImage
source={{ uri: item.poster, priority: FastImage.priority.normal }}
style={styles.gridPoster}
resizeMode={FastImage.resizeMode.cover}
/>
<Text numberOfLines={2} style={[styles.gridTitle, { color: colors.text }]}>
{item.name}
</Text>
</TouchableOpacity>
), [handleItemPress, colors.text]);
const keyExtractor = useCallback((item: StreamingContent) => item.id, []);
if (!folder) {
return (
<View style={[styles.container, { backgroundColor: colors.darkBackground }]}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<LoadingSpinner size="large" />
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colors.darkBackground }]}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<ScreenHeader
title={folder.title}
showBackButton
onBackPress={() => navigation.goBack()}
/>
{/* Tabs */}
{folder.catalogSources.length > 1 && (
<View style={styles.tabContainer}>
<FlatList
data={tabLabels}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.tabList}
keyExtractor={(_, idx) => `tab-${idx}`}
renderItem={({ item: tab, index }) => (
<TouchableOpacity
style={[
styles.tab,
{
backgroundColor: selectedTabIndex === index ? colors.primary : colors.elevation1,
borderColor: selectedTabIndex === index ? colors.primary : colors.border,
},
]}
onPress={() => handleTabPress(index)}
>
<Text
style={[
styles.tabName,
{ color: selectedTabIndex === index ? '#fff' : colors.text },
]}
>
{tab.name}
</Text>
<Text
style={[
styles.tabType,
{ color: selectedTabIndex === index ? 'rgba(255,255,255,0.7)' : colors.textMuted },
]}
>
{tab.type}
</Text>
</TouchableOpacity>
)}
/>
</View>
)}
{/* Content Grid */}
{loading ? (
<View style={styles.loadingContainer}>
<LoadingSpinner size="large" />
</View>
) : items.length === 0 ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="movie-filter" size={48} color={colors.textMuted} />
<Text style={[styles.emptyText, { color: colors.textMuted }]}>No content found</Text>
</View>
) : (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={NUM_COLUMNS}
contentContainerStyle={styles.gridContent}
columnWrapperStyle={styles.gridRow}
showsVerticalScrollIndicator={false}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
tabContainer: {
paddingVertical: 8,
},
tabList: {
paddingHorizontal: HORIZONTAL_PADDING,
gap: 8,
},
tab: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
alignItems: 'center',
},
tabName: {
fontSize: 14,
fontWeight: '600',
},
tabType: {
fontSize: 11,
marginTop: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: 12,
},
emptyText: {
fontSize: 16,
},
gridContent: {
padding: HORIZONTAL_PADDING,
},
gridRow: {
gap: ITEM_SPACING,
marginBottom: ITEM_SPACING,
},
gridItem: {
marginBottom: 4,
},
gridPoster: {
width: '100%',
aspectRatio: 2 / 3,
borderRadius: 10,
backgroundColor: 'rgba(255,255,255,0.05)',
},
gridTitle: {
fontSize: 12,
fontWeight: '500',
marginTop: 4,
},
});
export default FolderDetailScreen;

View file

@ -71,6 +71,9 @@ import FirstTimeWelcome from '../components/FirstTimeWelcome';
import { HeaderVisibility } from '../contexts/HeaderVisibility';
import { useTrailer } from '../contexts/TrailerContext';
import { useScrollToTop } from '../contexts/ScrollToTopContext';
import CollectionRowSection from '../components/home/CollectionRowSection';
import { useCollections } from '../hooks/useCollections';
import { collectionsEmitter, COLLECTIONS_EVENTS } from '../services/collectionsService';
// Constants
const CATALOG_SETTINGS_KEY = 'catalog_settings';
@ -117,6 +120,7 @@ type HomeScreenListItem =
| { type: 'thisWeek'; key: string }
| { type: 'continueWatching'; key: string }
| { type: 'catalog'; catalog: CatalogContent; key: string }
| { type: 'collection'; collection: import('../types/collections').Collection; key: string }
| { type: 'placeholder'; key: string }
| { type: 'welcome'; key: string }
| { type: 'loadMore'; key: string };
@ -143,6 +147,7 @@ const HomeScreen = () => {
const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes
const { showInfo } = useToast();
const { setTrailerPlaying } = useTrailer();
const { collections } = useCollections();
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@ -758,22 +763,47 @@ const HomeScreen = () => {
// Only show a limited number of catalogs initially for performance
const catalogsToShow = catalogs.slice(0, visibleCatalogCount);
// Build catalog items first
const catalogItems: HomeScreenListItem[] = [];
catalogsToShow.forEach((catalog, index) => {
if (catalog) {
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
catalogItems.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
} else if (catalogsLoading && pendingCatalogIndexes[index]) {
// Add a key for placeholders
data.push({ type: 'placeholder', key: `placeholder-${index}` });
catalogItems.push({ type: 'placeholder', key: `placeholder-${index}` });
}
});
// Interleave collections after the first 2 catalog rows, then after each subsequent collection
let catalogIndex = 0;
const collectionsToInsert = [...collections];
const INSERT_AFTER_ROW = 2; // Insert collections after 2nd catalog row
for (let i = 0; i < catalogItems.length; i++) {
data.push(catalogItems[i]);
catalogIndex++;
if (catalogIndex === INSERT_AFTER_ROW && collectionsToInsert.length > 0) {
for (const collection of collectionsToInsert) {
data.push({ type: 'collection', collection, key: `collection-${collection.id}` });
}
collectionsToInsert.length = 0;
}
}
// If we didn't reach INSERT_AFTER_ROW, append remaining collections at the end
if (collectionsToInsert.length > 0) {
for (const collection of collectionsToInsert) {
data.push({ type: 'collection', collection, key: `collection-${collection.id}` });
}
}
// 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]);
}, [hasAddons, catalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection, collections]);
const handleLoadMoreCatalogs = useCallback(() => {
setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length));
@ -865,6 +895,8 @@ const HomeScreen = () => {
return null; // Moved to ListHeaderComponent to avoid remounts on scroll
case 'catalog':
return <CatalogSection catalog={item.catalog} />;
case 'collection':
return <CollectionRowSection collection={item.collection} />;
case 'placeholder':
return (
<Animated.View>

View file

@ -120,6 +120,14 @@ export const ContentDiscoverySettingsContent: React.FC<ContentDiscoverySettingsC
isTablet={isTablet}
/>
)}
<SettingItem
title="Collections"
description="Organize catalogs into folders"
icon="folder-open"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('Collections')}
isTablet={isTablet}
/>
{isItemVisible('home_screen') && (
<SettingItem
title={t('settings.items.home_screen')}

View file

@ -0,0 +1,128 @@
import { mmkvStorage } from './mmkvStorage';
import { Collection, CollectionFolder, CollectionCatalogSource } from '../types/collections';
import { logger } from '../utils/logger';
import EventEmitter from 'eventemitter3';
const COLLECTIONS_STORAGE_KEY = 'collections';
export const collectionsEmitter = new EventEmitter();
export const COLLECTIONS_EVENTS = {
CHANGED: 'collections_changed',
} as const;
class CollectionsService {
private static instance: CollectionsService;
private constructor() {}
static getInstance(): CollectionsService {
if (!CollectionsService.instance) {
CollectionsService.instance = new CollectionsService();
}
return CollectionsService.instance;
}
private async getStorageKey(): Promise<string> {
const scope = await mmkvStorage.getItem('@user:current') || 'local';
return `@user:${scope}:${COLLECTIONS_STORAGE_KEY}`;
}
async getCollections(): Promise<Collection[]> {
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 (error) {
logger.error('[CollectionsService] Error loading collections:', error);
return [];
}
}
async saveCollections(collections: Collection[]): Promise<void> {
try {
const key = await this.getStorageKey();
await mmkvStorage.setItem(key, JSON.stringify(collections));
collectionsEmitter.emit(COLLECTIONS_EVENTS.CHANGED);
} catch (error) {
logger.error('[CollectionsService] Error saving collections:', error);
}
}
async addCollection(collection: Collection): Promise<void> {
const collections = await this.getCollections();
collections.push(collection);
await this.saveCollections(collections);
}
async updateCollection(updated: Collection): Promise<void> {
const collections = await this.getCollections();
const index = collections.findIndex(c => c.id === updated.id);
if (index >= 0) {
collections[index] = updated;
await this.saveCollections(collections);
}
}
async deleteCollection(id: string): Promise<void> {
const collections = await this.getCollections();
await this.saveCollections(collections.filter(c => c.id !== id));
}
async moveCollection(id: string, direction: 'up' | 'down'): Promise<void> {
const collections = await this.getCollections();
const index = collections.findIndex(c => c.id === id);
if (index < 0) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= collections.length) return;
const temp = collections[index];
collections[index] = collections[newIndex];
collections[newIndex] = temp;
await this.saveCollections(collections);
}
async exportToJson(): Promise<string> {
const collections = await this.getCollections();
return JSON.stringify(collections, null, 2);
}
async importFromJson(json: string): Promise<{ added: number; updated: number }> {
try {
const imported = JSON.parse(json);
if (!Array.isArray(imported)) {
throw new Error('Invalid format: expected an array');
}
const existing = await this.getCollections();
const existingMap = new Map(existing.map(c => [c.id, c]));
let added = 0;
let updated = 0;
for (const item of imported) {
if (!item.id || !item.title || !Array.isArray(item.folders)) continue;
if (existingMap.has(item.id)) {
existingMap.set(item.id, item);
updated++;
} else {
existingMap.set(item.id, item);
added++;
}
}
await this.saveCollections(Array.from(existingMap.values()));
return { added, updated };
} catch (error) {
logger.error('[CollectionsService] Import error:', error);
throw error;
}
}
generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
}
export const collectionsService = CollectionsService.getInstance();

21
src/types/collections.ts Normal file
View file

@ -0,0 +1,21 @@
export interface CollectionCatalogSource {
addonId: string;
type: string;
catalogId: string;
}
export interface CollectionFolder {
id: string;
title: string;
coverImageUrl?: string;
coverEmoji?: string;
tileShape: 'poster' | 'wide' | 'square';
hideTitle: boolean;
catalogSources: CollectionCatalogSource[];
}
export interface Collection {
id: string;
title: string;
folders: CollectionFolder[];
}