diff --git a/README.md b/README.md index e0eff14..c3f0be0 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,15 @@ An app I built with React Native/Expo for browsing and watching movies & shows. Built for iOS and Android. -## Key Features ✨ +## Key Features * **Home Screen:** Highlights new content, your watch history, and content categories. * **Discover:** Browse trending and popular movies & TV shows. * **Details:** Displays detailed info (descriptions, cast, ratings). -* **Video Player:** Integrated player that remembers playback progress. +* **Video Player:** Integrated player(still broken on IOS,supports External PLayer for now). * **Stream Finding:** Finds available streams using Stremio addons. * **Search:** Quickly find specific movies or shows. -* **Trakt Sync:** Option to connect your Trakt.tv account. +* **Trakt Sync:** Planned integration (coming soon). * **Addon Management:** Add and manage your Stremio addons. * **UI:** Focuses on a clean, interactive user experience. @@ -28,7 +28,7 @@ Built for iOS and Android. | **Metadata** | **Seasons & Episodes** | **Rating** | | ![Metadata](src/assets/metadascreen.jpg) | ![Seasons](src/assets/seasonandepisode.jpg)| ![Rating](src/assets/ratingscreen.jpg) | -## Wanna run it? 🚀 +## Development 1. You'll need Node.js, npm/yarn, and the Expo Go app (or native build tools like Android Studio/Xcode). 2. `git clone https://github.com/nayifleo1/NuvioExpo.git` @@ -37,11 +37,11 @@ Built for iOS and Android. 5. `npx expo start` (Easiest way: Scan QR code with Expo Go app) * Or `npx expo run:android` / `npx expo run:ios` for native builds. -## Found a bug or have an idea? 🐛 +## Found a bug or have an idea? Great! Please open an [Issue on GitHub](https://github.com/nayifleo1/NuvioExpo/issues). Describe the problem or your suggestion. -## Want to contribute? 🤝 +## Contribution Contributions are welcome! Fork the repository, make your changes, and submit a Pull Request. diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx new file mode 100644 index 0000000..a1da2c8 --- /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 0000000..47e810d --- /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 0000000..7431655 --- /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 0000000..405208d --- /dev/null +++ b/src/components/home/FeaturedContent.tsx @@ -0,0 +1,391 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ImageBackground, + Dimensions, + ViewStyle, + TextStyle, + ImageStyle, + ActivityIndicator, + Platform +} 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 [logoUrl, setLogoUrl] = useState(null); + const [bannerUrl, setBannerUrl] = useState(null); + const prevContentIdRef = useRef(null); + + // Animation values + const posterOpacity = useSharedValue(0); + const logoOpacity = useSharedValue(0); + const contentOpacity = useSharedValue(1); // Start visible + const buttonsOpacity = useSharedValue(1); + + const posterAnimatedStyle = useAnimatedStyle(() => ({ + opacity: posterOpacity.value, + })); + + const logoAnimatedStyle = useAnimatedStyle(() => ({ + opacity: logoOpacity.value, + })); + + const contentAnimatedStyle = useAnimatedStyle(() => ({ + opacity: contentOpacity.value, + })); + + const buttonsAnimatedStyle = useAnimatedStyle(() => ({ + opacity: buttonsOpacity.value, + })); + + // Preload the image + const preloadImage = async (url: string): Promise => { + if (!url) return false; + 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 and 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) { + posterOpacity.value = 0; + logoOpacity.value = 0; + } + + prevContentIdRef.current = contentId; + + // Set URLs immediately for instant display + if (posterUrl) setBannerUrl(posterUrl); + if (titleLogo) setLogoUrl(titleLogo); + + // Load images in background + const loadImages = async () => { + // Load poster + if (posterUrl) { + const posterSuccess = await preloadImage(posterUrl); + if (posterSuccess) { + posterOpacity.value = withTiming(1, { + duration: 600, + easing: Easing.bezier(0.25, 0.1, 0.25, 1) + }); + } + } + + // Load logo if available + if (titleLogo) { + const logoSuccess = await preloadImage(titleLogo); + if (logoSuccess) { + logoOpacity.value = withDelay(300, withTiming(1, { + duration: 500, + easing: Easing.bezier(0.25, 0.1, 0.25, 1) + })); + } + } + }; + + loadImages(); + }, [featuredContent?.id]); + + 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.48, + 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: 4, + }, + 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: 4, + 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: 55, + paddingTop: 0, + }, + 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 0000000..0127899 --- /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 72c8727..249fefe 100644 --- a/src/hooks/useFeaturedContent.ts +++ b/src/hooks/useFeaturedContent.ts @@ -6,11 +6,28 @@ 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, + // Track last used settings to detect changes on app restart + lastSettings: { + showHeroSection: true, + featuredContentSource: 'tmdb' as 'tmdb' | 'catalogs', + selectedHeroCatalogs: [] as string[] + } +}; + +// 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(); @@ -19,7 +36,7 @@ export function useFeaturedContent() { const { genreMap, loadingGenres } = useGenres(); - // Update local state when settings change + // Simple update for state variables useEffect(() => { setContentSource(settings.featuredContentSource); setSelectedCatalogs(settings.selectedHeroCatalogs || []); @@ -32,7 +49,33 @@ export function useFeaturedContent() { } }, []); - const loadFeaturedContent = useCallback(async () => { + const loadFeaturedContent = useCallback(async (forceRefresh = false) => { + // First, ensure contentSource matches current settings (could be outdated due to async updates) + if (contentSource !== settings.featuredContentSource) { + console.log(`Updating content source from ${contentSource} to ${settings.featuredContentSource}`); + setContentSource(settings.featuredContentSource); + // We return here and let the effect triggered by contentSource change handle the loading + return; + } + + // 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 + console.log('Using cached featured content data'); + setFeaturedContent(persistentStore.featuredContent); + setAllFeaturedContent(persistentStore.allFeaturedContent); + setLoading(false); + persistentStore.isFirstLoad = false; + return; + } + + console.log(`Loading featured content from ${contentSource}`); setLoading(true); cleanup(); abortControllerRef.current = new AbortController(); @@ -101,13 +144,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 +162,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); @@ -145,14 +192,75 @@ export function useFeaturedContent() { } }, [cleanup, genreMap, loadingGenres, contentSource, selectedCatalogs]); + // Check for settings changes, including during app restart + useEffect(() => { + // Check if settings changed while app was closed + const settingsChanged = + persistentStore.lastSettings.showHeroSection !== settings.showHeroSection || + persistentStore.lastSettings.featuredContentSource !== settings.featuredContentSource || + JSON.stringify(persistentStore.lastSettings.selectedHeroCatalogs) !== JSON.stringify(settings.selectedHeroCatalogs); + + // Update our tracking of last used settings + persistentStore.lastSettings = { + showHeroSection: settings.showHeroSection, + featuredContentSource: settings.featuredContentSource, + selectedHeroCatalogs: [...settings.selectedHeroCatalogs] + }; + + // Force refresh if settings changed during app restart + if (settingsChanged) { + loadFeaturedContent(true); + } + }, [settings, loadFeaturedContent]); + + // Subscribe directly to settings emitter for immediate updates + useEffect(() => { + const handleSettingsChange = () => { + // Only refresh if current content source is different from settings + // This prevents duplicate refreshes when HomeScreen also handles this event + if (contentSource !== settings.featuredContentSource) { + console.log('Content source changed, refreshing featured content'); + console.log('Current content source:', contentSource); + console.log('New settings source:', settings.featuredContentSource); + // Content source will be updated in the next render cycle due to state updates + // No need to call loadFeaturedContent here as it will be triggered by contentSource change + } else if ( + contentSource === 'catalogs' && + JSON.stringify(selectedCatalogs) !== JSON.stringify(settings.selectedHeroCatalogs) + ) { + // Only refresh if using catalogs and selected catalogs changed + console.log('Selected catalogs changed, refreshing featured content'); + loadFeaturedContent(true); + } + }; + + // Subscribe to settings changes + const unsubscribe = settingsEmitter.addListener(handleSettingsChange); + + return unsubscribe; + }, [loadFeaturedContent, settings, contentSource, selectedCatalogs]); + // Load featured content initially and when content source changes useEffect(() => { - // Force a full refresh to get updated logos - if (contentSource === 'tmdb') { + // Force refresh when switching to catalogs or when catalog selection changes + if (contentSource === 'catalogs') { + // Clear cache when switching to catalogs mode setAllFeaturedContent([]); setFeaturedContent(null); + persistentStore.allFeaturedContent = []; + persistentStore.featuredContent = null; + loadFeaturedContent(true); + } else if (contentSource === 'tmdb' && contentSource !== persistentStore.featuredContent?.type) { + // Clear cache when switching to TMDB mode from catalogs + setAllFeaturedContent([]); + setFeaturedContent(null); + persistentStore.allFeaturedContent = []; + persistentStore.featuredContent = null; + loadFeaturedContent(true); + } else { + // Normal load (might use cache if available) + loadFeaturedContent(false); } - loadFeaturedContent(); }, [loadFeaturedContent, contentSource, selectedCatalogs]); useEffect(() => { @@ -184,7 +292,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 +328,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/hooks/useSettings.ts b/src/hooks/useSettings.ts index 426f44e..1899269 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -84,8 +84,11 @@ export const useSettings = () => { try { await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings)); setSettings(newSettings); + console.log(`Setting updated: ${key}`, value); + // Notify all subscribers that settings have changed (if requested) if (emitEvent) { + console.log('Emitting settings change event'); settingsEmitter.emit(); } } catch (error) { diff --git a/src/screens/HeroCatalogsScreen.tsx b/src/screens/HeroCatalogsScreen.tsx index 2fa27d7..6c84b81 100644 --- a/src/screens/HeroCatalogsScreen.tsx +++ b/src/screens/HeroCatalogsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState, useRef } from 'react'; import { View, Text, @@ -12,9 +12,10 @@ import { useColorScheme, ActivityIndicator, Alert, + Animated } from 'react-native'; -import { useSettings } from '../hooks/useSettings'; -import { useNavigation } from '@react-navigation/native'; +import { useSettings, settingsEmitter } from '../hooks/useSettings'; +import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import { colors } from '../styles/colors'; import { catalogService, StreamingAddon } from '../services/catalogService'; @@ -38,6 +39,58 @@ const HeroCatalogsScreen: React.FC = () => { const [catalogs, setCatalogs] = useState([]); const [selectedCatalogs, setSelectedCatalogs] = useState(settings.selectedHeroCatalogs || []); const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames(); + const [showSavedIndicator, setShowSavedIndicator] = useState(false); + const fadeAnim = useRef(new Animated.Value(0)).current; + + // Ensure selected catalogs state is refreshed whenever the screen gains focus + useFocusEffect( + useCallback(() => { + setSelectedCatalogs(settings.selectedHeroCatalogs || []); + }, [settings.selectedHeroCatalogs]) + ); + + // Subscribe to settings changes + useEffect(() => { + const unsubscribe = settingsEmitter.addListener(() => { + // Refresh selected catalogs when settings change + setSelectedCatalogs(settings.selectedHeroCatalogs || []); + }); + + return unsubscribe; + }, [settings.selectedHeroCatalogs]); + + // Fade in/out animation for the "Changes saved" indicator + useEffect(() => { + if (showSavedIndicator) { + Animated.sequence([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true + }), + Animated.delay(1500), + Animated.timing(fadeAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true + }) + ]).start(() => setShowSavedIndicator(false)); + } + }, [showSavedIndicator, fadeAnim]); + + const handleSave = useCallback(() => { + // First update the settings + updateSetting('selectedHeroCatalogs', selectedCatalogs); + + // Show the confirmation indicator + setShowSavedIndicator(true); + + // Short delay before navigating back to allow settings to save + // and the user to see the confirmation message + setTimeout(() => { + navigation.goBack(); + }, 800); + }, [navigation, selectedCatalogs, updateSetting]); const handleBack = useCallback(() => { navigation.goBack(); @@ -84,11 +137,6 @@ const HeroCatalogsScreen: React.FC = () => { setSelectedCatalogs([]); }, []); - const handleSave = useCallback(() => { - updateSetting('selectedHeroCatalogs', selectedCatalogs); - navigation.goBack(); - }, [navigation, selectedCatalogs, updateSetting]); - const toggleCatalog = useCallback((catalogId: string) => { setSelectedCatalogs(prev => { if (prev.includes(catalogId)) { @@ -127,6 +175,21 @@ const HeroCatalogsScreen: React.FC = () => { + {/* Saved indicator */} + + + Settings Saved + + {loading || isLoadingCustomNames ? ( @@ -153,13 +216,14 @@ const HeroCatalogsScreen: React.FC = () => { style={[styles.saveButton, { backgroundColor: colors.primary }]} onPress={handleSave} > + Save - Select which catalogs to display in the hero section. If none are selected, all catalogs will be used. + Select which catalogs to display in the hero section. If none are selected, all catalogs will be used. Don't forget to press Save when you're done. @@ -256,6 +320,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 12, justifyContent: 'space-between', + alignItems: 'center', }, actionButton: { paddingHorizontal: 12, @@ -271,12 +336,25 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8, + backgroundColor: colors.primary, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + minWidth: 100, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.2, + shadowRadius: 1.5, }, saveButtonText: { color: colors.white, fontSize: 14, fontWeight: '600', }, + saveIcon: { + marginRight: 6, + }, infoCard: { marginHorizontal: 16, marginBottom: 16, @@ -320,6 +398,28 @@ const styles = StyleSheet.create({ fontSize: 14, marginTop: 2, }, + savedIndicator: { + position: 'absolute', + top: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 60 : 90, + alignSelf: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 24, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, + elevation: 5, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + }, + savedIndicatorText: { + color: '#FFFFFF', + marginLeft: 6, + fontWeight: '600', + }, }); export default HeroCatalogsScreen; \ No newline at end of file diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 436eaf2..977df42 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,35 +387,40 @@ const HomeScreen = () => { setFeaturedContentSource(settings.featuredContentSource); }, [settings]); - // If featured content source changes, refresh featured content with debouncing + // Subscribe directly to settings emitter for immediate updates useEffect(() => { - if (showHeroSection) { - // Clear any existing timeout - if (refreshTimeoutRef.current) { - clearTimeout(refreshTimeoutRef.current); - } + const handleSettingsChange = () => { + setShowHeroSection(settings.showHeroSection); + setFeaturedContentSource(settings.featuredContentSource); - // Set a new timeout to debounce the refresh - refreshTimeoutRef.current = setTimeout(() => { - refreshFeatured(); - refreshTimeoutRef.current = null; - }, 300); + // The featured content refresh is now handled by the useFeaturedContent hook + // No need to call refreshFeatured() here to avoid duplicate refreshes + }; + + // Subscribe to settings changes + const unsubscribe = settingsEmitter.addListener(handleSettingsChange); + + return unsubscribe; + }, [settings]); + + // Update the featured content refresh logic to handle persistence + useEffect(() => { + // This effect was causing duplicate refreshes - it's now handled in useFeaturedContent + // We'll keep it just to sync the local state with settings + if (showHeroSection && featuredContentSource !== settings.featuredContentSource) { + // Just update the local state + setFeaturedContentSource(settings.featuredContentSource); } - // Cleanup the timeout on unmount - return () => { - if (refreshTimeoutRef.current) { - clearTimeout(refreshTimeoutRef.current); - } - }; - }, [featuredContentSource, showHeroSection, refreshFeatured]); + // No timeout needed since we're not refreshing here + }, [settings.featuredContentSource, showHeroSection]); useFocusEffect( useCallback(() => { const statusBarConfig = () => { StatusBar.setBarStyle("light-content"); - StatusBar.setTranslucent(true); - StatusBar.setBackgroundColor('transparent'); + StatusBar.setTranslucent(true); + StatusBar.setBackgroundColor('transparent'); }; statusBarConfig(); @@ -476,7 +478,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 +529,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, - { paddingTop: Platform.OS === 'ios' ? 0 : 0 } + homeStyles.scrollContent, + { paddingTop: Platform.OS === 'ios' ? 39 : 90 } ]} showsVerticalScrollIndicator={false} > - {showHeroSection && renderFeaturedContent()} + {showHeroSection && ( + + )} @@ -753,22 +589,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/screens/HomeScreenSettings.tsx b/src/screens/HomeScreenSettings.tsx index acbd34f..9b0d9b0 100644 --- a/src/screens/HomeScreenSettings.tsx +++ b/src/screens/HomeScreenSettings.tsx @@ -249,8 +249,9 @@ const HomeScreenSettings: React.FC = () => { icon="settings-input-component" isDarkMode={isDarkMode} renderControl={() => } + isLast={!settings.showHeroSection || settings.featuredContentSource !== 'catalogs'} /> - {settings.featuredContentSource === 'catalogs' && ( + {settings.showHeroSection && settings.featuredContentSource === 'catalogs' && ( { isLast={true} /> )} - {settings.featuredContentSource !== 'catalogs' && ( - // Placeholder to maintain layout - )} {settings.showHeroSection && ( @@ -271,7 +269,10 @@ const HomeScreenSettings: React.FC = () => { handleUpdateSetting('featuredContentSource', 'tmdb')} + onPress={() => { + console.log('Selected TMDB source'); + handleUpdateSetting('featuredContentSource', 'tmdb'); + }} label="TMDB Trending Movies" /> @@ -284,7 +285,10 @@ const HomeScreenSettings: React.FC = () => { handleUpdateSetting('featuredContentSource', 'catalogs')} + onPress={() => { + console.log('Selected Catalogs source'); + handleUpdateSetting('featuredContentSource', 'catalogs'); + }} label="Installed Catalogs" /> diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index e76bef4..6c07846 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -147,12 +147,14 @@ class CatalogService { async getHomeCatalogs(): Promise { const addons = await this.getAllAddons(); - const catalogs: CatalogContent[] = []; - + // Load enabled/disabled settings const catalogSettingsJson = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY); const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {}; + // Create an array of promises for all catalog fetches + const catalogPromises: Promise[] = []; + // Process addons in order (they're already returned in order from getAllAddons) for (const addon of addons) { if (addon.catalogs) { @@ -161,54 +163,65 @@ class CatalogService { const isEnabled = catalogSettings[settingKey] ?? true; if (isEnabled) { - try { - const addonManifest = await stremioService.getInstalledAddonsAsync(); - const manifest = addonManifest.find(a => a.id === addon.id); - if (!manifest) continue; + // Create a promise for each catalog fetch + const catalogPromise = (async () => { + try { + const addonManifest = await stremioService.getInstalledAddonsAsync(); + const manifest = addonManifest.find(a => a.id === addon.id); + if (!manifest) return null; - const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); - if (metas && metas.length > 0) { - const items = metas.map(meta => this.convertMetaToStreamingContent(meta)); - - // Get potentially custom display name - let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name); - - // Remove duplicate words and clean up the name (case-insensitive) - const words = displayName.split(' '); - const uniqueWords = []; - const seenWords = new Set(); - for (const word of words) { - const lowerWord = word.toLowerCase(); - if (!seenWords.has(lowerWord)) { - uniqueWords.push(word); - seenWords.add(lowerWord); + const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); + if (metas && metas.length > 0) { + const items = metas.map(meta => this.convertMetaToStreamingContent(meta)); + + // Get potentially custom display name + let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name); + + // Remove duplicate words and clean up the name (case-insensitive) + const words = displayName.split(' '); + const uniqueWords = []; + const seenWords = new Set(); + for (const word of words) { + const lowerWord = word.toLowerCase(); + if (!seenWords.has(lowerWord)) { + uniqueWords.push(word); + seenWords.add(lowerWord); + } } + displayName = uniqueWords.join(' '); + + // Add content type if not present + const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; + if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { + displayName = `${displayName} ${contentType}`; + } + + return { + addon: addon.id, + type: catalog.type, + id: catalog.id, + name: displayName, + items + }; } - displayName = uniqueWords.join(' '); - - // Add content type if not present - const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; - if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { - displayName = `${displayName} ${contentType}`; - } - - catalogs.push({ - addon: addon.id, - type: catalog.type, - id: catalog.id, - name: displayName, - items - }); + return null; + } catch (error) { + logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error); + return null; } - } catch (error) { - logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error); - } + })(); + + catalogPromises.push(catalogPromise); } } } } - return catalogs; + // Wait for all catalog fetch promises to resolve in parallel + const catalogResults = await Promise.all(catalogPromises); + + // Filter out null results + return catalogResults.filter(catalog => catalog !== null) as CatalogContent[]; } async getCatalogByType(type: string, genreFilter?: string): Promise { @@ -222,46 +235,58 @@ class CatalogService { // Otherwise use the original Stremio addons method const addons = await this.getAllAddons(); - const catalogs: CatalogContent[] = []; - + const typeAddons = addons.filter(addon => addon.catalogs && addon.catalogs.some(catalog => catalog.type === type) ); + // Create an array of promises for all catalog fetches + const catalogPromises: Promise[] = []; + for (const addon of typeAddons) { const typeCatalogs = addon.catalogs.filter(catalog => catalog.type === type); for (const catalog of typeCatalogs) { - try { - const addonManifest = await stremioService.getInstalledAddonsAsync(); - const manifest = addonManifest.find(a => a.id === addon.id); - if (!manifest) continue; + const catalogPromise = (async () => { + try { + const addonManifest = await stremioService.getInstalledAddonsAsync(); + const manifest = addonManifest.find(a => a.id === addon.id); + if (!manifest) return null; - const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : []; - const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters); - - if (metas && metas.length > 0) { - const items = metas.map(meta => this.convertMetaToStreamingContent(meta)); + const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : []; + const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters); - // Get potentially custom display name - const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name); - - catalogs.push({ - addon: addon.id, - type, - id: catalog.id, - name: displayName, - genre: genreFilter, - items - }); + if (metas && metas.length > 0) { + const items = metas.map(meta => this.convertMetaToStreamingContent(meta)); + + // Get potentially custom display name + const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name); + + return { + addon: addon.id, + type, + id: catalog.id, + name: displayName, + genre: genreFilter, + items + }; + } + return null; + } catch (error) { + logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error); + return null; } - } catch (error) { - logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error); - } + })(); + + catalogPromises.push(catalogPromise); } } - return catalogs; + // Wait for all catalog fetch promises to resolve in parallel + const catalogResults = await Promise.all(catalogPromises); + + // Filter out null results + return catalogResults.filter(catalog => catalog !== null) as CatalogContent[]; } /** @@ -277,64 +302,75 @@ class CatalogService { // If no genre filter or All is selected, get multiple catalogs if (!genreFilter || genreFilter === 'All') { - // Get trending - const trendingItems = await tmdbService.getTrending(tmdbType, 'week'); - const trendingItemsPromises = trendingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); - const trendingStreamingItems = await Promise.all(trendingItemsPromises); + // Create an array of promises for all catalog fetches + const catalogFetchPromises = [ + // Trending catalog + (async () => { + const trendingItems = await tmdbService.getTrending(tmdbType, 'week'); + const trendingItemsPromises = trendingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); + const trendingStreamingItems = await Promise.all(trendingItemsPromises); + + return { + addon: 'tmdb', + type, + id: 'trending', + name: `Trending ${type === 'movie' ? 'Movies' : 'TV Shows'}`, + items: trendingStreamingItems + }; + })(), + + // Popular catalog + (async () => { + const popularItems = await tmdbService.getPopular(tmdbType, 1); + const popularItemsPromises = popularItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); + const popularStreamingItems = await Promise.all(popularItemsPromises); + + return { + addon: 'tmdb', + type, + id: 'popular', + name: `Popular ${type === 'movie' ? 'Movies' : 'TV Shows'}`, + items: popularStreamingItems + }; + })(), + + // Upcoming/on air catalog + (async () => { + const upcomingItems = await tmdbService.getUpcoming(tmdbType, 1); + const upcomingItemsPromises = upcomingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); + const upcomingStreamingItems = await Promise.all(upcomingItemsPromises); + + return { + addon: 'tmdb', + type, + id: 'upcoming', + name: type === 'movie' ? 'Upcoming Movies' : 'On Air TV Shows', + items: upcomingStreamingItems + }; + })() + ]; - catalogs.push({ - addon: 'tmdb', - type, - id: 'trending', - name: `Trending ${type === 'movie' ? 'Movies' : 'TV Shows'}`, - items: trendingStreamingItems - }); - - // Get popular - const popularItems = await tmdbService.getPopular(tmdbType, 1); - const popularItemsPromises = popularItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); - const popularStreamingItems = await Promise.all(popularItemsPromises); - - catalogs.push({ - addon: 'tmdb', - type, - id: 'popular', - name: `Popular ${type === 'movie' ? 'Movies' : 'TV Shows'}`, - items: popularStreamingItems - }); - - // Get upcoming/on air - const upcomingItems = await tmdbService.getUpcoming(tmdbType, 1); - const upcomingItemsPromises = upcomingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); - const upcomingStreamingItems = await Promise.all(upcomingItemsPromises); - - catalogs.push({ - addon: 'tmdb', - type, - id: 'upcoming', - name: type === 'movie' ? 'Upcoming Movies' : 'On Air TV Shows', - items: upcomingStreamingItems - }); + // Wait for all catalog fetches to complete in parallel + return await Promise.all(catalogFetchPromises); } else { // Get content by genre const genreItems = await tmdbService.discoverByGenre(tmdbType, genreFilter); const streamingItemsPromises = genreItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); const streamingItems = await Promise.all(streamingItemsPromises); - catalogs.push({ + return [{ addon: 'tmdb', type, id: 'discover', name: `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}`, genre: genreFilter, items: streamingItems - }); + }]; } } catch (error) { logger.error(`Failed to get catalog from TMDB for type ${type}, genre ${genreFilter}:`, error); + return []; } - - return catalogs; } /** diff --git a/src/styles/homeStyles.ts b/src/styles/homeStyles.ts new file mode 100644 index 0000000..b8b0566 --- /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