diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx new file mode 100644 index 00000000..a1da2c84 --- /dev/null +++ b/src/components/home/CatalogSection.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, FlatList, Platform, Dimensions } from 'react-native'; +import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { FadeIn } from 'react-native-reanimated'; +import { CatalogContent, StreamingContent } from '../../services/catalogService'; +import { colors } from '../../styles/colors'; +import ContentItem from './ContentItem'; +import { RootStackParamList } from '../../navigation/AppNavigator'; + +interface CatalogSectionProps { + catalog: CatalogContent; +} + +const { width } = Dimensions.get('window'); +const POSTER_WIDTH = (width - 50) / 3; + +const CatalogSection = ({ catalog }: CatalogSectionProps) => { + const navigation = useNavigation>(); + + const handleContentPress = (id: string, type: string) => { + navigation.navigate('Metadata', { id, type }); + }; + + const renderContentItem = ({ item, index }: { item: StreamingContent, index: number }) => { + return ( + + + + ); + }; + + return ( + + + + {catalog.name} + + + + navigation.navigate('Catalog', { + id: catalog.id, + type: catalog.type, + addonId: catalog.addon + }) + } + style={styles.seeAllButton} + > + See More + + + + + `${item.id}-${item.type}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.catalogList} + snapToInterval={POSTER_WIDTH + 12} + decelerationRate="fast" + snapToAlignment="start" + ItemSeparatorComponent={() => } + initialNumToRender={4} + maxToRenderPerBatch={4} + windowSize={5} + removeClippedSubviews={Platform.OS === 'android'} + getItemLayout={(data, index) => ({ + length: POSTER_WIDTH + 12, + offset: (POSTER_WIDTH + 12) * index, + index, + })} + /> + + ); +}; + +const styles = StyleSheet.create({ + catalogContainer: { + marginBottom: 24, + paddingTop: 0, + marginTop: 16, + }, + catalogHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + marginBottom: 12, + }, + titleContainer: { + position: 'relative', + }, + catalogTitle: { + fontSize: 18, + fontWeight: '800', + color: colors.highEmphasis, + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 6, + }, + titleUnderline: { + position: 'absolute', + bottom: -4, + left: 0, + width: 60, + height: 3, + borderRadius: 1.5, + }, + seeAllButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.elevation1, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + }, + seeAllText: { + color: colors.primary, + fontSize: 13, + fontWeight: '700', + marginRight: 4, + }, + catalogList: { + paddingHorizontal: 16, + paddingBottom: 12, + paddingTop: 6, + }, +}); + +export default CatalogSection; \ No newline at end of file diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx new file mode 100644 index 00000000..47e810dd --- /dev/null +++ b/src/components/home/ContentItem.tsx @@ -0,0 +1,186 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions } from 'react-native'; +import { Image as ExpoImage } from 'expo-image'; +import { MaterialIcons } from '@expo/vector-icons'; +import { colors } from '../../styles/colors'; +import { catalogService, StreamingContent } from '../../services/catalogService'; +import DropUpMenu from './DropUpMenu'; + +interface ContentItemProps { + item: StreamingContent; + onPress: (id: string, type: string) => void; +} + +const { width } = Dimensions.get('window'); +const POSTER_WIDTH = (width - 50) / 3; + +const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { + const [menuVisible, setMenuVisible] = useState(false); + const [localItem, setLocalItem] = useState(initialItem); + const [isWatched, setIsWatched] = useState(false); + const [imageLoaded, setImageLoaded] = useState(false); + const [imageError, setImageError] = useState(false); + + const handleLongPress = useCallback(() => { + setMenuVisible(true); + }, []); + + const handlePress = useCallback(() => { + onPress(localItem.id, localItem.type); + }, [localItem.id, localItem.type, onPress]); + + const handleOptionSelect = useCallback((option: string) => { + switch (option) { + case 'library': + if (localItem.inLibrary) { + catalogService.removeFromLibrary(localItem.type, localItem.id); + } else { + catalogService.addToLibrary(localItem); + } + break; + case 'watched': + setIsWatched(prev => !prev); + break; + case 'playlist': + break; + case 'share': + break; + } + }, [localItem]); + + const handleMenuClose = useCallback(() => { + setMenuVisible(false); + }, []); + + useEffect(() => { + setLocalItem(initialItem); + }, [initialItem]); + + useEffect(() => { + const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => { + const isInLibrary = libraryItems.some( + libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type + ); + setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary })); + }); + + return () => unsubscribe(); + }, [localItem.id, localItem.type]); + + return ( + <> + + + { + setImageLoaded(false); + setImageError(false); + }} + onLoadEnd={() => setImageLoaded(true)} + onError={() => { + setImageError(true); + setImageLoaded(true); + }} + /> + {(!imageLoaded || imageError) && ( + + {!imageError ? ( + + ) : ( + + )} + + )} + {isWatched && ( + + + + )} + {localItem.inLibrary && ( + + + + )} + + + + + + ); +}; + +const styles = StyleSheet.create({ + contentItem: { + width: POSTER_WIDTH, + aspectRatio: 2/3, + margin: 0, + borderRadius: 16, + overflow: 'hidden', + position: 'relative', + elevation: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + }, + contentItemContainer: { + width: '100%', + height: '100%', + borderRadius: 16, + overflow: 'hidden', + position: 'relative', + }, + poster: { + width: '100%', + height: '100%', + borderRadius: 16, + }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 16, + }, + watchedIndicator: { + position: 'absolute', + top: 8, + right: 8, + backgroundColor: colors.transparentDark, + borderRadius: 12, + padding: 2, + }, + libraryBadge: { + position: 'absolute', + top: 8, + left: 8, + backgroundColor: colors.transparentDark, + borderRadius: 8, + padding: 4, + }, +}); + +export default ContentItem; \ No newline at end of file diff --git a/src/components/home/DropUpMenu.tsx b/src/components/home/DropUpMenu.tsx new file mode 100644 index 00000000..74316552 --- /dev/null +++ b/src/components/home/DropUpMenu.tsx @@ -0,0 +1,257 @@ +import React, { useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + Modal, + Pressable, + TouchableOpacity, + useColorScheme, + Dimensions, + Platform +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { Image as ExpoImage } from 'expo-image'; +import { colors } from '../../styles/colors'; +import Animated, { + useAnimatedStyle, + withTiming, + useSharedValue, + interpolate, + Extrapolate, + runOnJS, +} from 'react-native-reanimated'; +import { + Gesture, + GestureDetector, + GestureHandlerRootView, +} from 'react-native-gesture-handler'; +import { StreamingContent } from '../../services/catalogService'; + +interface DropUpMenuProps { + visible: boolean; + onClose: () => void; + item: StreamingContent; + onOptionSelect: (option: string) => void; +} + +export const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => { + const translateY = useSharedValue(300); + const opacity = useSharedValue(0); + const isDarkMode = useColorScheme() === 'dark'; + const SNAP_THRESHOLD = 100; + + useEffect(() => { + if (visible) { + opacity.value = withTiming(1, { duration: 200 }); + translateY.value = withTiming(0, { duration: 300 }); + } else { + opacity.value = withTiming(0, { duration: 200 }); + translateY.value = withTiming(300, { duration: 300 }); + } + }, [visible]); + + const gesture = Gesture.Pan() + .onStart(() => { + // Store initial position if needed + }) + .onUpdate((event) => { + if (event.translationY > 0) { // Only allow dragging downwards + translateY.value = event.translationY; + opacity.value = interpolate( + event.translationY, + [0, 300], + [1, 0], + Extrapolate.CLAMP + ); + } + }) + .onEnd((event) => { + if (event.translationY > SNAP_THRESHOLD || event.velocityY > 500) { + translateY.value = withTiming(300, { duration: 300 }); + opacity.value = withTiming(0, { duration: 200 }); + runOnJS(onClose)(); + } else { + translateY.value = withTiming(0, { duration: 300 }); + opacity.value = withTiming(1, { duration: 200 }); + } + }); + + const overlayStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + + const menuStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + })); + + const menuOptions = [ + { + icon: item.inLibrary ? 'bookmark' : 'bookmark-border', + label: item.inLibrary ? 'Remove from Library' : 'Add to Library', + action: 'library' + }, + { + icon: 'check-circle', + label: 'Mark as Watched', + action: 'watched' + }, + { + icon: 'playlist-add', + label: 'Add to Playlist', + action: 'playlist' + }, + { + icon: 'share', + label: 'Share', + action: 'share' + } + ]; + + const backgroundColor = isDarkMode ? '#1A1A1A' : '#FFFFFF'; + + return ( + + + + + + + + + + + + {item.name} + + {item.year && ( + + {item.year} + + )} + + + + {menuOptions.map((option, index) => ( + { + onOptionSelect(option.action); + onClose(); + }} + > + + + {option.label} + + + ))} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + modalOverlay: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: colors.transparentDark, + }, + modalOverlayPressable: { + flex: 1, + }, + dragHandle: { + width: 40, + height: 4, + backgroundColor: colors.transparentLight, + borderRadius: 2, + alignSelf: 'center', + marginTop: 12, + marginBottom: 10, + }, + menuContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingBottom: Platform.select({ ios: 40, android: 24 }), + ...Platform.select({ + ios: { + shadowColor: colors.black, + shadowOffset: { width: 0, height: -3 }, + shadowOpacity: 0.1, + shadowRadius: 5, + }, + android: { + elevation: 5, + }, + }), + }, + menuHeader: { + flexDirection: 'row', + padding: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + }, + menuPoster: { + width: 60, + height: 90, + borderRadius: 12, + }, + menuTitleContainer: { + flex: 1, + marginLeft: 12, + justifyContent: 'center', + }, + menuTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 4, + }, + menuYear: { + fontSize: 14, + }, + menuOptions: { + paddingTop: 8, + }, + menuOption: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + lastMenuOption: { + borderBottomWidth: 0, + }, + menuOptionText: { + fontSize: 16, + marginLeft: 16, + }, +}); + +export default DropUpMenu; \ No newline at end of file diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx new file mode 100644 index 00000000..e2dc4136 --- /dev/null +++ b/src/components/home/FeaturedContent.tsx @@ -0,0 +1,436 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ImageBackground, + Dimensions, + ViewStyle, + TextStyle, + ImageStyle +} from 'react-native'; +import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Image as ExpoImage } from 'expo-image'; +import { MaterialIcons } from '@expo/vector-icons'; +import { colors } from '../../styles/colors'; +import Animated, { + FadeIn, + useAnimatedStyle, + useSharedValue, + withTiming, + Easing, + withDelay +} from 'react-native-reanimated'; +import { StreamingContent } from '../../services/catalogService'; +import { SkeletonFeatured } from './SkeletonLoaders'; + +interface FeaturedContentProps { + featuredContent: StreamingContent | null; + isSaved: boolean; + handleSaveToLibrary: () => void; +} + +// Cache to store preloaded images +const imageCache: Record = {}; + +const { width, height } = Dimensions.get('window'); + +const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => { + const navigation = useNavigation>(); + const [posterLoaded, setPosterLoaded] = useState(false); + const [logoLoaded, setLogoLoaded] = useState(false); + const [imageError, setImageError] = useState(false); + const [bannerUrl, setBannerUrl] = useState(null); + const [logoUrl, setLogoUrl] = useState(null); + const prevContentIdRef = useRef(null); + + // Animation values + const posterOpacity = useSharedValue(0); + const logoOpacity = useSharedValue(0); + const contentOpacity = useSharedValue(0); + + const posterAnimatedStyle = useAnimatedStyle(() => ({ + opacity: posterOpacity.value, + })); + + const logoAnimatedStyle = useAnimatedStyle(() => ({ + opacity: logoOpacity.value, + })); + + const contentAnimatedStyle = useAnimatedStyle(() => ({ + opacity: contentOpacity.value, + })); + + // Preload the image + const preloadImage = async (url: string): Promise => { + if (!url) return false; + + // If already cached, return true immediately + if (imageCache[url]) return true; + + try { + await ExpoImage.prefetch(url); + imageCache[url] = true; + return true; + } catch (error) { + console.error('Error preloading image:', error); + return false; + } + }; + + // Load poster first, then logo + useEffect(() => { + if (!featuredContent) return; + + const posterUrl = featuredContent.banner || featuredContent.poster; + const titleLogo = featuredContent.logo; + const contentId = featuredContent.id; + + // Reset states for new content + if (contentId !== prevContentIdRef.current) { + setPosterLoaded(false); + setLogoLoaded(false); + setImageError(false); + posterOpacity.value = 0; + logoOpacity.value = 0; + contentOpacity.value = 0; + } + + prevContentIdRef.current = contentId; + + // Sequential loading: poster first, then logo + const loadImages = async () => { + // Step 1: Load poster + if (posterUrl) { + setBannerUrl(posterUrl); + const posterSuccess = await preloadImage(posterUrl); + + if (posterSuccess) { + setPosterLoaded(true); + // Fade in poster + posterOpacity.value = withTiming(1, { + duration: 600, + easing: Easing.bezier(0.25, 0.1, 0.25, 1) + }); + + // After poster loads, start showing content with slight delay + contentOpacity.value = withDelay(150, withTiming(1, { + duration: 400, + easing: Easing.bezier(0.25, 0.1, 0.25, 1) + })); + } else { + setImageError(true); + } + } + + // Step 2: Load logo if available + if (titleLogo) { + setLogoUrl(titleLogo); + const logoSuccess = await preloadImage(titleLogo); + + if (logoSuccess) { + setLogoLoaded(true); + // Fade in logo with delay after poster + logoOpacity.value = withDelay(300, withTiming(1, { + duration: 500, + easing: Easing.bezier(0.25, 0.1, 0.25, 1) + })); + } + } + }; + + loadImages(); + }, [featuredContent?.id]); + + // Preload next content + useEffect(() => { + if (!featuredContent || !posterLoaded) return; + + // After current poster loads, prefetch for potential next items + const preloadNextContent = async () => { + // Simulate preloading next item (in a real app, you'd get this from allFeaturedContent) + if (featuredContent.type === 'movie' && featuredContent.id) { + // Try to preload related content by ID pattern + const relatedIds = [ + `tmdb:${parseInt(featuredContent.id.split(':')[1] || '0') + 1}`, + `tmdb:${parseInt(featuredContent.id.split(':')[1] || '0') + 2}` + ]; + + for (const id of relatedIds) { + // This is just a simulation - in real app you'd have actual next content URLs + const potentialNextPoster = featuredContent.poster?.replace( + featuredContent.id, + id + ); + + if (potentialNextPoster) { + await preloadImage(potentialNextPoster); + } + } + } + }; + + preloadNextContent(); + }, [posterLoaded, featuredContent]); + + if (!featuredContent) { + return ; + } + + return ( + { + navigation.navigate('Metadata', { + id: featuredContent.id, + type: featuredContent.type + }); + }} + style={styles.featuredContainer as ViewStyle} + > + + + + + {featuredContent.logo ? ( + + + + ) : ( + {featuredContent.name} + )} + + {featuredContent.genres?.slice(0, 3).map((genre, index, array) => ( + + {genre} + {index < array.length - 1 && ( + + )} + + ))} + + + + + + {isSaved ? "Saved" : "Save"} + + + + { + if (featuredContent) { + navigation.navigate('Streams', { + id: featuredContent.id, + type: featuredContent.type + }); + } + }} + > + + Play + + + { + if (featuredContent) { + navigation.navigate('Metadata', { + id: featuredContent.id, + type: featuredContent.type + }); + } + }} + > + + Info + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + featuredContainer: { + width: '100%', + height: height * 0.6, + marginTop: 0, + marginBottom: 8, + position: 'relative', + backgroundColor: colors.elevation1, + }, + imageContainer: { + width: '100%', + height: '100%', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 2, + }, + featuredImage: { + width: '100%', + height: '100%', + }, + backgroundFallback: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: colors.elevation1, + justifyContent: 'center', + alignItems: 'center', + zIndex: 1, + }, + featuredGradient: { + width: '100%', + height: '100%', + justifyContent: 'space-between', + }, + featuredContentContainer: { + flex: 1, + justifyContent: 'flex-end', + paddingHorizontal: 16, + paddingBottom: 20, + }, + featuredLogo: { + width: width * 0.7, + height: 100, + marginBottom: 0, + alignSelf: 'center', + }, + featuredTitleText: { + color: colors.highEmphasis, + fontSize: 28, + fontWeight: '900', + marginBottom: 8, + textShadowColor: 'rgba(0,0,0,0.6)', + textShadowOffset: { width: 0, height: 2 }, + textShadowRadius: 4, + textAlign: 'center', + paddingHorizontal: 16, + }, + genreContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 16, + flexWrap: 'wrap', + gap: 4, + }, + genreText: { + color: colors.white, + fontSize: 14, + fontWeight: '500', + opacity: 0.9, + }, + genreDot: { + color: colors.white, + fontSize: 14, + fontWeight: '500', + opacity: 0.6, + marginHorizontal: 4, + }, + featuredButtons: { + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-evenly', + width: '100%', + flex: 1, + maxHeight: 65, + paddingTop: 16, + }, + playButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 14, + paddingHorizontal: 32, + borderRadius: 30, + backgroundColor: colors.white, + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + flex: 0, + width: 150, + }, + myListButton: { + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + padding: 0, + gap: 6, + width: 44, + height: 44, + flex: undefined, + }, + infoButton: { + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + padding: 0, + gap: 4, + width: 44, + height: 44, + flex: undefined, + }, + playButtonText: { + color: colors.black, + fontWeight: '600', + marginLeft: 8, + fontSize: 16, + }, + myListButtonText: { + color: colors.white, + fontSize: 12, + fontWeight: '500', + }, + infoButtonText: { + color: colors.white, + fontSize: 12, + fontWeight: '500', + }, +}); + +export default FeaturedContent; \ No newline at end of file diff --git a/src/components/home/SkeletonLoaders.tsx b/src/components/home/SkeletonLoaders.tsx new file mode 100644 index 00000000..01278996 --- /dev/null +++ b/src/components/home/SkeletonLoaders.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { View, Text, ActivityIndicator, StyleSheet, Dimensions } from 'react-native'; +import { colors } from '../../styles/colors'; + +const { height } = Dimensions.get('window'); + +export const SkeletonCatalog = () => ( + + + + + +); + +export const SkeletonFeatured = () => ( + + + Loading featured content... + +); + +const styles = StyleSheet.create({ + catalogContainer: { + marginBottom: 24, + paddingTop: 0, + marginTop: 16, + }, + loadingPlaceholder: { + height: 200, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.elevation1, + borderRadius: 12, + marginHorizontal: 16, + }, + featuredLoadingContainer: { + height: height * 0.4, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.elevation1, + }, + loadingText: { + color: colors.textMuted, + marginTop: 12, + fontSize: 14, + }, + skeletonBox: { + backgroundColor: colors.elevation2, + borderRadius: 16, + overflow: 'hidden', + }, + skeletonFeatured: { + width: '100%', + height: height * 0.6, + backgroundColor: colors.elevation2, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + marginBottom: 0, + }, + skeletonPoster: { + backgroundColor: colors.elevation1, + marginHorizontal: 4, + borderRadius: 16, + }, +}); + +export default { + SkeletonCatalog, + SkeletonFeatured +}; \ No newline at end of file diff --git a/src/hooks/useFeaturedContent.ts b/src/hooks/useFeaturedContent.ts index 72c8727d..827cfa29 100644 --- a/src/hooks/useFeaturedContent.ts +++ b/src/hooks/useFeaturedContent.ts @@ -6,11 +6,22 @@ import * as Haptics from 'expo-haptics'; import { useGenres } from '../contexts/GenreContext'; import { useSettings, settingsEmitter } from './useSettings'; +// Create a persistent store outside of the hook to maintain state between navigation +const persistentStore = { + featuredContent: null as StreamingContent | null, + allFeaturedContent: [] as StreamingContent[], + lastFetchTime: 0, + isFirstLoad: true +}; + +// Cache timeout in milliseconds (e.g., 5 minutes) +const CACHE_TIMEOUT = 5 * 60 * 1000; + export function useFeaturedContent() { - const [featuredContent, setFeaturedContent] = useState(null); - const [allFeaturedContent, setAllFeaturedContent] = useState([]); + const [featuredContent, setFeaturedContent] = useState(persistentStore.featuredContent); + const [allFeaturedContent, setAllFeaturedContent] = useState(persistentStore.allFeaturedContent); const [isSaved, setIsSaved] = useState(false); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(persistentStore.isFirstLoad); const currentIndexRef = useRef(0); const abortControllerRef = useRef(null); const { settings } = useSettings(); @@ -32,7 +43,23 @@ export function useFeaturedContent() { } }, []); - const loadFeaturedContent = useCallback(async () => { + const loadFeaturedContent = useCallback(async (forceRefresh = false) => { + // Check if we should use cached data + const now = Date.now(); + const cacheAge = now - persistentStore.lastFetchTime; + + if (!forceRefresh && + persistentStore.featuredContent && + persistentStore.allFeaturedContent.length > 0 && + cacheAge < CACHE_TIMEOUT) { + // Use cached data + setFeaturedContent(persistentStore.featuredContent); + setAllFeaturedContent(persistentStore.allFeaturedContent); + setLoading(false); + persistentStore.isFirstLoad = false; + return; + } + setLoading(true); cleanup(); abortControllerRef.current = new AbortController(); @@ -101,13 +128,10 @@ export function useFeaturedContent() { const filteredCatalogs = selectedCatalogs && selectedCatalogs.length > 0 ? catalogs.filter(catalog => { const catalogId = `${catalog.addon}:${catalog.type}:${catalog.id}`; - console.log(`Checking catalog: ${catalogId}, selected: ${selectedCatalogs.includes(catalogId)}`); return selectedCatalogs.includes(catalogId); }) : catalogs; // Use all catalogs if none specifically selected - console.log(`Original catalogs: ${catalogs.length}, Filtered catalogs: ${filteredCatalogs.length}`); - // Flatten all catalog items into a single array, filter out items without posters const allItems = filteredCatalogs.flatMap(catalog => catalog.items) .filter(item => item.poster) @@ -122,16 +146,23 @@ export function useFeaturedContent() { if (signal.aborted) return; + // Update persistent store with the new data + persistentStore.allFeaturedContent = formattedContent; + persistentStore.lastFetchTime = now; + persistentStore.isFirstLoad = false; + setAllFeaturedContent(formattedContent); if (formattedContent.length > 0) { + persistentStore.featuredContent = formattedContent[0]; setFeaturedContent(formattedContent[0]); currentIndexRef.current = 0; } else { + persistentStore.featuredContent = null; setFeaturedContent(null); } } catch (error) { - if (signal.aborted) { + if (signal.aborted) { logger.info('Featured content fetch aborted'); } else { logger.error('Failed to load featured content:', error); @@ -147,12 +178,17 @@ export function useFeaturedContent() { // Load featured content initially and when content source changes useEffect(() => { - // Force a full refresh to get updated logos - if (contentSource === 'tmdb') { + const shouldForceRefresh = contentSource === 'tmdb' && + contentSource !== persistentStore.featuredContent?.type; + + if (shouldForceRefresh) { setAllFeaturedContent([]); setFeaturedContent(null); + persistentStore.allFeaturedContent = []; + persistentStore.featuredContent = null; } - loadFeaturedContent(); + + loadFeaturedContent(shouldForceRefresh); }, [loadFeaturedContent, contentSource, selectedCatalogs]); useEffect(() => { @@ -184,7 +220,10 @@ export function useFeaturedContent() { const rotateContent = () => { currentIndexRef.current = (currentIndexRef.current + 1) % allFeaturedContent.length; if (allFeaturedContent[currentIndexRef.current]) { - setFeaturedContent(allFeaturedContent[currentIndexRef.current]); + const newContent = allFeaturedContent[currentIndexRef.current]; + setFeaturedContent(newContent); + // Also update the persistent store + persistentStore.featuredContent = newContent; } }; @@ -217,11 +256,14 @@ export function useFeaturedContent() { } }, [featuredContent, isSaved]); + // Function to force a refresh if needed + const refreshFeatured = useCallback(() => loadFeaturedContent(true), [loadFeaturedContent]); + return { featuredContent, loading, isSaved, handleSaveToLibrary, - refreshFeatured: loadFeaturedContent + refreshFeatured }; } \ No newline at end of file diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 436eaf20..777732c8 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -55,6 +55,10 @@ import { storageService } from '../services/storageService'; import { useHomeCatalogs } from '../hooks/useHomeCatalogs'; import { useFeaturedContent } from '../hooks/useFeaturedContent'; import { useSettings, settingsEmitter } from '../hooks/useSettings'; +import FeaturedContent from '../components/home/FeaturedContent'; +import CatalogSection from '../components/home/CatalogSection'; +import { SkeletonFeatured } from '../components/home/SkeletonLoaders'; +import homeStyles from '../styles/homeStyles'; // Define interfaces for our data interface Category { @@ -348,13 +352,6 @@ const SkeletonCatalog = () => ( ); -const SkeletonFeatured = () => ( - - - Loading featured content... - -); - const HomeScreen = () => { const navigation = useNavigation>(); const isDarkMode = useColorScheme() === 'dark'; @@ -390,15 +387,15 @@ const HomeScreen = () => { setFeaturedContentSource(settings.featuredContentSource); }, [settings]); - // If featured content source changes, refresh featured content with debouncing + // Update the featured content refresh logic to handle persistence useEffect(() => { - if (showHeroSection) { + if (showHeroSection && featuredContentSource !== settings.featuredContentSource) { // Clear any existing timeout if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); } - // Set a new timeout to debounce the refresh + // Set a new timeout to debounce the refresh - only when settings actually change refreshTimeoutRef.current = setTimeout(() => { refreshFeatured(); refreshTimeoutRef.current = null; @@ -411,14 +408,14 @@ const HomeScreen = () => { clearTimeout(refreshTimeoutRef.current); } }; - }, [featuredContentSource, showHeroSection, refreshFeatured]); + }, [featuredContentSource, settings.featuredContentSource, showHeroSection, refreshFeatured]); useFocusEffect( useCallback(() => { const statusBarConfig = () => { StatusBar.setBarStyle("light-content"); - StatusBar.setTranslucent(true); - StatusBar.setBackgroundColor('transparent'); + StatusBar.setTranslucent(true); + StatusBar.setBackgroundColor('transparent'); }; statusBarConfig(); @@ -476,7 +473,8 @@ const HomeScreen = () => { continueWatchingRef.current?.refresh(), ]; - // Only refresh featured content if hero section is enabled + // Only refresh featured content if hero section is enabled, + // and force refresh to bypass the cache if (showHeroSection) { refreshTasks.push(refreshFeatured()); } @@ -526,198 +524,24 @@ const HomeScreen = () => { }; }, [navigation, refreshContinueWatching]); - const renderFeaturedContent = () => { - if (!featuredContent) { - return ; - } - - return ( - { - if (featuredContent) { - navigation.navigate('Metadata', { - id: featuredContent.id, - type: featuredContent.type - }); - } - }} - style={styles.featuredContainer} - > - - - - {featuredContent.logo ? ( - - ) : ( - {featuredContent.name} - )} - - {featuredContent.genres?.slice(0, 3).map((genre, index, array) => ( - - {genre} - {index < array.length - 1 && ( - - )} - - ))} - - - - - - {isSaved ? "Saved" : "Save"} - - - - { - if (featuredContent) { - navigation.navigate('Streams', { - id: featuredContent.id, - type: featuredContent.type - }); - } - }} - > - - Play - - - { - if (featuredContent) { - navigation.navigate('Metadata', { - id: featuredContent.id, - type: featuredContent.type - }); - } - }} - > - - Info - - - - - - - ); - }; - - const renderContentItem = useCallback(({ item, index }: { item: StreamingContent, index: number }) => { - return ( - - - - ); - }, [handleContentPress]); - - const renderCatalog = ({ item }: { item: CatalogContent }) => { - return ( - - - - {item.name} - - - - navigation.navigate('Catalog', { - id: item.id, - type: item.type, - addonId: item.addon - }) - } - style={styles.seeAllButton} - > - See More - - - - - renderContentItem({ item, index })} - keyExtractor={(item) => `${item.id}-${item.type}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.catalogList} - snapToInterval={POSTER_WIDTH + 12} - decelerationRate="fast" - snapToAlignment="start" - ItemSeparatorComponent={() => } - initialNumToRender={4} - maxToRenderPerBatch={4} - windowSize={5} - removeClippedSubviews={Platform.OS === 'android'} - getItemLayout={(data, index) => ({ - length: POSTER_WIDTH + 12, - offset: (POSTER_WIDTH + 12) * index, - index, - })} - /> - - ); - }; - if (isLoading && !isRefreshing) { return ( - + - + - Loading your content... + Loading your content... ); } return ( - + { /> } contentContainerStyle={[ - styles.scrollContent, + homeStyles.scrollContent, { paddingTop: Platform.OS === 'ios' ? 0 : 0 } ]} showsVerticalScrollIndicator={false} > - {showHeroSection && renderFeaturedContent()} + {showHeroSection && ( + + )} @@ -753,22 +583,22 @@ const HomeScreen = () => { {catalogs.length > 0 ? ( catalogs.map((catalog, index) => ( - {renderCatalog({ item: catalog })} + )) ) : ( !catalogsLoading && ( - + No content available navigation.navigate('Settings')} > - Add Catalogs + Add Catalogs ) diff --git a/src/styles/homeStyles.ts b/src/styles/homeStyles.ts new file mode 100644 index 00000000..b8b05660 --- /dev/null +++ b/src/styles/homeStyles.ts @@ -0,0 +1,55 @@ +import { StyleSheet, Dimensions, Platform } from 'react-native'; +import { colors } from './colors'; + +const { width, height } = Dimensions.get('window'); +export const POSTER_WIDTH = (width - 50) / 3; + +export const homeStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + scrollContent: { + paddingBottom: 40, + }, + loadingMainContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingBottom: 40, + }, + loadingText: { + color: colors.textMuted, + marginTop: 12, + fontSize: 14, + }, + emptyCatalog: { + padding: 32, + alignItems: 'center', + backgroundColor: colors.elevation1, + margin: 16, + borderRadius: 16, + }, + addCatalogButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.primary, + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 30, + marginTop: 16, + elevation: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 3, + }, + addCatalogButtonText: { + color: colors.white, + fontSize: 14, + fontWeight: '600', + marginLeft: 8, + }, +}); + +export default homeStyles; \ No newline at end of file