mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
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:
parent
d4977c2fb0
commit
c6216cb95d
10 changed files with 1944 additions and 4 deletions
173
src/components/home/CollectionRowSection.tsx
Normal file
173
src/components/home/CollectionRowSection.tsx
Normal 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;
|
||||
35
src/hooks/useCollections.ts
Normal file
35
src/hooks/useCollections.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
907
src/screens/CollectionEditorScreen.tsx
Normal file
907
src/screens/CollectionEditorScreen.tsx
Normal 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;
|
||||
313
src/screens/CollectionManagementScreen.tsx
Normal file
313
src/screens/CollectionManagementScreen.tsx
Normal 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;
|
||||
281
src/screens/FolderDetailScreen.tsx
Normal file
281
src/screens/FolderDetailScreen.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
128
src/services/collectionsService.ts
Normal file
128
src/services/collectionsService.ts
Normal 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
21
src/types/collections.ts
Normal 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[];
|
||||
}
|
||||
Loading…
Reference in a new issue