NuvioStreaming/src/screens/CatalogScreen.tsx
2025-09-06 02:39:54 +05:30

718 lines
No EOL
24 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
SafeAreaView,
StatusBar,
RefreshControl,
Dimensions,
Platform,
} from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { RouteProp } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { RootStackParamList } from '../navigation/AppNavigator';
import { Meta, stremioService } from '../services/stremioService';
import { useTheme } from '../contexts/ThemeContext';
import { Image } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { logger } from '../utils/logger';
import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { catalogService, DataSource, StreamingContent } from '../services/catalogService';
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;
// Dynamic column calculation based on screen width
const calculateCatalogLayout = (screenWidth: number) => {
const MIN_ITEM_WIDTH = 120;
const MAX_ITEM_WIDTH = 180; // Increased for tablets
const HORIZONTAL_PADDING = SPACING.lg * 2;
const ITEM_SPACING = SPACING.sm;
// Calculate how many columns can fit
const availableWidth = screenWidth - HORIZONTAL_PADDING;
const maxColumns = Math.floor(availableWidth / (MIN_ITEM_WIDTH + ITEM_SPACING));
// More flexible column limits for different screen sizes
let numColumns;
if (screenWidth < 600) {
// Phone: 2-3 columns
numColumns = Math.min(Math.max(maxColumns, 2), 3);
} else if (screenWidth < 900) {
// Small tablet: 3-5 columns
numColumns = Math.min(Math.max(maxColumns, 3), 5);
} else if (screenWidth < 1200) {
// Large tablet: 4-6 columns
numColumns = Math.min(Math.max(maxColumns, 4), 6);
} else {
// Very large screens: 5-8 columns
numColumns = Math.min(Math.max(maxColumns, 5), 8);
}
// Calculate actual item width with proper spacing
const totalSpacing = ITEM_SPACING * (numColumns - 1);
const itemWidth = (availableWidth - totalSpacing) / numColumns;
// Ensure item width doesn't exceed maximum
const finalItemWidth = Math.min(itemWidth, MAX_ITEM_WIDTH);
return {
numColumns,
itemWidth: finalItemWidth
};
};
// Create a styles creator function that accepts the theme colors
const createStyles = (colors: any) => StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.darkBackground,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
// Center header on very wide screens
alignSelf: 'center',
maxWidth: 1400,
width: '100%',
},
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,
// Center title on very wide screens
alignSelf: 'center',
maxWidth: 1400,
width: '100%',
},
list: {
padding: SPACING.lg,
paddingTop: SPACING.sm,
// Center content on very wide screens
alignSelf: 'center',
maxWidth: 1400, // Prevent content from being too wide on large screens
width: '100%',
},
item: {
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,
},
// removed bottom text container; keep spacing via item margin only
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,
// Center content on very wide screens
alignSelf: 'center',
maxWidth: 600, // Narrower max width for centered content
width: '100%',
},
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,
}
});
const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const { addonId, type, id, name: originalName, genreFilter } = route.params;
const [items, setItems] = useState<Meta[]>([]);
const [loading, setLoading] = useState(true);
const [paginating, setPaginating] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dataSource, setDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS);
const [actualCatalogName, setActualCatalogName] = useState<string | null>(null);
const [screenData, setScreenData] = useState(() => {
const { width } = Dimensions.get('window');
return {
width,
...calculateCatalogLayout(width)
};
});
const [mobileColumnsPref, setMobileColumnsPref] = useState<'auto' | 2 | 3>('auto');
const { currentTheme } = useTheme();
const colors = currentTheme.colors;
const styles = createStyles(colors);
const isDarkMode = true;
const isInitialRender = React.useRef(true);
// Load mobile columns preference (phones only)
useEffect(() => {
(async () => {
try {
const pref = await AsyncStorage.getItem('catalog_mobile_columns');
if (pref === '2') setMobileColumnsPref(2);
else if (pref === '3') setMobileColumnsPref(3);
else setMobileColumnsPref('auto');
} catch {}
})();
}, []);
// Handle screen dimension changes
useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ window }) => {
const base = calculateCatalogLayout(window.width);
setScreenData(prev => ({
width: window.width,
...base
}));
});
return () => subscription?.remove();
}, []);
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames();
// Create display name with proper type suffix
const createDisplayName = (catalogName: string) => {
if (!catalogName) return '';
// Check if the name already includes content type indicators
const lowerName = catalogName.toLowerCase();
const contentType = type === 'movie' ? 'Movies' : type === 'series' ? 'TV Shows' : `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
// If the name already contains type information, return as is
if (lowerName.includes('movie') || lowerName.includes('tv') || lowerName.includes('show') || lowerName.includes('series')) {
return catalogName;
}
// Otherwise append the content type
return `${catalogName} ${contentType}`;
};
// Use actual catalog name if available, otherwise fallback to custom name or original name
const displayName = actualCatalogName
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
(genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` :
`${type.charAt(0).toUpperCase() + type.slice(1)}s`);
// Add effect to get the actual catalog name from addon manifest
useEffect(() => {
const getActualCatalogName = async () => {
if (addonId && type && id) {
try {
const manifests = await stremioService.getInstalledAddonsAsync();
const addon = manifests.find(a => a.id === addonId);
if (addon && addon.catalogs) {
const catalog = addon.catalogs.find(c => c.type === type && c.id === id);
if (catalog && catalog.name) {
setActualCatalogName(catalog.name);
}
}
} catch (error) {
logger.error('Failed to get actual catalog name:', error);
}
}
};
getActualCatalogName();
}, [addonId, type, id]);
// Add effect to get data source preference when component mounts
useEffect(() => {
const getDataSourcePreference = async () => {
const preference = await catalogService.getDataSourcePreference();
setDataSource(preference);
};
getDataSourcePreference();
}, []);
const loadItems = useCallback(async (pageNum: number, shouldRefresh: boolean = false) => {
try {
if (shouldRefresh) {
setRefreshing(true);
setHasMore(true); // Reset hasMore on refresh
} else if (pageNum > 1) {
setPaginating(true);
} else {
setLoading(true);
}
setError(null);
// Process the genre filter - ignore "All" and clean up the value
let effectiveGenreFilter = genreFilter;
if (effectiveGenreFilter === 'All') {
effectiveGenreFilter = undefined;
logger.log('Genre "All" detected, removing genre filter');
} else if (effectiveGenreFilter) {
// Clean up the genre filter
effectiveGenreFilter = effectiveGenreFilter.trim();
logger.log(`Using cleaned genre filter: "${effectiveGenreFilter}"`);
}
// Check if using TMDB as data source and not requesting a specific addon
if (dataSource === DataSource.TMDB && !addonId) {
logger.log('Using TMDB data source for CatalogScreen');
try {
const catalogs = await catalogService.getCatalogByType(type, effectiveGenreFilter);
if (catalogs && catalogs.length > 0) {
// Flatten all items from all catalogs
const allItems: StreamingContent[] = [];
catalogs.forEach(catalog => {
allItems.push(...catalog.items);
});
// Convert StreamingContent to Meta format
const metaItems: Meta[] = allItems.map(item => ({
id: item.id,
type: item.type,
name: item.name,
poster: item.poster,
background: item.banner,
logo: item.logo,
description: item.description,
releaseInfo: item.year?.toString() || '',
imdbRating: item.imdbRating,
year: item.year,
genres: item.genres || [],
runtime: item.runtime,
certification: item.certification,
}));
// Remove duplicates
const uniqueItems = metaItems.filter((item, index, self) =>
index === self.findIndex((t) => t.id === item.id)
);
setItems(uniqueItems);
setHasMore(false); // TMDB already returns a full set
setLoading(false);
setRefreshing(false);
return;
} else {
setError("No content found for the selected filters");
setItems([]);
setLoading(false);
setRefreshing(false);
return;
}
} catch (error) {
logger.error('Failed to get TMDB catalog:', error);
setError('Failed to load content from TMDB');
setItems([]);
setLoading(false);
setRefreshing(false);
return;
}
}
// 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 = effectiveGenreFilter ? [{ title: 'genre', value: effectiveGenreFilter }] : [];
// Load items from the catalog
const newItems = await stremioService.getCatalog(addon, type, id, pageNum, filters);
if (newItems.length === 0) {
setHasMore(false);
} else {
foundItems = true;
setHasMore(true); // Ensure hasMore is true if we found items
}
if (shouldRefresh || pageNum === 1) {
setItems(newItems);
} else {
setItems(prev => [...prev, ...newItems]);
}
} else if (effectiveGenreFilter) {
// Get all addons that have catalogs of the specified type
const typeManifests = manifests.filter(manifest =>
manifest.catalogs && manifest.catalogs.some(catalog => catalog.type === type)
);
// Add debug logging for genre filter
logger.log(`Using genre filter: "${effectiveGenreFilter}" for 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: effectiveGenreFilter }];
// Debug logging for each catalog request
logger.log(`Requesting from ${manifest.name}, catalog ${catalog.id} with genre "${effectiveGenreFilter}"`);
const catalogItems = await stremioService.getCatalog(manifest, type, catalog.id, pageNum, filters);
if (catalogItems && catalogItems.length > 0) {
// Log first few items' genres to debug
const sampleItems = catalogItems.slice(0, 3);
sampleItems.forEach(item => {
logger.log(`Item "${item.name}" has genres: ${JSON.stringify(item.genres)}`);
});
// Filter items client-side to ensure they contain the requested genre
// Some addons might not properly filter by genre on the server
let filteredItems = catalogItems;
if (effectiveGenreFilter) {
const normalizedGenreFilter = effectiveGenreFilter.toLowerCase().trim();
filteredItems = catalogItems.filter(item => {
// Skip items without genres
if (!item.genres || !Array.isArray(item.genres)) {
return false;
}
// Check for genre match (exact or substring)
return item.genres.some(genre => {
const normalizedGenre = genre.toLowerCase().trim();
return normalizedGenre === normalizedGenreFilter ||
normalizedGenre.includes(normalizedGenreFilter) ||
normalizedGenreFilter.includes(normalizedGenre);
});
});
logger.log(`Filtered ${catalogItems.length} items to ${filteredItems.length} matching genre "${effectiveGenreFilter}"`);
}
allItems = [...allItems, ...filteredItems];
foundItems = filteredItems.length > 0;
}
} 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 && allItems.length === 0) {
setHasMore(false);
} else {
foundItems = true;
setHasMore(true); // Ensure hasMore is true if we found items
}
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);
setPaginating(false);
}
}, [addonId, type, id, genreFilter, dataSource]);
useEffect(() => {
loadItems(1, true);
}, [loadItems]);
const handleRefresh = useCallback(() => {
setPage(1);
setItems([]); // Clear items on refresh
loadItems(1, true);
}, [loadItems]);
const handleLoadMore = useCallback(() => {
if (isInitialRender.current) {
isInitialRender.current = false;
return;
}
if (!loading && !paginating && hasMore && !refreshing) {
const nextPage = page + 1;
setPage(nextPage);
loadItems(nextPage);
}
}, [loading, paginating, hasMore, page, loadItems, refreshing]);
const effectiveNumColumns = React.useMemo(() => {
const isPhone = screenData.width < 600; // basic breakpoint; tablets generally above this
if (!isPhone || mobileColumnsPref === 'auto') return screenData.numColumns;
// clamp to 2 or 3 on phones
return mobileColumnsPref === 2 ? 2 : 3;
}, [screenData.width, screenData.numColumns, mobileColumnsPref]);
const effectiveItemWidth = React.useMemo(() => {
if (effectiveNumColumns === screenData.numColumns) return screenData.itemWidth;
// recompute width for custom columns on mobile to maintain spacing roughly similar
const HORIZONTAL_PADDING = 16 * 2; // SPACING.lg * 2
const ITEM_SPACING = 8; // SPACING.sm
const availableWidth = screenData.width - HORIZONTAL_PADDING;
const totalSpacing = ITEM_SPACING * (effectiveNumColumns - 1);
return (availableWidth - totalSpacing) / effectiveNumColumns;
}, [effectiveNumColumns, screenData.width, screenData.itemWidth]);
const renderItem = useCallback(({ item, index }: { item: Meta; index: number }) => {
// Calculate if this is the last item in a row
const isLastInRow = (index + 1) % effectiveNumColumns === 0;
// For proper spacing
const rightMargin = isLastInRow ? 0 : SPACING.sm;
return (
<TouchableOpacity
style={[
styles.item,
{
marginRight: rightMargin,
width: effectiveItemWidth
}
]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type, addonId })}
activeOpacity={0.7}
>
<Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450/333333/666666?text=No+Image' }}
style={styles.poster}
contentFit="cover"
cachePolicy="disk"
transition={0}
allowDownscaling
/>
</TouchableOpacity>
);
}, [navigation, styles, effectiveNumColumns, effectiveItemWidth]);
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>
);
const isScreenLoading = loading || isLoadingCustomNames;
if (isScreenLoading && 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}>{displayName || originalName || `${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}>{displayName || `${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}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{items.length > 0 ? (
<FlashList
data={items}
renderItem={renderItem}
keyExtractor={(item) => `${item.id}-${item.type}`}
numColumns={effectiveNumColumns}
key={effectiveNumColumns}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
colors={[colors.primary]}
tintColor={colors.primary}
/>
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={
paginating ? (
<View style={styles.footer}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
) : null
}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
estimatedItemSize={effectiveItemWidth * 1.5 + SPACING.lg}
/>
) : renderEmptyState()}
</SafeAreaView>
);
};
export default CatalogScreen;