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/components/streams/EpisodeHero.tsx b/src/components/streams/EpisodeHero.tsx deleted file mode 100644 index 92d8d12f..00000000 --- a/src/components/streams/EpisodeHero.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import React from 'react'; -import { StyleSheet, View, Text, ImageBackground } from 'react-native'; -import { MaterialIcons } from '@expo/vector-icons'; -import { Image } from 'expo-image'; -import { LinearGradient } from 'expo-linear-gradient'; -import Animated, { FadeIn } from 'react-native-reanimated'; -import { colors } from '../../styles/colors'; -import { tmdbService } from '../../services/tmdbService'; - -const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906'; - -interface EpisodeHeroProps { - currentEpisode: { - name: string; - overview?: string; - still_path?: string; - air_date?: string | null; - vote_average?: number; - runtime?: number; - episodeString: string; - season_number?: number; - episode_number?: number; - } | null; - metadata: { - poster?: string; - } | null; - animatedStyle: any; -} - -const EpisodeHero = ({ currentEpisode, metadata, animatedStyle }: EpisodeHeroProps) => { - if (!currentEpisode) return null; - - const episodeImage = currentEpisode.still_path - ? tmdbService.getImageUrl(currentEpisode.still_path, 'original') - : metadata?.poster || null; - - // Format air date safely - const formattedAirDate = currentEpisode.air_date !== undefined - ? tmdbService.formatAirDate(currentEpisode.air_date) - : 'Unknown'; - - return ( - - - - - - - - - {currentEpisode.episodeString} - - - {currentEpisode.name} - - {currentEpisode.overview && ( - - {currentEpisode.overview} - - )} - - - {formattedAirDate} - - {currentEpisode.vote_average && currentEpisode.vote_average > 0 && ( - - - - {currentEpisode.vote_average.toFixed(1)} - - - )} - {currentEpisode.runtime && ( - - - - {currentEpisode.runtime >= 60 - ? `${Math.floor(currentEpisode.runtime / 60)}h ${currentEpisode.runtime % 60}m` - : `${currentEpisode.runtime}m`} - - - )} - - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - streamsHeroContainer: { - width: '100%', - height: 300, - marginBottom: 0, - position: 'relative', - backgroundColor: colors.black, - pointerEvents: 'box-none', - }, - streamsHeroBackground: { - width: '100%', - height: '100%', - backgroundColor: colors.black, - }, - streamsHeroGradient: { - flex: 1, - justifyContent: 'flex-end', - padding: 16, - paddingBottom: 0, - }, - streamsHeroContent: { - width: '100%', - }, - streamsHeroInfo: { - width: '100%', - }, - streamsHeroEpisodeNumber: { - color: colors.primary, - fontSize: 14, - fontWeight: 'bold', - marginBottom: 2, - textShadowColor: 'rgba(0,0,0,0.75)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, - }, - streamsHeroTitle: { - color: colors.highEmphasis, - fontSize: 24, - fontWeight: 'bold', - marginBottom: 4, - textShadowColor: 'rgba(0,0,0,0.75)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 3, - }, - streamsHeroOverview: { - color: colors.mediumEmphasis, - fontSize: 14, - lineHeight: 20, - marginBottom: 2, - textShadowColor: 'rgba(0,0,0,0.75)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, - }, - streamsHeroMeta: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - marginTop: 0, - }, - streamsHeroReleased: { - color: colors.mediumEmphasis, - fontSize: 14, - textShadowColor: 'rgba(0,0,0,0.75)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, - }, - streamsHeroRating: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.7)', - paddingHorizontal: 6, - paddingVertical: 3, - borderRadius: 4, - marginTop: 0, - }, - tmdbLogo: { - width: 20, - height: 14, - }, - streamsHeroRatingText: { - color: '#01b4e4', - fontSize: 13, - fontWeight: '700', - marginLeft: 4, - }, - streamsHeroRuntime: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - backgroundColor: 'rgba(0,0,0,0.5)', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 6, - }, - streamsHeroRuntimeText: { - color: colors.mediumEmphasis, - fontSize: 13, - fontWeight: '600', - }, -}); - -export default React.memo(EpisodeHero); \ No newline at end of file diff --git a/src/components/streams/MovieHero.tsx b/src/components/streams/MovieHero.tsx deleted file mode 100644 index d05c6a83..00000000 --- a/src/components/streams/MovieHero.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import { StyleSheet, Text, View, ImageBackground, Dimensions, Platform } from 'react-native'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Image } from 'expo-image'; -import Animated from 'react-native-reanimated'; -import { colors } from '../../styles/colors'; - -const { width } = Dimensions.get('window'); - -interface MovieHeroProps { - metadata: { - name: string; - logo?: string; - banner?: string; - poster?: string; - } | null; - animatedStyle: any; -} - -const MovieHero = ({ metadata, animatedStyle }: MovieHeroProps) => { - if (!metadata) return null; - - return ( - - - - - {metadata.logo ? ( - - ) : ( - - {metadata.name} - - )} - - - - - ); -}; - -const styles = StyleSheet.create({ - movieTitleContainer: { - width: '100%', - height: 180, - backgroundColor: colors.black, - pointerEvents: 'box-none', - }, - movieTitleBackground: { - width: '100%', - height: '100%', - backgroundColor: colors.black, - }, - movieTitleGradient: { - flex: 1, - justifyContent: 'center', - padding: 16, - }, - movieTitleContent: { - width: '100%', - alignItems: 'center', - marginTop: Platform.OS === 'android' ? 35 : 45, - }, - movieLogo: { - width: width * 0.6, - height: 70, - marginBottom: 8, - }, - movieTitle: { - color: colors.highEmphasis, - fontSize: 28, - fontWeight: '900', - textAlign: 'center', - textShadowColor: 'rgba(0,0,0,0.75)', - textShadowOffset: { width: 0, height: 2 }, - textShadowRadius: 4, - letterSpacing: -0.5, - }, -}); - -export default React.memo(MovieHero); \ No newline at end of file diff --git a/src/components/streams/ProviderFilter.tsx b/src/components/streams/ProviderFilter.tsx deleted file mode 100644 index 800051f9..00000000 --- a/src/components/streams/ProviderFilter.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React, { useCallback } from 'react'; -import { FlatList, StyleSheet, Text, TouchableOpacity } from 'react-native'; -import { colors } from '../../styles/colors'; - -interface ProviderFilterProps { - selectedProvider: string; - providers: Array<{ id: string; name: string; }>; - onSelect: (id: string) => void; -} - -const ProviderFilter = ({ selectedProvider, providers, onSelect }: ProviderFilterProps) => { - const renderItem = useCallback(({ item }: { item: { id: string; name: string } }) => ( - onSelect(item.id)} - > - - {item.name} - - - ), [selectedProvider, onSelect]); - - return ( - item.id} - horizontal - showsHorizontalScrollIndicator={false} - style={styles.filterScroll} - bounces={true} - overScrollMode="never" - decelerationRate="fast" - initialNumToRender={5} - maxToRenderPerBatch={3} - windowSize={3} - getItemLayout={(data, index) => ({ - length: 100, // Approximate width of each item - offset: 100 * index, - index, - })} - /> - ); -}; - -const styles = StyleSheet.create({ - filterScroll: { - flexGrow: 0, - }, - filterChip: { - backgroundColor: colors.transparentLight, - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - marginRight: 8, - borderWidth: 1, - borderColor: colors.transparent, - }, - filterChipSelected: { - backgroundColor: colors.transparentLight, - borderColor: colors.primary, - }, - filterChipText: { - color: colors.text, - fontWeight: '500', - }, - filterChipTextSelected: { - color: colors.primary, - fontWeight: 'bold', - }, -}); - -export default React.memo(ProviderFilter); \ No newline at end of file diff --git a/src/components/streams/StreamCard.tsx b/src/components/streams/StreamCard.tsx deleted file mode 100644 index cb69824d..00000000 --- a/src/components/streams/StreamCard.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; -import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../../styles/colors'; -import { Stream } from '../../types/metadata'; -import QualityBadge from '../metadata/QualityBadge'; - -interface StreamCardProps { - stream: Stream; - onPress: () => void; - index: number; - isLoading?: boolean; - statusMessage?: string; -} - -const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: StreamCardProps) => { - const quality = stream.title?.match(/(\d+)p/)?.[1] || null; - const isHDR = stream.title?.toLowerCase().includes('hdr'); - const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV'); - const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1]; - const isDebrid = stream.behaviorHints?.cached; - - const displayTitle = stream.name || stream.title || 'Unnamed Stream'; - const displayAddonName = stream.title || ''; - - return ( - - - - - - {displayTitle} - - {displayAddonName && displayAddonName !== displayTitle && ( - - {displayAddonName} - - )} - - - {/* Show loading indicator if stream is loading */} - {isLoading && ( - - - - {statusMessage || "Loading..."} - - - )} - - - - {quality && quality >= "720" && ( - - )} - - {isDolby && ( - - )} - - {size && ( - - {size} - - )} - - {isDebrid && ( - - DEBRID - - )} - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - streamCard: { - flexDirection: 'row', - alignItems: 'flex-start', - padding: 12, - borderRadius: 12, - marginBottom: 8, - minHeight: 70, - backgroundColor: colors.elevation1, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.05)', - width: '100%', - zIndex: 1, - }, - streamCardLoading: { - opacity: 0.7, - }, - streamDetails: { - flex: 1, - }, - streamNameRow: { - flexDirection: 'row', - alignItems: 'flex-start', - justifyContent: 'space-between', - width: '100%', - flexWrap: 'wrap', - gap: 8 - }, - streamTitleContainer: { - flex: 1, - }, - streamName: { - fontSize: 14, - fontWeight: '600', - marginBottom: 2, - lineHeight: 20, - color: colors.highEmphasis, - }, - streamAddonName: { - fontSize: 13, - lineHeight: 18, - color: colors.mediumEmphasis, - marginBottom: 6, - }, - streamMetaRow: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 4, - marginBottom: 6, - alignItems: 'center', - }, - chip: { - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 4, - marginRight: 4, - marginBottom: 4, - }, - chipText: { - color: colors.highEmphasis, - fontSize: 12, - fontWeight: '600', - }, - loadingIndicator: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 12, - marginLeft: 8, - }, - loadingText: { - color: colors.primary, - fontSize: 12, - marginLeft: 4, - fontWeight: '500', - }, - streamAction: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: colors.elevation2, - justifyContent: 'center', - alignItems: 'center', - }, -}); - -export default React.memo(StreamCard); \ 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/hooks/useStreamNavigation.ts b/src/hooks/useStreamNavigation.ts deleted file mode 100644 index b3925830..00000000 --- a/src/hooks/useStreamNavigation.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { useCallback } from 'react'; -import { Platform, Linking } from 'react-native'; -import { NavigationProp, useNavigation } from '@react-navigation/native'; -import { RootStackParamList } from '../navigation/AppNavigator'; -import { Stream } from '../types/metadata'; -import { logger } from '../utils/logger'; - -interface UseStreamNavigationProps { - metadata: { - name?: string; - year?: number; - } | null; - currentEpisode?: { - name?: string; - season_number?: number; - episode_number?: number; - } | null; - id: string; - type: string; - selectedEpisode?: string; - useExternalPlayer?: boolean; - preferredPlayer?: 'internal' | 'vlc' | 'outplayer' | 'infuse' | 'vidhub' | 'external'; -} - -export const useStreamNavigation = ({ - metadata, - currentEpisode, - id, - type, - selectedEpisode, - useExternalPlayer, - preferredPlayer -}: UseStreamNavigationProps) => { - const navigation = useNavigation>(); - - const navigateToPlayer = useCallback((stream: Stream) => { - navigation.navigate('Player', { - uri: stream.url, - title: metadata?.name || '', - episodeTitle: type === 'series' ? currentEpisode?.name : undefined, - season: type === 'series' ? currentEpisode?.season_number : undefined, - episode: type === 'series' ? currentEpisode?.episode_number : undefined, - quality: stream.title?.match(/(\d+)p/)?.[1] || undefined, - year: metadata?.year, - streamProvider: stream.name, - id, - type, - episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined - }); - }, [metadata, type, currentEpisode, navigation, id, selectedEpisode]); - - const handleStreamPress = useCallback(async (stream: Stream) => { - try { - if (stream.url) { - logger.log('handleStreamPress called with stream:', { - url: stream.url, - behaviorHints: stream.behaviorHints, - useExternalPlayer, - preferredPlayer - }); - - // For iOS, try to open with the preferred external player - if (Platform.OS === 'ios' && preferredPlayer !== 'internal') { - try { - // Format the URL for the selected player - const streamUrl = encodeURIComponent(stream.url); - let externalPlayerUrls: string[] = []; - - // Configure URL formats based on the selected player - switch (preferredPlayer) { - case 'vlc': - externalPlayerUrls = [ - `vlc://${stream.url}`, - `vlc-x-callback://x-callback-url/stream?url=${streamUrl}`, - `vlc://${streamUrl}` - ]; - break; - - case 'outplayer': - externalPlayerUrls = [ - `outplayer://${stream.url}`, - `outplayer://${streamUrl}`, - `outplayer://play?url=${streamUrl}`, - `outplayer://stream?url=${streamUrl}`, - `outplayer://play/browser?url=${streamUrl}` - ]; - break; - - case 'infuse': - externalPlayerUrls = [ - `infuse://x-callback-url/play?url=${streamUrl}`, - `infuse://play?url=${streamUrl}`, - `infuse://${streamUrl}` - ]; - break; - - case 'vidhub': - externalPlayerUrls = [ - `vidhub://play?url=${streamUrl}`, - `vidhub://${streamUrl}` - ]; - break; - - default: - // If no matching player or the setting is somehow invalid, use internal player - navigateToPlayer(stream); - return; - } - - console.log(`Attempting to open stream in ${preferredPlayer}`); - - // Try each URL format in sequence - const tryNextUrl = (index: number) => { - if (index >= externalPlayerUrls.length) { - console.log(`All ${preferredPlayer} formats failed, falling back to direct URL`); - // Try direct URL as last resort - Linking.openURL(stream.url) - .then(() => console.log('Opened with direct URL')) - .catch(() => { - console.log('Direct URL failed, falling back to built-in player'); - navigateToPlayer(stream); - }); - return; - } - - const url = externalPlayerUrls[index]; - console.log(`Trying ${preferredPlayer} URL format ${index + 1}: ${url}`); - - Linking.openURL(url) - .then(() => console.log(`Successfully opened stream with ${preferredPlayer} format ${index + 1}`)) - .catch(err => { - console.log(`Format ${index + 1} failed: ${err.message}`, err); - tryNextUrl(index + 1); - }); - }; - - // Start with the first URL format - tryNextUrl(0); - - } catch (error) { - console.error(`Error with ${preferredPlayer}:`, error); - // Fallback to the built-in player - navigateToPlayer(stream); - } - } - // For Android with external player preference - else if (Platform.OS === 'android' && useExternalPlayer) { - try { - console.log('Opening stream with Android native app chooser'); - - // For Android, determine if the URL is a direct http/https URL or a magnet link - const isMagnet = stream.url.startsWith('magnet:'); - - if (isMagnet) { - // For magnet links, open directly which will trigger the torrent app chooser - console.log('Opening magnet link directly'); - Linking.openURL(stream.url) - .then(() => console.log('Successfully opened magnet link')) - .catch(err => { - console.error('Failed to open magnet link:', err); - // No good fallback for magnet links - navigateToPlayer(stream); - }); - } else { - // For direct video URLs, use the S.Browser.ACTION_VIEW approach - // This is a more reliable way to force Android to show all video apps - - // Strip query parameters if they exist as they can cause issues with some apps - let cleanUrl = stream.url; - if (cleanUrl.includes('?')) { - cleanUrl = cleanUrl.split('?')[0]; - } - - // Create an Android intent URL that forces the chooser - // Set component=null to ensure chooser is shown - // Set action=android.intent.action.VIEW to open the content - const intentUrl = `intent:${cleanUrl}#Intent;action=android.intent.action.VIEW;category=android.intent.category.DEFAULT;component=;type=video/*;launchFlags=0x10000000;end`; - - console.log(`Using intent URL: ${intentUrl}`); - - Linking.openURL(intentUrl) - .then(() => console.log('Successfully opened with intent URL')) - .catch(err => { - console.error('Failed to open with intent URL:', err); - - // First fallback: Try direct URL with regular Linking API - console.log('Trying plain URL as fallback'); - Linking.openURL(stream.url) - .then(() => console.log('Opened with direct URL')) - .catch(directErr => { - console.error('Failed to open direct URL:', directErr); - - // Final fallback: Use built-in player - console.log('All external player attempts failed, using built-in player'); - navigateToPlayer(stream); - }); - }); - } - } catch (error) { - console.error('Error with external player:', error); - // Fallback to the built-in player - navigateToPlayer(stream); - } - } - else { - // For internal player or if other options failed, use the built-in player - navigateToPlayer(stream); - } - } - } catch (error) { - console.error('Error in handleStreamPress:', error); - // Final fallback: Use built-in player - navigateToPlayer(stream); - } - }, [navigateToPlayer, preferredPlayer, useExternalPlayer]); - - return { - handleStreamPress, - navigateToPlayer - }; -}; \ No newline at end of file diff --git a/src/hooks/useStreamProviders.ts b/src/hooks/useStreamProviders.ts deleted file mode 100644 index d9f4f161..00000000 --- a/src/hooks/useStreamProviders.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; -import { stremioService } from '../services/stremioService'; -import { Stream } from '../types/metadata'; -import { logger } from '../utils/logger'; - -interface StreamGroups { - [addonId: string]: { - addonName: string; - streams: Stream[]; - }; -} - -export const useStreamProviders = ( - groupedStreams: StreamGroups, - episodeStreams: StreamGroups, - type: string, - loadingStreams: boolean, - loadingEpisodeStreams: boolean -) => { - const [selectedProvider, setSelectedProvider] = useState('all'); - const [availableProviders, setAvailableProviders] = useState>(new Set()); - const [loadingProviders, setLoadingProviders] = useState<{[key: string]: boolean}>({}); - const [providerStatus, setProviderStatus] = useState<{ - [key: string]: { - loading: boolean; - success: boolean; - error: boolean; - message: string; - timeStarted: number; - timeCompleted: number; - } - }>({}); - const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({}); - const [loadStartTime, setLoadStartTime] = useState(0); - - // Update available providers when streams change - converted to useEffect - useEffect(() => { - const streams = type === 'series' ? episodeStreams : groupedStreams; - const providers = new Set(Object.keys(streams)); - setAvailableProviders(providers); - }, [type, groupedStreams, episodeStreams]); - - // Start tracking load time when loading begins - converted to useEffect - useEffect(() => { - if (loadingStreams || loadingEpisodeStreams) { - logger.log("⏱️ Stream loading started"); - const now = Date.now(); - setLoadStartTime(now); - setProviderLoadTimes({}); - - // Reset provider status - only for stremio addons - setProviderStatus({ - 'stremio': { - loading: true, - success: false, - error: false, - message: 'Loading...', - timeStarted: now, - timeCompleted: 0 - } - }); - - // Also update the simpler loading state - only for stremio - setLoadingProviders({ - 'stremio': true - }); - } - }, [loadingStreams, loadingEpisodeStreams]); - - // Generate filter items for the provider selector - const filterItems = useMemo(() => { - const installedAddons = stremioService.getInstalledAddons(); - const streams = type === 'series' ? episodeStreams : groupedStreams; - - return [ - { id: 'all', name: 'All Providers' }, - ...Array.from(availableProviders) - .sort((a, b) => { - const indexA = installedAddons.findIndex(addon => addon.id === a); - const indexB = installedAddons.findIndex(addon => addon.id === b); - - if (indexA !== -1 && indexB !== -1) return indexA - indexB; - if (indexA !== -1) return -1; - if (indexB !== -1) return 1; - return 0; - }) - .map(provider => { - const addonInfo = streams[provider]; - const installedAddon = installedAddons.find(addon => addon.id === provider); - - let displayName = provider; - if (installedAddon) displayName = installedAddon.name; - else if (addonInfo?.addonName) displayName = addonInfo.addonName; - - return { id: provider, name: displayName }; - }) - ]; - }, [availableProviders, type, episodeStreams, groupedStreams]); - - // Filter streams to show only selected provider (or all) - const filteredSections = useMemo(() => { - const streams = type === 'series' ? episodeStreams : groupedStreams; - const installedAddons = stremioService.getInstalledAddons(); - - return Object.entries(streams) - .filter(([addonId]) => { - // If "all" is selected, show all providers - if (selectedProvider === 'all') { - return true; - } - // Otherwise only show the selected provider - return addonId === selectedProvider; - }) - .sort(([addonIdA], [addonIdB]) => { - const indexA = installedAddons.findIndex(addon => addon.id === addonIdA); - const indexB = installedAddons.findIndex(addon => addon.id === addonIdB); - - if (indexA !== -1 && indexB !== -1) return indexA - indexB; - if (indexA !== -1) return -1; - if (indexB !== -1) return 1; - return 0; - }) - .map(([addonId, { addonName, streams }]) => ({ - title: addonName, - addonId, - data: streams - })); - }, [selectedProvider, type, episodeStreams, groupedStreams]); - - // Handler for changing the selected provider - const handleProviderChange = useCallback((provider: string) => { - setSelectedProvider(provider); - }, []); - - return { - selectedProvider, - availableProviders, - loadingProviders, - providerStatus, - filterItems, - filteredSections, - handleProviderChange, - setLoadingProviders, - setProviderStatus - }; -}; \ 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/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 56e33caa..3f0f27b8 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -1,53 +1,208 @@ -import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import React, { useCallback, useMemo, memo, useState, useEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, + FlatList, SectionList, Platform, + ImageBackground, + ScrollView, StatusBar, - Dimensions + Alert, + Dimensions, + Linking } from 'react-native'; import { useRoute, useNavigation } from '@react-navigation/native'; import { RouteProp } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Image } from 'expo-image'; import { RootStackParamList, RootStackNavigationProp } from '../navigation/AppNavigator'; import { useMetadata } from '../hooks/useMetadata'; import { colors } from '../styles/colors'; import { Stream } from '../types/metadata'; +import { tmdbService } from '../services/tmdbService'; +import { stremioService } from '../services/stremioService'; +import { VideoPlayerService } from '../services/videoPlayerService'; import { useSettings } from '../hooks/useSettings'; +import QualityBadge from '../components/metadata/QualityBadge'; import Animated, { FadeIn, + FadeInDown, + SlideInDown, + withSpring, withTiming, useAnimatedStyle, useSharedValue, interpolate, Extrapolate, - cancelAnimation + runOnJS, + cancelAnimation, + SharedValue } from 'react-native-reanimated'; import { logger } from '../utils/logger'; -// Import custom components -import StreamCard from '../components/streams/StreamCard'; -import ProviderFilter from '../components/streams/ProviderFilter'; -import MovieHero from '../components/streams/MovieHero'; -import EpisodeHero from '../components/streams/EpisodeHero'; +const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906'; +const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png'; +const DOLBY_ICON = 'https://upload.wikimedia.org/wikipedia/en/thumb/3/3f/Dolby_Vision_%28logo%29.svg/512px-Dolby_Vision_%28logo%29.svg.png?20220908042900'; -// Import custom hooks -import { useStreamNavigation } from '../hooks/useStreamNavigation'; -import { useStreamProviders } from '../hooks/useStreamProviders'; +const { width, height } = Dimensions.get('window'); + +// Extracted Components +const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: { + stream: Stream; + onPress: () => void; + index: number; + isLoading?: boolean; + statusMessage?: string; +}) => { + const quality = stream.title?.match(/(\d+)p/)?.[1] || null; + const isHDR = stream.title?.toLowerCase().includes('hdr'); + const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV'); + const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1]; + const isDebrid = stream.behaviorHints?.cached; + + const displayTitle = stream.name || stream.title || 'Unnamed Stream'; + const displayAddonName = stream.title || ''; + + return ( + + + + + + {displayTitle} + + {displayAddonName && displayAddonName !== displayTitle && ( + + {displayAddonName} + + )} + + + {/* Show loading indicator if stream is loading */} + {isLoading && ( + + + + {statusMessage || "Loading..."} + + + )} + + + + {quality && quality >= "720" && ( + + )} + + {isDolby && ( + + )} + + {size && ( + + {size} + + )} + + {isDebrid && ( + + DEBRID + + )} + + + + + + + + ); +}; + +const QualityTag = React.memo(({ text, color }: { text: string; color: string }) => ( + + {text} + +)); + +const ProviderFilter = memo(({ + selectedProvider, + providers, + onSelect +}: { + selectedProvider: string; + providers: Array<{ id: string; name: string; }>; + onSelect: (id: string) => void; +}) => { + const renderItem = useCallback(({ item }: { item: { id: string; name: string } }) => ( + onSelect(item.id)} + > + + {item.name} + + + ), [selectedProvider, onSelect]); + + return ( + item.id} + horizontal + showsHorizontalScrollIndicator={false} + style={styles.filterScroll} + bounces={true} + overScrollMode="never" + decelerationRate="fast" + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={3} + getItemLayout={(data, index) => ({ + length: 100, // Approximate width of each item + offset: 100 * index, + index, + })} + /> + ); +}); export const StreamsScreen = () => { const route = useRoute>(); const navigation = useNavigation(); const { id, type, episodeId } = route.params; const { settings } = useSettings(); - - // Track loading initialization to prevent duplicate loads - const [initialLoadComplete, setInitialLoadComplete] = useState(false); + // Add timing logs + const [loadStartTime, setLoadStartTime] = useState(0); + const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({}); + const { metadata, episodes, @@ -62,32 +217,57 @@ export const StreamsScreen = () => { groupedEpisodes, } = useMetadata({ id, type }); + const [selectedProvider, setSelectedProvider] = React.useState('all'); + const [availableProviders, setAvailableProviders] = React.useState>(new Set()); + // Optimize animation values with cleanup const headerOpacity = useSharedValue(0); const heroScale = useSharedValue(0.95); const filterOpacity = useSharedValue(0); - // Use custom hooks - const { - selectedProvider, - filterItems, - filteredSections, - handleProviderChange, - loadingProviders, - providerStatus, - setLoadingProviders - } = useStreamProviders( - groupedStreams, - episodeStreams, - type, - loadingStreams, - loadingEpisodeStreams - ); + // Add state for provider loading status + const [loadingProviders, setLoadingProviders] = useState<{[key: string]: boolean}>({}); + + // Add state for more detailed provider loading tracking + const [providerStatus, setProviderStatus] = useState<{ + [key: string]: { + loading: boolean; + success: boolean; + error: boolean; + message: string; + timeStarted: number; + timeCompleted: number; + } + }>({}); - // Load initial streams only once + // Monitor streams loading start useEffect(() => { - if (initialLoadComplete) return; - + if (loadingStreams || loadingEpisodeStreams) { + logger.log("⏱️ Stream loading started"); + const now = Date.now(); + setLoadStartTime(now); + setProviderLoadTimes({}); + + // Reset provider status - only for stremio addons + setProviderStatus({ + 'stremio': { + loading: true, + success: false, + error: false, + message: 'Loading...', + timeStarted: now, + timeCompleted: 0 + } + }); + + // Also update the simpler loading state - only for stremio + setLoadingProviders({ + 'stremio': true + }); + } + }, [loadingStreams, loadingEpisodeStreams]); + + React.useEffect(() => { if (type === 'series' && episodeId) { logger.log(`🎬 Loading episode streams for: ${episodeId}`); setLoadingProviders({ @@ -95,31 +275,30 @@ export const StreamsScreen = () => { }); setSelectedEpisode(episodeId); loadEpisodeStreams(episodeId); - setInitialLoadComplete(true); } else if (type === 'movie') { logger.log(`🎬 Loading movie streams for: ${id}`); setLoadingProviders({ 'stremio': true }); loadStreams(); - setInitialLoadComplete(true); } - }, [ - initialLoadComplete, - type, - episodeId, - id, - loadEpisodeStreams, - loadStreams, - setSelectedEpisode, - setLoadingProviders - ]); + }, [type, episodeId]); - // Animation effects - useEffect(() => { + React.useEffect(() => { + const streams = type === 'series' ? episodeStreams : groupedStreams; + const providers = new Set(Object.keys(streams)); + setAvailableProviders(providers); + }, [type, groupedStreams, episodeStreams]); + + React.useEffect(() => { // Trigger entrance animations headerOpacity.value = withTiming(1, { duration: 400 }); - heroScale.value = withTiming(1, { duration: 400 }); + heroScale.value = withSpring(1, { + damping: 15, + stiffness: 100, + mass: 0.9, + restDisplacementThreshold: 0.01 + }); filterOpacity.value = withTiming(1, { duration: 500 }); return () => { @@ -128,29 +307,7 @@ export const StreamsScreen = () => { cancelAnimation(heroScale); cancelAnimation(filterOpacity); }; - }, [headerOpacity, heroScale, filterOpacity]); - - const currentEpisode = useMemo(() => { - if (!selectedEpisode) return null; - - // Search through all episodes in all seasons - const allEpisodes = Object.values(groupedEpisodes).flat(); - return allEpisodes.find(ep => - ep.stremioId === selectedEpisode || - `${id}:${ep.season_number}:${ep.episode_number}` === selectedEpisode - ); - }, [selectedEpisode, groupedEpisodes, id]); - - // Use navigation hook - const { handleStreamPress } = useStreamNavigation({ - metadata, - currentEpisode, - id, - type, - selectedEpisode: selectedEpisode || undefined, - useExternalPlayer: settings.useExternalPlayer, - preferredPlayer: settings.preferredPlayer as 'internal' | 'vlc' | 'outplayer' | 'infuse' | 'vidhub' | 'external' - }); + }, []); // Memoize handlers const handleBack = useCallback(() => { @@ -172,6 +329,272 @@ export const StreamsScreen = () => { } }, [navigation, headerOpacity, heroScale, filterOpacity, type, id]); + const handleProviderChange = useCallback((provider: string) => { + setSelectedProvider(provider); + }, []); + + const currentEpisode = useMemo(() => { + if (!selectedEpisode) return null; + + // Search through all episodes in all seasons + const allEpisodes = Object.values(groupedEpisodes).flat(); + return allEpisodes.find(ep => + ep.stremioId === selectedEpisode || + `${id}:${ep.season_number}:${ep.episode_number}` === selectedEpisode + ); + }, [selectedEpisode, groupedEpisodes, id]); + + const navigateToPlayer = useCallback((stream: Stream) => { + navigation.navigate('Player', { + uri: stream.url, + title: metadata?.name || '', + episodeTitle: type === 'series' ? currentEpisode?.name : undefined, + season: type === 'series' ? currentEpisode?.season_number : undefined, + episode: type === 'series' ? currentEpisode?.episode_number : undefined, + quality: stream.title?.match(/(\d+)p/)?.[1] || undefined, + year: metadata?.year, + streamProvider: stream.name, + id, + type, + episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined + }); + }, [metadata, type, currentEpisode, navigation, id, selectedEpisode]); + + // Update handleStreamPress + const handleStreamPress = useCallback(async (stream: Stream) => { + try { + if (stream.url) { + logger.log('handleStreamPress called with stream:', { + url: stream.url, + behaviorHints: stream.behaviorHints, + useExternalPlayer: settings.useExternalPlayer, + preferredPlayer: settings.preferredPlayer + }); + + // For iOS, try to open with the preferred external player + if (Platform.OS === 'ios' && settings.preferredPlayer !== 'internal') { + try { + // Format the URL for the selected player + const streamUrl = encodeURIComponent(stream.url); + let externalPlayerUrls: string[] = []; + + // Configure URL formats based on the selected player + switch (settings.preferredPlayer) { + case 'vlc': + externalPlayerUrls = [ + `vlc://${stream.url}`, + `vlc-x-callback://x-callback-url/stream?url=${streamUrl}`, + `vlc://${streamUrl}` + ]; + break; + + case 'outplayer': + externalPlayerUrls = [ + `outplayer://${stream.url}`, + `outplayer://${streamUrl}`, + `outplayer://play?url=${streamUrl}`, + `outplayer://stream?url=${streamUrl}`, + `outplayer://play/browser?url=${streamUrl}` + ]; + break; + + case 'infuse': + externalPlayerUrls = [ + `infuse://x-callback-url/play?url=${streamUrl}`, + `infuse://play?url=${streamUrl}`, + `infuse://${streamUrl}` + ]; + break; + + case 'vidhub': + externalPlayerUrls = [ + `vidhub://play?url=${streamUrl}`, + `vidhub://${streamUrl}` + ]; + break; + + default: + // If no matching player or the setting is somehow invalid, use internal player + navigateToPlayer(stream); + return; + } + + console.log(`Attempting to open stream in ${settings.preferredPlayer}`); + + // Try each URL format in sequence + const tryNextUrl = (index: number) => { + if (index >= externalPlayerUrls.length) { + console.log(`All ${settings.preferredPlayer} formats failed, falling back to direct URL`); + // Try direct URL as last resort + Linking.openURL(stream.url) + .then(() => console.log('Opened with direct URL')) + .catch(() => { + console.log('Direct URL failed, falling back to built-in player'); + navigateToPlayer(stream); + }); + return; + } + + const url = externalPlayerUrls[index]; + console.log(`Trying ${settings.preferredPlayer} URL format ${index + 1}: ${url}`); + + Linking.openURL(url) + .then(() => console.log(`Successfully opened stream with ${settings.preferredPlayer} format ${index + 1}`)) + .catch(err => { + console.log(`Format ${index + 1} failed: ${err.message}`, err); + tryNextUrl(index + 1); + }); + }; + + // Start with the first URL format + tryNextUrl(0); + + } catch (error) { + console.error(`Error with ${settings.preferredPlayer}:`, error); + // Fallback to the built-in player + navigateToPlayer(stream); + } + } + // For Android with external player preference + else if (Platform.OS === 'android' && settings.useExternalPlayer) { + try { + console.log('Opening stream with Android native app chooser'); + + // For Android, determine if the URL is a direct http/https URL or a magnet link + const isMagnet = stream.url.startsWith('magnet:'); + + if (isMagnet) { + // For magnet links, open directly which will trigger the torrent app chooser + console.log('Opening magnet link directly'); + Linking.openURL(stream.url) + .then(() => console.log('Successfully opened magnet link')) + .catch(err => { + console.error('Failed to open magnet link:', err); + // No good fallback for magnet links + navigateToPlayer(stream); + }); + } else { + // For direct video URLs, use the S.Browser.ACTION_VIEW approach + // This is a more reliable way to force Android to show all video apps + + // Strip query parameters if they exist as they can cause issues with some apps + let cleanUrl = stream.url; + if (cleanUrl.includes('?')) { + cleanUrl = cleanUrl.split('?')[0]; + } + + // Create an Android intent URL that forces the chooser + // Set component=null to ensure chooser is shown + // Set action=android.intent.action.VIEW to open the content + const intentUrl = `intent:${cleanUrl}#Intent;action=android.intent.action.VIEW;category=android.intent.category.DEFAULT;component=;type=video/*;launchFlags=0x10000000;end`; + + console.log(`Using intent URL: ${intentUrl}`); + + Linking.openURL(intentUrl) + .then(() => console.log('Successfully opened with intent URL')) + .catch(err => { + console.error('Failed to open with intent URL:', err); + + // First fallback: Try direct URL with regular Linking API + console.log('Trying plain URL as fallback'); + Linking.openURL(stream.url) + .then(() => console.log('Opened with direct URL')) + .catch(directErr => { + console.error('Failed to open direct URL:', directErr); + + // Final fallback: Use built-in player + console.log('All external player attempts failed, using built-in player'); + navigateToPlayer(stream); + }); + }); + } + } catch (error) { + console.error('Error with external player:', error); + // Fallback to the built-in player + navigateToPlayer(stream); + } + } + else { + // For internal player or if other options failed, use the built-in player + navigateToPlayer(stream); + } + } + } catch (error) { + console.error('Error in handleStreamPress:', error); + // Final fallback: Use built-in player + navigateToPlayer(stream); + } + }, [settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer]); + + const filterItems = useMemo(() => { + const installedAddons = stremioService.getInstalledAddons(); + const streams = type === 'series' ? episodeStreams : groupedStreams; + + return [ + { id: 'all', name: 'All Providers' }, + ...Array.from(availableProviders) + .sort((a, b) => { + const indexA = installedAddons.findIndex(addon => addon.id === a); + const indexB = installedAddons.findIndex(addon => addon.id === b); + + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + return 0; + }) + .map(provider => { + const addonInfo = streams[provider]; + const installedAddon = installedAddons.find(addon => addon.id === provider); + + let displayName = provider; + if (installedAddon) displayName = installedAddon.name; + else if (addonInfo?.addonName) displayName = addonInfo.addonName; + + return { id: provider, name: displayName }; + }) + ]; + }, [availableProviders, type, episodeStreams, groupedStreams]); + + const sections = useMemo(() => { + const streams = type === 'series' ? episodeStreams : groupedStreams; + const installedAddons = stremioService.getInstalledAddons(); + + // Filter streams by selected provider - only if not "all" + const filteredEntries = Object.entries(streams) + .filter(([addonId]) => { + // If "all" is selected, show all providers + if (selectedProvider === 'all') { + return true; + } + // Otherwise only show the selected provider + return addonId === selectedProvider; + }) + .sort(([addonIdA], [addonIdB]) => { + const indexA = installedAddons.findIndex(addon => addon.id === addonIdA); + const indexB = installedAddons.findIndex(addon => addon.id === addonIdB); + + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + return 0; + }) + .map(([addonId, { addonName, streams }]) => ({ + title: addonName, + addonId, + data: streams + })); + + return filteredEntries; + }, [selectedProvider, type, episodeStreams, groupedStreams]); + + const episodeImage = useMemo(() => { + if (!currentEpisode) return null; + if (currentEpisode.still_path) { + return tmdbService.getImageUrl(currentEpisode.still_path, 'original'); + } + return metadata?.poster || null; + }, [currentEpisode, metadata]); + const isLoading = type === 'series' ? loadingEpisodeStreams : loadingStreams; const streams = type === 'series' ? episodeStreams : groupedStreams; @@ -226,7 +649,6 @@ export const StreamsScreen = () => { barStyle="light-content" /> - {/* Back Button */} { - {/* Movie Hero */} {type === 'movie' && metadata && ( - + + + + + {metadata.logo ? ( + + ) : ( + + {metadata.name} + + )} + + + + )} - {/* Episode Hero */} {type === 'series' && currentEpisode && ( - + + + + + + + + + {currentEpisode.episodeString} + + + {currentEpisode.name} + + {currentEpisode.overview && ( + + {currentEpisode.overview} + + )} + + + {tmdbService.formatAirDate(currentEpisode.air_date)} + + {currentEpisode.vote_average > 0 && ( + + + + {currentEpisode.vote_average.toFixed(1)} + + + )} + {currentEpisode.runtime && ( + + + + {currentEpisode.runtime >= 60 + ? `${Math.floor(currentEpisode.runtime / 60)}h ${currentEpisode.runtime % 60}m` + : `${currentEpisode.runtime}m`} + + + )} + + + + + + + + )} - {/* Stream List */} - {/* Provider Filter */} {Object.keys(streams).length > 0 && ( { )} - {/* Loading or Empty State */} {isLoading && Object.keys(streams).length === 0 ? ( { ) : ( item.url || `${item.name}-${item.title}`} renderItem={renderItem} renderSectionHeader={renderSectionHeader} @@ -361,6 +880,30 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingBottom: 12, }, + filterScroll: { + flexGrow: 0, + }, + filterChip: { + backgroundColor: colors.transparentLight, + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + marginRight: 8, + borderWidth: 1, + borderColor: colors.transparent, + }, + filterChipSelected: { + backgroundColor: colors.transparentLight, + borderColor: colors.primary, + }, + filterChipText: { + color: colors.text, + fontWeight: '500', + }, + filterChipTextSelected: { + color: colors.primary, + fontWeight: 'bold', + }, streamsContent: { flex: 1, width: '100%', @@ -371,6 +914,10 @@ const styles = StyleSheet.create({ paddingBottom: 16, width: '100%', }, + streamGroup: { + marginBottom: 24, + width: '100%', + }, streamGroupTitle: { color: colors.text, fontSize: 16, @@ -379,6 +926,123 @@ const styles = StyleSheet.create({ marginTop: 0, backgroundColor: 'transparent', }, + streamCard: { + flexDirection: 'row', + alignItems: 'flex-start', + padding: 12, + borderRadius: 12, + marginBottom: 8, + minHeight: 70, + backgroundColor: colors.elevation1, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.05)', + width: '100%', + zIndex: 1, + }, + streamCardLoading: { + opacity: 0.7, + }, + streamDetails: { + flex: 1, + }, + streamNameRow: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + width: '100%', + flexWrap: 'wrap', + gap: 8 + }, + streamTitleContainer: { + flex: 1, + }, + streamName: { + fontSize: 14, + fontWeight: '600', + marginBottom: 2, + lineHeight: 20, + color: colors.highEmphasis, + }, + streamAddonName: { + fontSize: 13, + lineHeight: 18, + color: colors.mediumEmphasis, + marginBottom: 6, + }, + streamMetaRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 4, + marginBottom: 6, + alignItems: 'center', + }, + chip: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 4, + marginRight: 4, + marginBottom: 4, + }, + chipText: { + color: colors.highEmphasis, + fontSize: 12, + fontWeight: '600', + }, + progressContainer: { + height: 20, + backgroundColor: colors.transparentLight, + borderRadius: 10, + overflow: 'hidden', + marginBottom: 6, + }, + progressBar: { + height: '100%', + backgroundColor: colors.primary, + }, + progressText: { + color: colors.highEmphasis, + fontSize: 12, + fontWeight: '600', + marginLeft: 8, + }, + streamAction: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.elevation2, + justifyContent: 'center', + alignItems: 'center', + }, + skeletonCard: { + opacity: 0.7, + }, + skeletonTitle: { + height: 24, + width: '40%', + backgroundColor: colors.transparentLight, + borderRadius: 4, + marginBottom: 16, + }, + skeletonIcon: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: colors.transparentLight, + marginRight: 12, + }, + skeletonText: { + height: 16, + borderRadius: 4, + marginBottom: 8, + backgroundColor: colors.transparentLight, + }, + skeletonTag: { + width: 60, + height: 20, + borderRadius: 4, + marginRight: 8, + backgroundColor: colors.transparentLight, + }, noStreams: { flex: 1, justifyContent: 'center', @@ -390,6 +1054,90 @@ const styles = StyleSheet.create({ fontSize: 16, marginTop: 16, }, + streamsHeroContainer: { + width: '100%', + height: 300, + marginBottom: 0, + position: 'relative', + backgroundColor: colors.black, + pointerEvents: 'box-none', + }, + streamsHeroBackground: { + width: '100%', + height: '100%', + backgroundColor: colors.black, + }, + streamsHeroGradient: { + flex: 1, + justifyContent: 'flex-end', + padding: 16, + paddingBottom: 0, + }, + streamsHeroContent: { + width: '100%', + }, + streamsHeroInfo: { + width: '100%', + }, + streamsHeroEpisodeNumber: { + color: colors.primary, + fontSize: 14, + fontWeight: 'bold', + marginBottom: 2, + textShadowColor: 'rgba(0,0,0,0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + streamsHeroTitle: { + color: colors.highEmphasis, + fontSize: 24, + fontWeight: 'bold', + marginBottom: 4, + textShadowColor: 'rgba(0,0,0,0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 3, + }, + streamsHeroOverview: { + color: colors.mediumEmphasis, + fontSize: 14, + lineHeight: 20, + marginBottom: 2, + textShadowColor: 'rgba(0,0,0,0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + streamsHeroMeta: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + marginTop: 0, + }, + streamsHeroReleased: { + color: colors.mediumEmphasis, + fontSize: 14, + textShadowColor: 'rgba(0,0,0,0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + streamsHeroRating: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.7)', + paddingHorizontal: 6, + paddingVertical: 3, + borderRadius: 4, + marginTop: 0, + }, + tmdbLogo: { + width: 20, + height: 14, + }, + streamsHeroRatingText: { + color: '#01b4e4', + fontSize: 13, + fontWeight: '700', + marginLeft: 4, + }, loadingContainer: { alignItems: 'center', paddingVertical: 24, @@ -400,6 +1148,29 @@ const styles = StyleSheet.create({ marginLeft: 4, fontWeight: '500', }, + downloadingIndicator: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.transparentLight, + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 12, + marginLeft: 8, + }, + downloadingText: { + color: colors.primary, + fontSize: 12, + marginLeft: 4, + fontWeight: '500', + }, + loadingIndicator: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 12, + marginLeft: 8, + }, footerLoading: { flexDirection: 'row', alignItems: 'center', @@ -412,6 +1183,56 @@ const styles = StyleSheet.create({ marginLeft: 8, fontWeight: '500', }, + movieTitleContainer: { + width: '100%', + height: 180, + backgroundColor: colors.black, + pointerEvents: 'box-none', + }, + movieTitleBackground: { + width: '100%', + height: '100%', + backgroundColor: colors.black, + }, + movieTitleGradient: { + flex: 1, + justifyContent: 'center', + padding: 16, + }, + movieTitleContent: { + width: '100%', + alignItems: 'center', + marginTop: Platform.OS === 'android' ? 35 : 45, + }, + movieLogo: { + width: width * 0.6, + height: 70, + marginBottom: 8, + }, + movieTitle: { + color: colors.highEmphasis, + fontSize: 28, + fontWeight: '900', + textAlign: 'center', + textShadowColor: 'rgba(0,0,0,0.75)', + textShadowOffset: { width: 0, height: 2 }, + textShadowRadius: 4, + letterSpacing: -0.5, + }, + streamsHeroRuntime: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + backgroundColor: 'rgba(0,0,0,0.5)', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6, + }, + streamsHeroRuntimeText: { + color: colors.mediumEmphasis, + fontSize: 13, + fontWeight: '600', + }, }); -export default React.memo(StreamsScreen); \ No newline at end of file +export default memo(StreamsScreen); \ No newline at end of file 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