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; navigation: StackNavigationProp; }; // 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 = ({ route, navigation }) => { const { addonId, type, id, name: originalName, genreFilter } = route.params; const [items, setItems] = useState([]); 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(null); const [dataSource, setDataSource] = useState(DataSource.STREMIO_ADDONS); const [actualCatalogName, setActualCatalogName] = useState(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 ( navigation.navigate('Metadata', { id: item.id, type: item.type, addonId })} activeOpacity={0.7} > ); }, [navigation, styles, effectiveNumColumns, effectiveItemWidth]); const renderEmptyState = () => ( No content found Try Again ); const renderErrorState = () => ( {error} loadItems(1)} > Retry ); const renderLoadingState = () => ( Loading content... ); const isScreenLoading = loading || isLoadingCustomNames; if (isScreenLoading && items.length === 0) { return ( navigation.goBack()} > Back {displayName || originalName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} {renderLoadingState()} ); } if (error && items.length === 0) { return ( navigation.goBack()} > Back {displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} {renderErrorState()} ); } return ( navigation.goBack()} > Back {displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} {items.length > 0 ? ( `${item.id}-${item.type}`} numColumns={effectiveNumColumns} key={effectiveNumColumns} refreshControl={ } onEndReached={handleLoadMore} onEndReachedThreshold={0.5} ListFooterComponent={ paginating ? ( ) : null } contentContainerStyle={styles.list} showsVerticalScrollIndicator={false} estimatedItemSize={effectiveItemWidth * 1.5 + SPACING.lg} /> ) : renderEmptyState()} ); }; export default CatalogScreen;