From 18a1672eed9c9a51ea1288e9fcde7ac2eea7efa9 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 13:58:06 +0530 Subject: [PATCH 1/3] Implement caching mechanism in useFeaturedContent hook to optimize data fetching and reduce unnecessary network requests; update HomeScreen to utilize new FeaturedContent component for improved structure and readability. Enhance refresh logic to handle persistence and ensure timely updates based on content source changes. --- src/components/home/CatalogSection.tsx | 147 ++++++++ src/components/home/ContentItem.tsx | 186 ++++++++++ src/components/home/DropUpMenu.tsx | 257 ++++++++++++++ src/components/home/FeaturedContent.tsx | 443 ++++++++++++++++++++++++ src/components/home/SkeletonLoaders.tsx | 70 ++++ src/hooks/useFeaturedContent.ts | 68 +++- src/screens/HomeScreen.tsx | 226 ++---------- src/styles/homeStyles.ts | 55 +++ 8 files changed, 1241 insertions(+), 211 deletions(-) create mode 100644 src/components/home/CatalogSection.tsx create mode 100644 src/components/home/ContentItem.tsx create mode 100644 src/components/home/DropUpMenu.tsx create mode 100644 src/components/home/FeaturedContent.tsx create mode 100644 src/components/home/SkeletonLoaders.tsx create mode 100644 src/styles/homeStyles.ts 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..54b21042 --- /dev/null +++ b/src/components/home/FeaturedContent.tsx @@ -0,0 +1,443 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ImageBackground, + Dimensions, + ViewStyle, + TextStyle, + ImageStyle, + ActivityIndicator +} 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 + + + + + + + + {!posterLoaded && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + featuredContainer: { + width: '100%', + height: height * 0.6, + marginTop: 0, + marginBottom: 8, + position: 'relative', + backgroundColor: colors.elevation1, + }, + imageContainer: { + width: '100%', + height: '100%', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 2, + }, + featuredImage: { + width: '100%', + height: '100%', + }, + backgroundFallback: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: colors.elevation1, + justifyContent: 'center', + alignItems: 'center', + zIndex: 1, + }, + featuredGradient: { + width: '100%', + height: '100%', + justifyContent: 'space-between', + }, + featuredContentContainer: { + flex: 1, + justifyContent: 'flex-end', + paddingHorizontal: 16, + paddingBottom: 20, + }, + featuredLogo: { + width: width * 0.7, + height: 100, + marginBottom: 0, + alignSelf: 'center', + }, + featuredTitleText: { + color: colors.highEmphasis, + fontSize: 28, + fontWeight: '900', + marginBottom: 8, + textShadowColor: 'rgba(0,0,0,0.6)', + textShadowOffset: { width: 0, height: 2 }, + textShadowRadius: 4, + textAlign: 'center', + paddingHorizontal: 16, + }, + genreContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 16, + flexWrap: 'wrap', + gap: 4, + }, + genreText: { + color: colors.white, + fontSize: 14, + fontWeight: '500', + opacity: 0.9, + }, + genreDot: { + color: colors.white, + fontSize: 14, + fontWeight: '500', + opacity: 0.6, + marginHorizontal: 4, + }, + featuredButtons: { + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-evenly', + width: '100%', + flex: 1, + maxHeight: 65, + paddingTop: 16, + }, + playButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 14, + paddingHorizontal: 32, + borderRadius: 30, + backgroundColor: colors.white, + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + flex: 0, + width: 150, + }, + myListButton: { + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + padding: 0, + gap: 6, + width: 44, + height: 44, + flex: undefined, + }, + infoButton: { + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + padding: 0, + gap: 4, + width: 44, + height: 44, + flex: undefined, + }, + playButtonText: { + color: colors.black, + fontWeight: '600', + marginLeft: 8, + fontSize: 16, + }, + myListButtonText: { + color: colors.white, + fontSize: 12, + fontWeight: '500', + }, + infoButtonText: { + color: colors.white, + fontSize: 12, + fontWeight: '500', + }, +}); + +export default FeaturedContent; \ No newline at end of file diff --git a/src/components/home/SkeletonLoaders.tsx b/src/components/home/SkeletonLoaders.tsx new file mode 100644 index 00000000..01278996 --- /dev/null +++ b/src/components/home/SkeletonLoaders.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { View, Text, ActivityIndicator, StyleSheet, Dimensions } from 'react-native'; +import { colors } from '../../styles/colors'; + +const { height } = Dimensions.get('window'); + +export const SkeletonCatalog = () => ( + + + + + +); + +export const SkeletonFeatured = () => ( + + + Loading featured content... + +); + +const styles = StyleSheet.create({ + catalogContainer: { + marginBottom: 24, + paddingTop: 0, + marginTop: 16, + }, + loadingPlaceholder: { + height: 200, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.elevation1, + borderRadius: 12, + marginHorizontal: 16, + }, + featuredLoadingContainer: { + height: height * 0.4, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.elevation1, + }, + loadingText: { + color: colors.textMuted, + marginTop: 12, + fontSize: 14, + }, + skeletonBox: { + backgroundColor: colors.elevation2, + borderRadius: 16, + overflow: 'hidden', + }, + skeletonFeatured: { + width: '100%', + height: height * 0.6, + backgroundColor: colors.elevation2, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + marginBottom: 0, + }, + skeletonPoster: { + backgroundColor: colors.elevation1, + marginHorizontal: 4, + borderRadius: 16, + }, +}); + +export default { + SkeletonCatalog, + SkeletonFeatured +}; \ No newline at end of file diff --git a/src/hooks/useFeaturedContent.ts b/src/hooks/useFeaturedContent.ts index 72c8727d..827cfa29 100644 --- a/src/hooks/useFeaturedContent.ts +++ b/src/hooks/useFeaturedContent.ts @@ -6,11 +6,22 @@ import * as Haptics from 'expo-haptics'; import { useGenres } from '../contexts/GenreContext'; import { useSettings, settingsEmitter } from './useSettings'; +// Create a persistent store outside of the hook to maintain state between navigation +const persistentStore = { + featuredContent: null as StreamingContent | null, + allFeaturedContent: [] as StreamingContent[], + lastFetchTime: 0, + isFirstLoad: true +}; + +// Cache timeout in milliseconds (e.g., 5 minutes) +const CACHE_TIMEOUT = 5 * 60 * 1000; + export function useFeaturedContent() { - const [featuredContent, setFeaturedContent] = useState(null); - const [allFeaturedContent, setAllFeaturedContent] = useState([]); + const [featuredContent, setFeaturedContent] = useState(persistentStore.featuredContent); + const [allFeaturedContent, setAllFeaturedContent] = useState(persistentStore.allFeaturedContent); const [isSaved, setIsSaved] = useState(false); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(persistentStore.isFirstLoad); const currentIndexRef = useRef(0); const abortControllerRef = useRef(null); const { settings } = useSettings(); @@ -32,7 +43,23 @@ export function useFeaturedContent() { } }, []); - const loadFeaturedContent = useCallback(async () => { + const loadFeaturedContent = useCallback(async (forceRefresh = false) => { + // Check if we should use cached data + const now = Date.now(); + const cacheAge = now - persistentStore.lastFetchTime; + + if (!forceRefresh && + persistentStore.featuredContent && + persistentStore.allFeaturedContent.length > 0 && + cacheAge < CACHE_TIMEOUT) { + // Use cached data + setFeaturedContent(persistentStore.featuredContent); + setAllFeaturedContent(persistentStore.allFeaturedContent); + setLoading(false); + persistentStore.isFirstLoad = false; + return; + } + setLoading(true); cleanup(); abortControllerRef.current = new AbortController(); @@ -101,13 +128,10 @@ export function useFeaturedContent() { const filteredCatalogs = selectedCatalogs && selectedCatalogs.length > 0 ? catalogs.filter(catalog => { const catalogId = `${catalog.addon}:${catalog.type}:${catalog.id}`; - console.log(`Checking catalog: ${catalogId}, selected: ${selectedCatalogs.includes(catalogId)}`); return selectedCatalogs.includes(catalogId); }) : catalogs; // Use all catalogs if none specifically selected - console.log(`Original catalogs: ${catalogs.length}, Filtered catalogs: ${filteredCatalogs.length}`); - // Flatten all catalog items into a single array, filter out items without posters const allItems = filteredCatalogs.flatMap(catalog => catalog.items) .filter(item => item.poster) @@ -122,16 +146,23 @@ export function useFeaturedContent() { if (signal.aborted) return; + // Update persistent store with the new data + persistentStore.allFeaturedContent = formattedContent; + persistentStore.lastFetchTime = now; + persistentStore.isFirstLoad = false; + setAllFeaturedContent(formattedContent); if (formattedContent.length > 0) { + persistentStore.featuredContent = formattedContent[0]; setFeaturedContent(formattedContent[0]); currentIndexRef.current = 0; } else { + persistentStore.featuredContent = null; setFeaturedContent(null); } } catch (error) { - if (signal.aborted) { + if (signal.aborted) { logger.info('Featured content fetch aborted'); } else { logger.error('Failed to load featured content:', error); @@ -147,12 +178,17 @@ export function useFeaturedContent() { // Load featured content initially and when content source changes useEffect(() => { - // Force a full refresh to get updated logos - if (contentSource === 'tmdb') { + const shouldForceRefresh = contentSource === 'tmdb' && + contentSource !== persistentStore.featuredContent?.type; + + if (shouldForceRefresh) { setAllFeaturedContent([]); setFeaturedContent(null); + persistentStore.allFeaturedContent = []; + persistentStore.featuredContent = null; } - loadFeaturedContent(); + + loadFeaturedContent(shouldForceRefresh); }, [loadFeaturedContent, contentSource, selectedCatalogs]); useEffect(() => { @@ -184,7 +220,10 @@ export function useFeaturedContent() { const rotateContent = () => { currentIndexRef.current = (currentIndexRef.current + 1) % allFeaturedContent.length; if (allFeaturedContent[currentIndexRef.current]) { - setFeaturedContent(allFeaturedContent[currentIndexRef.current]); + const newContent = allFeaturedContent[currentIndexRef.current]; + setFeaturedContent(newContent); + // Also update the persistent store + persistentStore.featuredContent = newContent; } }; @@ -217,11 +256,14 @@ export function useFeaturedContent() { } }, [featuredContent, isSaved]); + // Function to force a refresh if needed + const refreshFeatured = useCallback(() => loadFeaturedContent(true), [loadFeaturedContent]); + return { featuredContent, loading, isSaved, handleSaveToLibrary, - refreshFeatured: loadFeaturedContent + refreshFeatured }; } \ No newline at end of file diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 436eaf20..777732c8 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -55,6 +55,10 @@ import { storageService } from '../services/storageService'; import { useHomeCatalogs } from '../hooks/useHomeCatalogs'; import { useFeaturedContent } from '../hooks/useFeaturedContent'; import { useSettings, settingsEmitter } from '../hooks/useSettings'; +import FeaturedContent from '../components/home/FeaturedContent'; +import CatalogSection from '../components/home/CatalogSection'; +import { SkeletonFeatured } from '../components/home/SkeletonLoaders'; +import homeStyles from '../styles/homeStyles'; // Define interfaces for our data interface Category { @@ -348,13 +352,6 @@ const SkeletonCatalog = () => ( ); -const SkeletonFeatured = () => ( - - - Loading featured content... - -); - const HomeScreen = () => { const navigation = useNavigation>(); const isDarkMode = useColorScheme() === 'dark'; @@ -390,15 +387,15 @@ const HomeScreen = () => { setFeaturedContentSource(settings.featuredContentSource); }, [settings]); - // If featured content source changes, refresh featured content with debouncing + // Update the featured content refresh logic to handle persistence useEffect(() => { - if (showHeroSection) { + if (showHeroSection && featuredContentSource !== settings.featuredContentSource) { // Clear any existing timeout if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); } - // Set a new timeout to debounce the refresh + // Set a new timeout to debounce the refresh - only when settings actually change refreshTimeoutRef.current = setTimeout(() => { refreshFeatured(); refreshTimeoutRef.current = null; @@ -411,14 +408,14 @@ const HomeScreen = () => { clearTimeout(refreshTimeoutRef.current); } }; - }, [featuredContentSource, showHeroSection, refreshFeatured]); + }, [featuredContentSource, settings.featuredContentSource, showHeroSection, refreshFeatured]); useFocusEffect( useCallback(() => { const statusBarConfig = () => { StatusBar.setBarStyle("light-content"); - StatusBar.setTranslucent(true); - StatusBar.setBackgroundColor('transparent'); + StatusBar.setTranslucent(true); + StatusBar.setBackgroundColor('transparent'); }; statusBarConfig(); @@ -476,7 +473,8 @@ const HomeScreen = () => { continueWatchingRef.current?.refresh(), ]; - // Only refresh featured content if hero section is enabled + // Only refresh featured content if hero section is enabled, + // and force refresh to bypass the cache if (showHeroSection) { refreshTasks.push(refreshFeatured()); } @@ -526,198 +524,24 @@ const HomeScreen = () => { }; }, [navigation, refreshContinueWatching]); - const renderFeaturedContent = () => { - if (!featuredContent) { - return ; - } - - return ( - { - if (featuredContent) { - navigation.navigate('Metadata', { - id: featuredContent.id, - type: featuredContent.type - }); - } - }} - style={styles.featuredContainer} - > - - - - {featuredContent.logo ? ( - - ) : ( - {featuredContent.name} - )} - - {featuredContent.genres?.slice(0, 3).map((genre, index, array) => ( - - {genre} - {index < array.length - 1 && ( - - )} - - ))} - - - - - - {isSaved ? "Saved" : "Save"} - - - - { - if (featuredContent) { - navigation.navigate('Streams', { - id: featuredContent.id, - type: featuredContent.type - }); - } - }} - > - - Play - - - { - if (featuredContent) { - navigation.navigate('Metadata', { - id: featuredContent.id, - type: featuredContent.type - }); - } - }} - > - - Info - - - - - - - ); - }; - - const renderContentItem = useCallback(({ item, index }: { item: StreamingContent, index: number }) => { - return ( - - - - ); - }, [handleContentPress]); - - const renderCatalog = ({ item }: { item: CatalogContent }) => { - return ( - - - - {item.name} - - - - navigation.navigate('Catalog', { - id: item.id, - type: item.type, - addonId: item.addon - }) - } - style={styles.seeAllButton} - > - See More - - - - - renderContentItem({ item, index })} - keyExtractor={(item) => `${item.id}-${item.type}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.catalogList} - snapToInterval={POSTER_WIDTH + 12} - decelerationRate="fast" - snapToAlignment="start" - ItemSeparatorComponent={() => } - initialNumToRender={4} - maxToRenderPerBatch={4} - windowSize={5} - removeClippedSubviews={Platform.OS === 'android'} - getItemLayout={(data, index) => ({ - length: POSTER_WIDTH + 12, - offset: (POSTER_WIDTH + 12) * index, - index, - })} - /> - - ); - }; - if (isLoading && !isRefreshing) { return ( - + - + - Loading your content... + Loading your content... ); } return ( - + { /> } contentContainerStyle={[ - styles.scrollContent, + homeStyles.scrollContent, { paddingTop: Platform.OS === 'ios' ? 0 : 0 } ]} showsVerticalScrollIndicator={false} > - {showHeroSection && renderFeaturedContent()} + {showHeroSection && ( + + )} @@ -753,22 +583,22 @@ const HomeScreen = () => { {catalogs.length > 0 ? ( catalogs.map((catalog, index) => ( - {renderCatalog({ item: catalog })} + )) ) : ( !catalogsLoading && ( - + No content available navigation.navigate('Settings')} > - Add Catalogs + Add Catalogs ) diff --git a/src/styles/homeStyles.ts b/src/styles/homeStyles.ts new file mode 100644 index 00000000..b8b05660 --- /dev/null +++ b/src/styles/homeStyles.ts @@ -0,0 +1,55 @@ +import { StyleSheet, Dimensions, Platform } from 'react-native'; +import { colors } from './colors'; + +const { width, height } = Dimensions.get('window'); +export const POSTER_WIDTH = (width - 50) / 3; + +export const homeStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + scrollContent: { + paddingBottom: 40, + }, + loadingMainContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingBottom: 40, + }, + loadingText: { + color: colors.textMuted, + marginTop: 12, + fontSize: 14, + }, + emptyCatalog: { + padding: 32, + alignItems: 'center', + backgroundColor: colors.elevation1, + margin: 16, + borderRadius: 16, + }, + addCatalogButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.primary, + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 30, + marginTop: 16, + elevation: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 3, + }, + addCatalogButtonText: { + color: colors.white, + fontSize: 14, + fontWeight: '600', + marginLeft: 8, + }, +}); + +export default homeStyles; \ No newline at end of file From d86a2087788920fe09bf89784ebfe31360cd2d3b Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 14:21:24 +0530 Subject: [PATCH 2/3] Remove unused ActivityIndicator from FeaturedContent component to streamline code and improve readability. --- src/components/home/FeaturedContent.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index 54b21042..e2dc4136 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -8,8 +8,7 @@ import { Dimensions, ViewStyle, TextStyle, - ImageStyle, - ActivityIndicator + ImageStyle } from 'react-native'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; @@ -284,12 +283,6 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat - - {!posterLoaded && ( - - - - )} ); }; From dfda3ff38ac6272f7b7efa12783c72a8245a5635 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 14:30:27 +0530 Subject: [PATCH 3/3] Revert "Refactor StreamsScreen to streamline component structure and enhance readability; replace inline components with imports for MovieHero and EpisodeHero, and utilize custom hooks for provider management. Optimize loading logic and animation effects, while removing unused code and improving overall performance." This reverts commit 3b6fb438e31a07eb7dfc7b71c9e652fc61fa1a40. --- src/components/streams/EpisodeHero.tsx | 223 ----- src/components/streams/MovieHero.tsx | 98 --- src/components/streams/ProviderFilter.tsx | 80 -- src/components/streams/StreamCard.tsx | 181 ---- src/hooks/useStreamNavigation.ts | 221 ----- src/hooks/useStreamProviders.ts | 146 ---- src/screens/StreamsScreen.tsx | 991 ++++++++++++++++++++-- 7 files changed, 906 insertions(+), 1034 deletions(-) delete mode 100644 src/components/streams/EpisodeHero.tsx delete mode 100644 src/components/streams/MovieHero.tsx delete mode 100644 src/components/streams/ProviderFilter.tsx delete mode 100644 src/components/streams/StreamCard.tsx delete mode 100644 src/hooks/useStreamNavigation.ts delete mode 100644 src/hooks/useStreamProviders.ts 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/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/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