diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx index 71937b0e..80a3bdc2 100644 --- a/src/components/home/CatalogSection.tsx +++ b/src/components/home/CatalogSection.tsx @@ -15,26 +15,40 @@ interface CatalogSectionProps { const { width } = Dimensions.get('window'); -// Dynamic poster calculation based on screen width +// Dynamic poster calculation based on screen width - show 1/4 of next poster const calculatePosterLayout = (screenWidth: number) => { - const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability - const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters - const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins + const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters + const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters + const LEFT_PADDING = 16; // Left padding + const SPACING = 8; // Space between posters - // Calculate how many posters can fit - const availableWidth = screenWidth - HORIZONTAL_PADDING; - const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); + // Calculate available width for posters (reserve space for left padding) + const availableWidth = screenWidth - LEFT_PADDING; - // Limit to reasonable number of columns (3-6) - const numColumns = Math.min(Math.max(maxColumns, 3), 6); + // Try different numbers of full posters to find the best fit + let bestLayout = { numFullPosters: 3, posterWidth: 120 }; - // Calculate actual poster width - const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); + for (let n = 3; n <= 6; n++) { + // Calculate poster width needed for N full posters + 0.25 partial poster + // Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding + // Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding + // We'll use minimal right padding (8px) to maximize space + const usableWidth = availableWidth - 8; + const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25); + + console.log(`[CatalogSection] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`); + + if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) { + bestLayout = { numFullPosters: n, posterWidth }; + console.log(`[CatalogSection] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`); + } + } return { - numColumns, - posterWidth, - spacing: 12 // Space between posters + numFullPosters: bestLayout.numFullPosters, + posterWidth: bestLayout.posterWidth, + spacing: SPACING, + partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster }; }; @@ -98,18 +112,18 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { keyExtractor={(item) => `${item.id}-${item.type}`} horizontal showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.catalogList} - snapToInterval={POSTER_WIDTH + 12} + contentContainerStyle={[styles.catalogList, { paddingRight: 16 - posterLayout.partialPosterWidth }]} + snapToInterval={POSTER_WIDTH + 8} decelerationRate="fast" snapToAlignment="start" - ItemSeparatorComponent={() => } + ItemSeparatorComponent={() => } initialNumToRender={4} maxToRenderPerBatch={4} windowSize={5} removeClippedSubviews={Platform.OS === 'android'} getItemLayout={(data, index) => ({ - length: POSTER_WIDTH + 12, - offset: (POSTER_WIDTH + 12) * index, + length: POSTER_WIDTH + 8, + offset: (POSTER_WIDTH + 8) * index, index, })} /> diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index f5061c54..860a3012 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -13,26 +13,40 @@ interface ContentItemProps { const { width } = Dimensions.get('window'); -// Dynamic poster calculation based on screen width +// Dynamic poster calculation based on screen width - show 1/4 of next poster const calculatePosterLayout = (screenWidth: number) => { - const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability - const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters - const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins + const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters + const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters + const LEFT_PADDING = 16; // Left padding + const SPACING = 8; // Space between posters - // Calculate how many posters can fit - const availableWidth = screenWidth - HORIZONTAL_PADDING; - const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); + // Calculate available width for posters (reserve space for left padding) + const availableWidth = screenWidth - LEFT_PADDING; - // Limit to reasonable number of columns (3-6) - const numColumns = Math.min(Math.max(maxColumns, 3), 6); + // Try different numbers of full posters to find the best fit + let bestLayout = { numFullPosters: 3, posterWidth: 120 }; - // Calculate actual poster width - const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); + for (let n = 3; n <= 6; n++) { + // Calculate poster width needed for N full posters + 0.25 partial poster + // Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding + // Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding + // We'll use minimal right padding (8px) to maximize space + const usableWidth = availableWidth - 8; + const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25); + + console.log(`[ContentItem] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`); + + if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) { + bestLayout = { numFullPosters: n, posterWidth }; + console.log(`[ContentItem] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`); + } + } return { - numColumns, - posterWidth, - spacing: 12 // Space between posters + numFullPosters: bestLayout.numFullPosters, + posterWidth: bestLayout.posterWidth, + spacing: SPACING, + partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster }; }; @@ -157,28 +171,28 @@ const styles = StyleSheet.create({ width: POSTER_WIDTH, aspectRatio: 2/3, margin: 0, - borderRadius: 8, + borderRadius: 4, overflow: 'hidden', position: 'relative', - elevation: 8, + elevation: 6, shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.08)', + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.25, + shadowRadius: 6, + borderWidth: 0.5, + borderColor: 'rgba(255,255,255,0.12)', }, contentItemContainer: { width: '100%', height: '100%', - borderRadius: 8, + borderRadius: 4, overflow: 'hidden', position: 'relative', }, poster: { width: '100%', height: '100%', - borderRadius: 8, + borderRadius: 4, }, loadingOverlay: { position: 'absolute', diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 28d92fa6..483f5122 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -22,6 +22,7 @@ import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService'; +import { stremioService } from '../services/stremioService'; import { Stream } from '../types/metadata'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; @@ -386,16 +387,16 @@ const HomeScreen = () => { const { currentTheme } = useTheme(); const continueWatchingRef = useRef(null); const { settings } = useSettings(); + const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection); const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource); const refreshTimeoutRef = useRef(null); const [hasContinueWatching, setHasContinueWatching] = useState(false); - const { - catalogs, - loading: catalogsLoading, - refreshCatalogs - } = useHomeCatalogs(); + const [catalogs, setCatalogs] = useState([]); + const [catalogsLoading, setCatalogsLoading] = useState(true); + const [loadedCatalogCount, setLoadedCatalogCount] = useState(0); + const totalCatalogsRef = useRef(0); const { featuredContent, @@ -405,10 +406,116 @@ const HomeScreen = () => { refreshFeatured } = useFeaturedContent(); + // Progressive catalog loading function + const loadCatalogsProgressively = useCallback(async () => { + setCatalogsLoading(true); + setCatalogs([]); + setLoadedCatalogCount(0); + + try { + const addons = await catalogService.getAllAddons(); + + // Create placeholder array with proper order and track indices + const catalogPlaceholders: (CatalogContent | null)[] = []; + const catalogPromises: Promise[] = []; + let catalogIndex = 0; + + for (const addon of addons) { + if (addon.catalogs) { + for (const catalog of addon.catalogs) { + const currentIndex = catalogIndex; + catalogPlaceholders.push(null); // Reserve position + + const catalogPromise = (async () => { + try { + const addonManifest = await stremioService.getInstalledAddonsAsync(); + const manifest = addonManifest.find((a: any) => a.id === addon.id); + if (!manifest) return; + + const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); + if (metas && metas.length > 0) { + const items = metas.map((meta: any) => ({ + id: meta.id, + type: meta.type, + name: meta.name, + poster: meta.poster, + posterShape: meta.posterShape, + banner: meta.background, + logo: meta.logo, + imdbRating: meta.imdbRating, + year: meta.year, + genres: meta.genres, + description: meta.description, + runtime: meta.runtime, + released: meta.released, + trailerStreams: meta.trailerStreams, + videos: meta.videos, + directors: meta.director, + creators: meta.creator, + certification: meta.certification + })); + + let displayName = catalog.name; + const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; + if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { + displayName = `${displayName} ${contentType}`; + } + + const catalogContent = { + addon: addon.id, + type: catalog.type, + id: catalog.id, + name: displayName, + items + }; + + console.log(`[HomeScreen] Loaded catalog: ${displayName} at position ${currentIndex} (${items.length} items)`); + + // Update the catalog at its specific position + setCatalogs(prevCatalogs => { + const newCatalogs = [...prevCatalogs]; + newCatalogs[currentIndex] = catalogContent; + return newCatalogs; + }); + } + } catch (error) { + console.error(`[HomeScreen] Failed to load ${catalog.name} from ${addon.name}:`, error); + } finally { + setLoadedCatalogCount(prev => prev + 1); + } + })(); + + catalogPromises.push(catalogPromise); + catalogIndex++; + } + } + } + + totalCatalogsRef.current = catalogIndex; + console.log(`[HomeScreen] Starting to load ${catalogIndex} catalogs progressively...`); + + // Initialize catalogs array with proper length + setCatalogs(new Array(catalogIndex).fill(null)); + + // Wait for all catalogs to finish loading (success or failure) + await Promise.allSettled(catalogPromises); + console.log('[HomeScreen] All catalogs processed'); + + // Filter out null values to get only successfully loaded catalogs + setCatalogs(prevCatalogs => prevCatalogs.filter(catalog => catalog !== null)); + + } catch (error) { + console.error('[HomeScreen] Error in progressive catalog loading:', error); + } finally { + setCatalogsLoading(false); + } + }, []); + // Only count feature section as loading if it's enabled in settings + // For catalogs, we show them progressively, so only show loading if no catalogs are loaded yet const isLoading = useMemo(() => - (showHeroSection ? featuredLoading : false) || catalogsLoading, - [showHeroSection, featuredLoading, catalogsLoading] + (showHeroSection ? featuredLoading : false) || (catalogsLoading && catalogs.length === 0), + [showHeroSection, featuredLoading, catalogsLoading, catalogs.length] ); // React to settings changes @@ -417,6 +524,21 @@ const HomeScreen = () => { setFeaturedContentSource(settings.featuredContentSource); }, [settings]); + // Load catalogs progressively on mount and when settings change + useEffect(() => { + loadCatalogsProgressively(); + }, [loadCatalogsProgressively]); + + // Listen for catalog changes (addon additions/removals) and reload catalogs + useEffect(() => { + loadCatalogsProgressively(); + }, [lastUpdate, loadCatalogsProgressively]); + + // Create a refresh function for catalogs + const refreshCatalogs = useCallback(() => { + return loadCatalogsProgressively(); + }, [loadCatalogsProgressively]); + // Subscribe directly to settings emitter for immediate updates useEffect(() => { const handleSettingsChange = () => { @@ -567,10 +689,13 @@ const HomeScreen = () => { useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { refreshContinueWatching(); + // Also refresh catalogs when returning to home screen + // This ensures new addons are shown even if the context event was missed + loadCatalogsProgressively(); }); return unsubscribe; - }, [navigation, refreshContinueWatching]); + }, [navigation, refreshContinueWatching, loadCatalogsProgressively]); // Memoize the loading screen to prevent unnecessary re-renders const renderLoadingScreen = useMemo(() => { @@ -626,28 +751,63 @@ const HomeScreen = () => { - {catalogs.length > 0 ? ( - catalogs.map((catalog, index) => ( - + {/* Show catalogs as they load */} + {catalogs.map((catalog, index) => { + if (!catalog) { + // Show placeholder for loading catalog + return ( + + + + + + + {[...Array(4)].map((_, posterIndex) => ( + + ))} + + + ); + } + + return ( + - - )) - ) : ( - !catalogsLoading && ( - - - - No content available - - navigation.navigate('Settings')} - > - - Add Catalogs - - - ) + + ); + })} + + {/* Show loading indicator for remaining catalogs */} + {catalogsLoading && catalogs.length < totalCatalogsRef.current && ( + + + + Loading more content... ({loadedCatalogCount}/{totalCatalogsRef.current}) + + + )} + + {/* Show empty state only if all catalogs are loaded and none are available */} + {!catalogsLoading && catalogs.length === 0 && ( + + + + No content available + + navigation.navigate('Settings')} + > + + Add Catalogs + + )} @@ -671,26 +831,40 @@ const HomeScreen = () => { const { width, height } = Dimensions.get('window'); -// Dynamic poster calculation based on screen width +// Dynamic poster calculation based on screen width - show 1/4 of next poster const calculatePosterLayout = (screenWidth: number) => { - const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability - const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters - const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins + const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters + const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters + const LEFT_PADDING = 16; // Left padding + const SPACING = 8; // Space between posters - // Calculate how many posters can fit - const availableWidth = screenWidth - HORIZONTAL_PADDING; - const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); + // Calculate available width for posters (reserve space for left padding) + const availableWidth = screenWidth - LEFT_PADDING; - // Limit to reasonable number of columns (3-6) - const numColumns = Math.min(Math.max(maxColumns, 3), 6); + // Try different numbers of full posters to find the best fit + let bestLayout = { numFullPosters: 3, posterWidth: 120 }; - // Calculate actual poster width - const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); + for (let n = 3; n <= 6; n++) { + // Calculate poster width needed for N full posters + 0.25 partial poster + // Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding + // Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding + // We'll use minimal right padding (8px) to maximize space + const usableWidth = availableWidth - 8; + const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25); + + console.log(`[HomeScreen] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`); + + if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) { + bestLayout = { numFullPosters: n, posterWidth }; + console.log(`[HomeScreen] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`); + } + } return { - numColumns, - posterWidth, - spacing: 12 // Space between posters + numFullPosters: bestLayout.numFullPosters, + posterWidth: bestLayout.posterWidth, + spacing: SPACING, + partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster }; }; @@ -714,6 +888,42 @@ const styles = StyleSheet.create({ marginTop: 12, fontSize: 14, }, + loadingMoreCatalogs: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 16, + marginHorizontal: 16, + marginBottom: 16, + }, + loadingMoreText: { + marginLeft: 12, + fontSize: 14, + }, + catalogPlaceholder: { + marginBottom: 24, + paddingHorizontal: 16, + }, + placeholderHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + placeholderTitle: { + width: 150, + height: 20, + borderRadius: 4, + }, + placeholderPosters: { + flexDirection: 'row', + gap: 8, + }, + placeholderPoster: { + width: POSTER_WIDTH, + aspectRatio: 2/3, + borderRadius: 4, + }, emptyCatalog: { padding: 32, alignItems: 'center', @@ -903,7 +1113,8 @@ const styles = StyleSheet.create({ marginRight: 4, }, catalogList: { - paddingHorizontal: 16, + paddingLeft: 16, + paddingRight: 16 - posterLayout.partialPosterWidth, paddingBottom: 12, paddingTop: 6, }, @@ -911,21 +1122,21 @@ const styles = StyleSheet.create({ width: POSTER_WIDTH, aspectRatio: 2/3, margin: 0, - borderRadius: 8, + borderRadius: 4, overflow: 'hidden', position: 'relative', - elevation: 8, + elevation: 6, shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.08)', + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.25, + shadowRadius: 6, + borderWidth: 0.5, + borderColor: 'rgba(255,255,255,0.12)', }, poster: { width: '100%', height: '100%', - borderRadius: 8, + borderRadius: 4, }, imdbLogo: { width: 35, @@ -964,7 +1175,7 @@ const styles = StyleSheet.create({ contentItemContainer: { width: '100%', height: '100%', - borderRadius: 8, + borderRadius: 4, overflow: 'hidden', position: 'relative', },