NuvioStreaming/src/screens/CatalogScreen.tsx

446 lines
No EOL
13 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
ActivityIndicator,
SafeAreaView,
StatusBar,
RefreshControl,
Dimensions,
Platform,
} from 'react-native';
import { RouteProp } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { RootStackParamList } from '../navigation/AppNavigator';
import { Meta, stremioService } from '../services/stremioService';
import { colors } from '../styles';
import { Image } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { logger } from '../utils/logger';
type CatalogScreenProps = {
route: RouteProp<RootStackParamList, 'Catalog'>;
navigation: StackNavigationProp<RootStackParamList, 'Catalog'>;
};
// Constants for layout
const SPACING = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 24,
};
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Screen dimensions and grid layout
const { width } = Dimensions.get('window');
const NUM_COLUMNS = 3;
const ITEM_MARGIN = SPACING.sm;
const ITEM_WIDTH = (width - (SPACING.lg * 2) - (ITEM_MARGIN * 2 * NUM_COLUMNS)) / NUM_COLUMNS;
const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const { addonId, type, id, name, genreFilter } = route.params;
const [items, setItems] = useState<Meta[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
// Force dark mode
const isDarkMode = true;
const loadItems = useCallback(async (pageNum: number, shouldRefresh: boolean = false) => {
try {
if (shouldRefresh) {
setRefreshing(true);
} else if (pageNum === 1) {
setLoading(true);
}
setError(null);
// Use this flag to track if we found and processed any items
let foundItems = false;
let allItems: Meta[] = [];
// Get all installed addon manifests directly
const manifests = await stremioService.getInstalledAddonsAsync();
if (addonId) {
// If addon ID is provided, find the specific addon
const addon = manifests.find(a => a.id === addonId);
if (!addon) {
throw new Error(`Addon ${addonId} not found`);
}
// Create filters array for genre filtering if provided
const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : [];
// Load items from the catalog
const newItems = await stremioService.getCatalog(addon, type, id, pageNum, filters);
if (newItems.length === 0) {
setHasMore(false);
} else {
foundItems = true;
}
if (shouldRefresh || pageNum === 1) {
setItems(newItems);
} else {
setItems(prev => [...prev, ...newItems]);
}
} else if (genreFilter) {
// Get all addons that have catalogs of the specified type
const typeManifests = manifests.filter(manifest =>
manifest.catalogs && manifest.catalogs.some(catalog => catalog.type === type)
);
// For each addon, try to get content with the genre filter
for (const manifest of typeManifests) {
try {
// Find catalogs of this type
const typeCatalogs = manifest.catalogs?.filter(catalog => catalog.type === type) || [];
// For each catalog, try to get content
for (const catalog of typeCatalogs) {
try {
const filters = [{ title: 'genre', value: genreFilter }];
const catalogItems = await stremioService.getCatalog(manifest, type, catalog.id, pageNum, filters);
if (catalogItems && catalogItems.length > 0) {
allItems = [...allItems, ...catalogItems];
foundItems = true;
}
} catch (error) {
logger.log(`Failed to load items from ${manifest.name} catalog ${catalog.id}:`, error);
// Continue with other catalogs
}
}
} catch (error) {
logger.log(`Failed to process addon ${manifest.name}:`, error);
// Continue with other addons
}
}
// Remove duplicates by ID
const uniqueItems = allItems.filter((item, index, self) =>
index === self.findIndex((t) => t.id === item.id)
);
if (uniqueItems.length === 0) {
setHasMore(false);
}
if (shouldRefresh || pageNum === 1) {
setItems(uniqueItems);
} else {
// Add new items while avoiding duplicates
setItems(prev => {
const prevIds = new Set(prev.map(item => item.id));
const newItems = uniqueItems.filter(item => !prevIds.has(item.id));
return [...prev, ...newItems];
});
}
}
if (!foundItems) {
setError("No content found for the selected filters");
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load catalog items');
logger.error('Failed to load catalog:', err);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [addonId, type, id, genreFilter]);
useEffect(() => {
loadItems(1);
}, [loadItems]);
const handleRefresh = useCallback(() => {
setPage(1);
loadItems(1, true);
}, [loadItems]);
const handleLoadMore = useCallback(() => {
if (!loading && hasMore) {
const nextPage = page + 1;
setPage(nextPage);
loadItems(nextPage);
}
}, [loading, hasMore, page, loadItems]);
const renderItem = useCallback(({ item }: { item: Meta }) => {
return (
<TouchableOpacity
style={styles.item}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
activeOpacity={0.7}
>
<Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450/333333/666666?text=No+Image' }}
style={styles.poster}
contentFit="cover"
transition={200}
/>
<View style={styles.itemContent}>
<Text
style={styles.title}
numberOfLines={2}
>
{item.name}
</Text>
{item.releaseInfo && (
<Text style={styles.releaseInfo}>
{item.releaseInfo}
</Text>
)}
</View>
</TouchableOpacity>
);
}, [navigation]);
const renderEmptyState = () => (
<View style={styles.centered}>
<MaterialIcons name="search-off" size={56} color={colors.mediumGray} />
<Text style={styles.emptyText}>
No content found
</Text>
<TouchableOpacity
style={styles.button}
onPress={handleRefresh}
>
<Text style={styles.buttonText}>Try Again</Text>
</TouchableOpacity>
</View>
);
const renderErrorState = () => (
<View style={styles.centered}>
<MaterialIcons name="error-outline" size={56} color={colors.mediumGray} />
<Text style={styles.errorText}>
{error}
</Text>
<TouchableOpacity
style={styles.button}
onPress={() => loadItems(1)}
>
<Text style={styles.buttonText}>Retry</Text>
</TouchableOpacity>
</View>
);
const renderLoadingState = () => (
<View style={styles.centered}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading content...</Text>
</View>
);
if (loading && items.length === 0) {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{renderLoadingState()}
</SafeAreaView>
);
}
if (error && items.length === 0) {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{renderErrorState()}
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{items.length > 0 ? (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => `${item.id}-${item.type}`}
numColumns={NUM_COLUMNS}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
colors={[colors.primary]}
tintColor={colors.primary}
/>
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={
loading && items.length > 0 ? (
<View style={styles.footer}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
) : null
}
contentContainerStyle={styles.list}
columnWrapperStyle={styles.columnWrapper}
showsVerticalScrollIndicator={false}
/>
) : renderEmptyState()}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.darkBackground,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
padding: 8,
},
backText: {
fontSize: 17,
fontWeight: '400',
color: colors.primary,
},
headerTitle: {
fontSize: 34,
fontWeight: '700',
color: colors.white,
paddingHorizontal: 16,
paddingBottom: 16,
paddingTop: 8,
},
list: {
padding: SPACING.lg,
paddingTop: SPACING.sm,
},
columnWrapper: {
justifyContent: 'space-between',
},
item: {
width: ITEM_WIDTH,
marginBottom: SPACING.lg,
borderRadius: 12,
overflow: 'hidden',
backgroundColor: colors.elevation2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
poster: {
width: '100%',
aspectRatio: 2/3,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
backgroundColor: colors.elevation3,
},
itemContent: {
padding: SPACING.sm,
},
title: {
fontSize: 14,
fontWeight: '600',
color: colors.white,
lineHeight: 18,
},
releaseInfo: {
fontSize: 12,
marginTop: SPACING.xs,
color: colors.mediumGray,
},
footer: {
padding: SPACING.lg,
alignItems: 'center',
},
button: {
marginTop: SPACING.md,
paddingVertical: SPACING.md,
paddingHorizontal: SPACING.xl,
backgroundColor: colors.primary,
borderRadius: 8,
elevation: 2,
},
buttonText: {
color: colors.white,
fontWeight: '600',
fontSize: 16,
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: SPACING.xl,
},
emptyText: {
color: colors.white,
fontSize: 16,
textAlign: 'center',
marginTop: SPACING.md,
marginBottom: SPACING.sm,
},
errorText: {
color: colors.white,
fontSize: 16,
textAlign: 'center',
marginTop: SPACING.md,
marginBottom: SPACING.sm,
},
loadingText: {
color: colors.white,
fontSize: 16,
marginTop: SPACING.lg,
}
});
export default CatalogScreen;