diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx index b7edc37..dbbd73c 100644 --- a/src/components/home/CatalogSection.tsx +++ b/src/components/home/CatalogSection.tsx @@ -17,8 +17,9 @@ const { width } = Dimensions.get('window'); // Dynamic poster calculation based on screen width - show 1/4 of next poster const calculatePosterLayout = (screenWidth: number) => { - const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters - const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters + // TV gets larger posters + const MIN_POSTER_WIDTH = Platform.isTV ? 140 : 100; + const MAX_POSTER_WIDTH = Platform.isTV ? 180 : 130; const LEFT_PADDING = 16; // Left padding const SPACING = 8; // Space between posters @@ -26,7 +27,7 @@ const calculatePosterLayout = (screenWidth: number) => { const availableWidth = screenWidth - LEFT_PADDING; // Try different numbers of full posters to find the best fit - let bestLayout = { numFullPosters: 3, posterWidth: 120 }; + let bestLayout = { numFullPosters: 3, posterWidth: Platform.isTV ? 160 : 120 }; for (let n = 3; n <= 6; n++) { // Calculate poster width needed for N full posters + 0.25 partial poster @@ -123,6 +124,16 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { minIndexForVisible: 0 }} onEndReachedThreshold={1} + // TV-specific focus navigation properties + {...(Platform.isTV && { + directionalLockEnabled: true, + horizontal: true, + scrollEnabled: true, + focusable: false, + tvParallaxProperties: { + enabled: false, + }, + })} /> ); @@ -131,6 +142,8 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { const styles = StyleSheet.create({ catalogContainer: { marginBottom: 28, + overflow: 'visible', + paddingVertical: Platform.isTV ? 8 : 0, }, catalogHeader: { flexDirection: 'row', @@ -174,7 +187,9 @@ const styles = StyleSheet.create({ }, catalogList: { paddingHorizontal: 16, + paddingVertical: Platform.isTV ? 12 : 0, + overflow: 'visible', }, }); -export default React.memo(CatalogSection); \ No newline at end of file +export default React.memo(CatalogSection); \ No newline at end of file diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 1c7728c..d81edf2 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text } from 'react-native'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Animated } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../../contexts/ThemeContext'; @@ -15,8 +15,9 @@ const { width } = Dimensions.get('window'); // Dynamic poster calculation based on screen width - show 1/4 of next poster const calculatePosterLayout = (screenWidth: number) => { - const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters - const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters + // TV gets larger posters + const MIN_POSTER_WIDTH = Platform.isTV ? 140 : 100; + const MAX_POSTER_WIDTH = Platform.isTV ? 180 : 130; const LEFT_PADDING = 16; // Left padding const SPACING = 8; // Space between posters @@ -24,7 +25,7 @@ const calculatePosterLayout = (screenWidth: number) => { const availableWidth = screenWidth - LEFT_PADDING; // Try different numbers of full posters to find the best fit - let bestLayout = { numFullPosters: 3, posterWidth: 120 }; + let bestLayout = { numFullPosters: 3, posterWidth: Platform.isTV ? 160 : 120 }; for (let n = 3; n <= 6; n++) { // Calculate poster width needed for N full posters + 0.25 partial poster @@ -53,7 +54,11 @@ const POSTER_WIDTH = posterLayout.posterWidth; const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => { const [menuVisible, setMenuVisible] = useState(false); const [isWatched, setIsWatched] = useState(false); + const [isFocused, setIsFocused] = useState(false); const { currentTheme } = useTheme(); + + // Animation values for TV focus effects + const scaleAnim = useRef(new Animated.Value(1)).current; const handleLongPress = useCallback(() => { setMenuVisible(true); @@ -86,39 +91,75 @@ const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => { setMenuVisible(false); }, []); + // TV Focus handlers + const handleFocus = useCallback(() => { + if (Platform.isTV) { + setIsFocused(true); + Animated.spring(scaleAnim, { + toValue: 1.15, + useNativeDriver: true, + tension: 80, + friction: 6, + }).start(); + } + }, [scaleAnim]); + + const handleBlur = useCallback(() => { + if (Platform.isTV) { + setIsFocused(false); + Animated.spring(scaleAnim, { + toValue: 1, + useNativeDriver: true, + tension: 80, + friction: 6, + }).start(); + } + }, [scaleAnim]); + + // Dynamic styles for focus effects + const animatedContainerStyle = { + transform: [{ scale: scaleAnim }], + zIndex: isFocused && Platform.isTV ? 10 : 1, + }; + return ( <> - - - - {isWatched && ( - - + + + + + {isWatched && ( + + + + )} + {item.inLibrary && ( + + + + )} - )} - {item.inLibrary && ( - - - - )} - - + + {item.name} @@ -199,4 +240,4 @@ const styles = StyleSheet.create({ } }); -export default ContentItem; \ No newline at end of file +export default ContentItem; \ No newline at end of file diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 8244ba5..13a60ef 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -9,7 +9,9 @@ import { AppState, AppStateStatus, Alert, - ActivityIndicator + ActivityIndicator, + Platform, + Animated } from 'react-native'; // Removed react-native-reanimated import import { useNavigation } from '@react-navigation/native'; @@ -41,8 +43,9 @@ interface ContinueWatchingRef { // Dynamic poster calculation based on screen width for Continue Watching section const calculatePosterLayout = (screenWidth: number) => { - const MIN_POSTER_WIDTH = 120; // Slightly larger for continue watching items - const MAX_POSTER_WIDTH = 160; // Maximum poster width for this section + // TV gets larger posters + const MIN_POSTER_WIDTH = Platform.isTV ? 160 : 120; + const MAX_POSTER_WIDTH = Platform.isTV ? 200 : 160; const HORIZONTAL_PADDING = 40; // Total horizontal padding/margins // Calculate how many posters can fit (fewer items for continue watching) @@ -88,6 +91,160 @@ const isEpisodeReleased = (video: any): boolean => { }; // Create a proper imperative handle with React.forwardRef and updated type +// Continue Watching Item Component with TV Focus Animations +const ContinueWatchingItem = React.memo(({ item, onPress, onLongPress, deletingItemId, currentTheme }: { + item: ContinueWatchingItem; + onPress: () => void; + onLongPress: () => void; + deletingItemId: string | null; + currentTheme: any; +}) => { + const [isFocused, setIsFocused] = useState(false); + + // Animation values for TV focus effects + const scaleAnim = useRef(new Animated.Value(1)).current; + + // TV Focus handlers + const handleFocus = useCallback(() => { + if (Platform.isTV) { + setIsFocused(true); + Animated.spring(scaleAnim, { + toValue: 1.08, + useNativeDriver: true, + tension: 80, + friction: 6, + }).start(); + } + }, [scaleAnim]); + + const handleBlur = useCallback(() => { + if (Platform.isTV) { + setIsFocused(false); + Animated.spring(scaleAnim, { + toValue: 1, + useNativeDriver: true, + tension: 80, + friction: 6, + }).start(); + } + }, [scaleAnim]); + + // Dynamic styles for focus effects + const animatedContainerStyle = { + transform: [{ scale: scaleAnim }], + zIndex: isFocused && Platform.isTV ? 10 : 1, + }; + + return ( + + + {/* Poster Image */} + + + + {/* Delete Indicator Overlay */} + {deletingItemId === item.id && ( + + + + )} + + + {/* Content Details */} + + + {(() => { + const isUpNext = item.progress === 0; + return ( + + + {item.name} + + {isUpNext && ( + + Up Next + + )} + + ); + })()} + + + {/* Episode Info or Year */} + {(() => { + if (item.type === 'series' && item.season && item.episode) { + return ( + + + Season {item.season} + + {item.episodeTitle && ( + + Episode {item.episode}: {item.episodeTitle} + + )} + + ); + } else { + return ( + + {item.year} + + ); + } + })()} + + {/* Progress Bar */} + + + + + + {item.progress}% watched + + + + + + ); +}); + const ContinueWatchingSection = React.forwardRef((props, ref) => { const navigation = useNavigation>(); const { currentTheme } = useTheme(); @@ -583,109 +740,13 @@ const ContinueWatchingSection = React.forwardRef((props, re ( - handleContentPress(item.id, item.type)} onLongPress={() => handleLongPress(item)} - delayLongPress={800} - > - {/* Poster Image */} - - - - {/* Delete Indicator Overlay */} - {deletingItemId === item.id && ( - - - - )} - - - {/* Content Details */} - - - {(() => { - const isUpNext = item.progress === 0; - return ( - - - {item.name} - - {isUpNext && ( - - Up Next - - )} - - ); - })()} - - - {/* Episode Info or Year */} - {(() => { - if (item.type === 'series' && item.season && item.episode) { - return ( - - - Season {item.season} - - {item.episodeTitle && ( - - {item.episodeTitle} - - )} - - ); - } else { - return ( - - {item.year} • {item.type === 'movie' ? 'Movie' : 'Series'} - - ); - } - })()} - - {/* Progress Bar */} - {item.progress > 0 && ( - - - - - - {Math.round(item.progress)}% watched - - - )} - - + deletingItemId={deletingItemId} + currentTheme={currentTheme} + /> )} keyExtractor={(item) => `continue-${item.id}-${item.type}`} horizontal @@ -695,6 +756,16 @@ const ContinueWatchingSection = React.forwardRef((props, re decelerationRate="fast" snapToAlignment="start" ItemSeparatorComponent={() => } + // TV-specific focus navigation properties + {...(Platform.isTV && { + directionalLockEnabled: true, + horizontal: true, + scrollEnabled: true, + focusable: false, + tvParallaxProperties: { + enabled: false, + }, + })} /> ); @@ -705,6 +776,8 @@ const styles = StyleSheet.create({ marginBottom: 28, paddingTop: 0, marginTop: 12, + overflow: 'visible', + paddingVertical: Platform.isTV ? 8 : 0, }, header: { flexDirection: 'row', @@ -735,6 +808,8 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingBottom: 8, paddingTop: 4, + paddingVertical: Platform.isTV ? 12 : 4, + overflow: 'visible', }, wideContentItem: { width: 280, diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index ca478cd..bfc6147 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -4,13 +4,10 @@ import { Text, StyleSheet, TouchableOpacity, - ImageBackground, Dimensions, - ViewStyle, - TextStyle, - ImageStyle, - ActivityIndicator, - Platform + Platform, + TVFocusGuideView, + Animated } from 'react-native'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; @@ -18,18 +15,13 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Image as ExpoImage } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; import { StreamingContent } from '../../services/catalogService'; -import { SkeletonFeatured } from './SkeletonLoaders'; -import { isValidMetahubLogo, hasValidLogoFormat, isMetahubUrl, isTmdbUrl } from '../../utils/logoUtils'; -import { useSettings } from '../../hooks/useSettings'; -import { TMDBService } from '../../services/tmdbService'; + import { logger } from '../../utils/logger'; import { useTheme } from '../../contexts/ThemeContext'; import { imageCacheService } from '../../services/imageCacheService'; interface FeaturedContentProps { featuredContent: StreamingContent | null; - isSaved: boolean; - handleSaveToLibrary: () => void; } // Cache to store preloaded images @@ -40,17 +32,25 @@ const { width, height } = Dimensions.get('window'); const NoFeaturedContent = () => { const navigation = useNavigation>(); const { currentTheme } = useTheme(); + const [isFocused, setIsFocused] = useState(false); return ( - + No featured content available navigation.navigate('Search')} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + hasTVPreferredFocus={true} > Explore Content @@ -59,15 +59,17 @@ const NoFeaturedContent = () => { ); }; -const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => { +const FeaturedContent = ({ featuredContent }: FeaturedContentProps) => { const navigation = useNavigation>(); const { currentTheme } = useTheme(); - const { settings } = useSettings(); const [logoUrl, setLogoUrl] = useState(null); const [isLogoLoading, setIsLogoLoading] = useState(false); - const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); - // Removed TMDB service integration + const focusGuideRef = useRef(null); + + // Animation values for TV focus effects + const scaleAnim = useRef(new Animated.Value(1)).current; + const opacityAnim = useRef(new Animated.Value(1)).current; // Preload image when component mounts useEffect(() => { @@ -86,14 +88,16 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat // TMDB data fetching removed due to API limitations + + // Fetch logo when featured content changes useEffect(() => { const fetchLogo = async () => { if (!featuredContent || isLogoLoading) return; - + setIsLogoLoading(true); setLogoUrl(null); - + try { // Use existing logo logic if (featuredContent.logo) { @@ -107,25 +111,9 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat }; fetchLogo(); - }, [featuredContent]); + }, [featuredContent]); - const handlePlayPress = () => { - if (featuredContent) { - navigation.navigate('Metadata', { - id: featuredContent.id, - type: featuredContent.type - }); - } - }; - const handleInfoPress = () => { - if (featuredContent) { - navigation.navigate('Metadata', { - id: featuredContent.id, - type: featuredContent.type - }); - } - }; const formatGenres = (genres: string[] | undefined) => { if (!genres || genres.length === 0) return ''; @@ -136,110 +124,126 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat return ; } - const posterUrl = featuredContent.poster; + const backdropUrl = featuredContent.banner || featuredContent.poster; const formattedGenres = formatGenres(featuredContent.genres); return ( - {/* Background Image */} + {/* Background Image with Parallax Effect */} - {posterUrl && !imageError ? ( - setImageLoaded(true)} - onError={() => setImageError(true)} - placeholder={{ uri: 'https://via.placeholder.com/400x600' }} - placeholderContentFit="cover" - /> + {backdropUrl && !imageError ? ( + + setImageError(true)} + placeholder={{ uri: 'https://via.placeholder.com/400x600' }} + placeholderContentFit="cover" + /> + ) : ( - + )} - {/* Content Overlay */} - - - {/* Gradient Overlay */} + {/* Left Side Dark Gradient Fade */} + + {/* Enhanced Gradient Overlay for TV */} + - - {/* Logo or Title */} - {logoUrl && !isLogoLoading ? ( - - ) : ( - - {featuredContent.name} - - )} - - {/* Genres */} - {formattedGenres && ( - - - {formattedGenres} - + + + {/* Logo or Title with TV Scaling - Left Aligned */} + + {logoUrl && !isLogoLoading ? ( + + ) : ( + + {featuredContent.name} + + )} - )} - {/* Action Buttons */} - - {/* Play Button */} - - - Play - + {/* Enhanced Metadata Section */} + + {/* Genres */} + {formattedGenres && ( + + + {formattedGenres} + + + )} + + {/* Additional metadata for TV */} + {Platform.isTV && featuredContent.year && ( + + {featuredContent.year} + + )} + - {/* My List Button */} - - - - {isSaved ? 'Saved' : 'My List'} - - - {/* Info Button */} - - - Info - - + ); @@ -248,17 +252,17 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat const styles = StyleSheet.create({ featuredContainer: { width: '100%', - height: height * 0.55, + height: Platform.isTV ? height * 0.75 : height * 0.55, marginTop: 0, - marginBottom: 12, + marginBottom: Platform.isTV ? 24 : 12, position: 'relative', - borderRadius: 12, + borderRadius: Platform.isTV ? 0 : 12, overflow: 'hidden', - elevation: 8, + elevation: Platform.isTV ? 0 : 8, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, + shadowOpacity: Platform.isTV ? 0 : 0.3, + shadowRadius: Platform.isTV ? 0 : 8, }, imageContainer: { width: '100%', @@ -268,12 +272,16 @@ const styles = StyleSheet.create({ left: 0, right: 0, bottom: 0, - zIndex: 2, + zIndex: 1, + }, + imageWrapper: { + width: '100%', + height: '100%', }, featuredImage: { width: '100%', height: '100%', - transform: [{ scale: 1.05 }], + transform: Platform.isTV ? [{ scale: 1.02 }] : [{ scale: 1.05 }], }, backgroundFallback: { position: 'absolute', @@ -285,137 +293,128 @@ const styles = StyleSheet.create({ alignItems: 'center', zIndex: 1, }, + leftGradient: { + position: 'absolute', + top: 0, + left: 0, + width: '80%', + height: '100%', + zIndex: 2, + }, featuredGradient: { width: '100%', height: '100%', - justifyContent: 'space-between', - paddingTop: 20, + justifyContent: 'flex-end', + zIndex: 3, + }, + tvFocusGuide: { + flex: 1, + width: '100%', + height: '100%', }, featuredContentContainer: { flex: 1, justifyContent: 'flex-end', - paddingHorizontal: 20, - paddingBottom: 8, - paddingTop: 40, + paddingHorizontal: Platform.isTV ? 60 : 20, + paddingBottom: Platform.isTV ? 60 : 20, + paddingTop: Platform.isTV ? 60 : 40, + }, + titleContainer: { + alignItems: 'flex-start', + marginBottom: Platform.isTV ? 24 : 16, + paddingHorizontal: 0, + position: 'relative', + height: Platform.isTV ? 160 : 160, + width: '100%', + marginLeft: Platform.isTV ? -200 : 0, }, featuredLogo: { - width: width * 0.7, - height: 100, + width: width * 0.9, + height: 160, marginBottom: 0, - alignSelf: 'center', + alignSelf: 'flex-start', + position: Platform.isTV ? 'absolute' : 'relative', + left: Platform.isTV ? 0 : 'auto', + }, + featuredLogoTV: { + width: width * 0.8, + height: 200, + maxWidth: 900, + position: 'absolute', + left: 0, }, featuredTitleText: { - fontSize: 28, + fontSize: 32, fontWeight: '900', marginBottom: 8, - textShadowColor: 'rgba(0,0,0,0.6)', + textShadowColor: 'rgba(0,0,0,0.8)', textShadowOffset: { width: 0, height: 2 }, - textShadowRadius: 4, - textAlign: 'center', - paddingHorizontal: 16, + textShadowRadius: 6, + textAlign: 'left', + paddingHorizontal: 0, + lineHeight: 38, + position: Platform.isTV ? 'absolute' : 'relative', + left: Platform.isTV ? 0 : 'auto', + }, + featuredTitleTextTV: { + fontSize: 52, + lineHeight: 60, + maxWidth: width * 0.8, + textShadowRadius: 8, + }, + metadataContainer: { + alignItems: 'flex-start', + marginBottom: Platform.isTV ? 32 : 20, }, genreContainer: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', - marginBottom: 4, + justifyContent: 'flex-start', + marginBottom: Platform.isTV ? 12 : 8, flexWrap: 'wrap', - gap: 4, + gap: Platform.isTV ? 8 : 4, }, genreText: { fontSize: 14, fontWeight: '500', opacity: 0.9, + textShadowColor: 'rgba(0,0,0,0.6)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 3, }, - genreDot: { - fontSize: 14, - fontWeight: '500', - opacity: 0.6, - marginHorizontal: 4, - }, - featuredButtons: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-evenly', - width: '100%', - minHeight: 70, - paddingTop: 12, - paddingBottom: 20, - paddingHorizontal: 8, - }, - playButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - paddingHorizontal: 28, - borderRadius: 30, - elevation: 4, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 4, - flex: 0, - width: 140, - }, - 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: { + genreTextTV: { + fontSize: 18, fontWeight: '600', - marginLeft: 8, + }, + yearContainer: { + marginTop: 8, + }, + yearText: { fontSize: 16, - }, - myListButtonText: { - fontSize: 12, fontWeight: '500', + color: '#FFFFFF', + opacity: 0.8, + textShadowColor: 'rgba(0,0,0,0.6)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 3, }, - infoButtonText: { - fontSize: 12, - fontWeight: '500', - }, - contentOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0,0,0,0.15)', - zIndex: 1, - pointerEvents: 'none', - }, + noContentText: { - fontSize: 16, + fontSize: Platform.isTV ? 20 : 16, fontWeight: '500', marginTop: 16, marginBottom: 20, textAlign: 'center', }, exploreButton: { - paddingHorizontal: 24, - paddingVertical: 12, - borderRadius: 8, + paddingHorizontal: Platform.isTV ? 32 : 24, + paddingVertical: Platform.isTV ? 16 : 12, + borderRadius: Platform.isTV ? 12 : 8, + borderWidth: 0, }, exploreButtonText: { color: '#FFFFFF', - fontSize: 16, + fontSize: Platform.isTV ? 18 : 16, fontWeight: '600', }, }); diff --git a/src/components/metadata/MoreLikeThisSection.tsx b/src/components/metadata/MoreLikeThisSection.tsx index f2df2e4..62f7934 100644 --- a/src/components/metadata/MoreLikeThisSection.tsx +++ b/src/components/metadata/MoreLikeThisSection.tsx @@ -8,12 +8,13 @@ import { ActivityIndicator, Dimensions, Alert, + Platform, } from 'react-native'; import { Image } from 'expo-image'; import { useNavigation, StackActions } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; -import { StreamingContent } from '../../types/metadata'; +import { StreamingContent } from '../../services/catalogService'; import { useTheme } from '../../contexts/ThemeContext'; import { TMDBService } from '../../services/tmdbService'; import { catalogService } from '../../services/catalogService'; @@ -22,8 +23,9 @@ const { width } = Dimensions.get('window'); // Dynamic poster calculation based on screen width for More Like This section const calculatePosterLayout = (screenWidth: number) => { - const MIN_POSTER_WIDTH = 100; // Slightly smaller for more items in this section - const MAX_POSTER_WIDTH = 130; // Maximum poster width + // TV gets larger posters + const MIN_POSTER_WIDTH = Platform.isTV ? 140 : 100; + const MAX_POSTER_WIDTH = Platform.isTV ? 170 : 130; const HORIZONTAL_PADDING = 48; // Total horizontal padding/margins // Calculate how many posters can fit (aim for slightly more items than main sections) @@ -169,4 +171,4 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, -}); \ No newline at end of file +}); \ No newline at end of file diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 6f76975..806d462 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -634,8 +634,9 @@ const { width, height } = Dimensions.get('window'); // Dynamic poster calculation based on screen width - show 1/4 of next poster const calculatePosterLayout = (screenWidth: number) => { - const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters - const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters + // TV gets larger posters + const MIN_POSTER_WIDTH = Platform.isTV ? 140 : 100; + const MAX_POSTER_WIDTH = Platform.isTV ? 180 : 130; const LEFT_PADDING = 16; // Left padding const SPACING = 8; // Space between posters @@ -643,7 +644,7 @@ const calculatePosterLayout = (screenWidth: number) => { const availableWidth = screenWidth - LEFT_PADDING; // Try different numbers of full posters to find the best fit - let bestLayout = { numFullPosters: 3, posterWidth: 120 }; + let bestLayout = { numFullPosters: 3, posterWidth: Platform.isTV ? 160 : 120 }; for (let n = 3; n <= 6; n++) { // Calculate poster width needed for N full posters + 0.25 partial poster diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index af599c8..b430deb 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -15,6 +15,7 @@ import { Dimensions, Linking, Clipboard, + TVEventHandler, } from 'react-native'; @@ -74,6 +75,8 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the showLogos?: boolean; }) => { + + // Handle long press to copy stream URL to clipboard const handleLongPress = useCallback(async () => { if (stream.url) { @@ -94,7 +97,6 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the } } }, [stream.url]); - const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); const streamInfo = useMemo(() => { const title = stream.title || ''; @@ -170,77 +172,305 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the }; }, [stream.addonId, stream.addon]); + const isTV = Platform.isTV; + + // Horizontal TV-optimized card design + const cardStyle = { + flexDirection: 'row' as const, + alignItems: 'stretch' as const, + backgroundColor: isTV ? '#1a1a1a' : theme.colors.card, + borderRadius: isTV ? 24 : 12, + marginHorizontal: isTV ? 0 : 8, + marginVertical: isTV ? 16 : 8, + minHeight: isTV ? 160 : 90, + overflow: 'hidden' as const, + borderWidth: isTV ? 3 : 1, + borderColor: isTV ? '#333333' : theme.colors.cardHighlight, + shadowColor: '#000000', + shadowOffset: { width: 0, height: isTV ? 16 : 6 }, + shadowOpacity: isTV ? 0.7 : 0.25, + shadowRadius: isTV ? 24 : 10, + elevation: isTV ? 20 : 6, + // Force visibility on TV + opacity: 1, + zIndex: isTV ? 10 : 1, + }; + + + return ( - {/* Scraper Logo */} - {showLogos && scraperLogo && ( - - - - )} - - - - - - {streamInfo.displayName} - - {streamInfo.subTitle && ( - - {streamInfo.subTitle} - - )} + {/* Left Section - Logo and Quality Indicators */} + + {/* Scraper Logo */} + {showLogos && scraperLogo ? ( + + - - {/* Show loading indicator if stream is loading */} - {isLoading && ( - - - - {statusMessage || "Loading..."} - - - )} - + ) : ( + + + + )} - - {streamInfo.isDolby && ( - - )} - - {streamInfo.size && ( - - 💾 {streamInfo.size} + {/* Quality and HDR Badges */} + + {streamInfo.quality && ( + + {streamInfo.quality}p )} - {streamInfo.isDebrid && ( - - DEBRID + {streamInfo.isDolby && ( + + HDR )} - - + {/* Center Section - Stream Information */} + + {/* Title Section */} + + + {streamInfo.displayName} + + + {streamInfo.subTitle && ( + + {streamInfo.subTitle} + + )} + + + {/* Bottom Section - Size and Debrid */} + + {streamInfo.size && ( + + + {streamInfo.size} + + )} + + {streamInfo.isDebrid && ( + + PREMIUM + + )} + + + + {/* Right Section - Play Button and Loading */} + + {isLoading ? ( + + + + {statusMessage || "Loading"} + + + ) : ( + + + + )} ); @@ -304,23 +534,37 @@ const ProviderFilter = memo(({ }) => { const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); - const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => ( - onSelect(item.id)} - > - - {item.name} - - - ), [selectedProvider, onSelect, styles]); + const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => { + const isTV = Platform.isTV; + + return ( + onSelect(item.id)} + hasTVPreferredFocus={index === 0 && isTV} + tvParallaxProperties={isTV ? { + enabled: true, + shiftDistanceX: 2.0, + shiftDistanceY: 2.0, + tiltAngle: 0.05, + magnification: 1.05, + pressMagnification: 0.95, + pressDuration: 0.3, + } : undefined} + > + + {item.name} + + + ); + }, [selectedProvider, onSelect, styles]); return ( @@ -334,10 +578,11 @@ const ProviderFilter = memo(({ bounces={true} overScrollMode="never" decelerationRate="fast" - initialNumToRender={5} - maxToRenderPerBatch={3} - windowSize={3} - getItemLayout={(data, index) => ({ + initialNumToRender={Platform.isTV ? 8 : 5} + maxToRenderPerBatch={Platform.isTV ? 5 : 3} + windowSize={Platform.isTV ? 5 : 3} + removeClippedSubviews={!Platform.isTV} + getItemLayout={Platform.isTV ? undefined : (data, index) => ({ length: 100, // Approximate width of each item offset: 100 * index, index, @@ -1155,6 +1400,14 @@ export const StreamsScreen = () => { const sections = useMemo(() => { const streams = type === 'series' ? episodeStreams : groupedStreams; const installedAddons = stremioService.getInstalledAddons(); + + console.log('[StreamsScreen] Sections creation debug:'); + console.log(' type:', type); + console.log(' episodeStreams:', episodeStreams); + console.log(' groupedStreams:', groupedStreams); + console.log(' streams (selected):', streams); + console.log(' selectedProvider:', selectedProvider); + console.log(' installedAddons:', installedAddons); // Filter streams by selected provider const filteredEntries = Object.entries(streams) @@ -1278,6 +1531,10 @@ export const StreamsScreen = () => { const loadElapsed = streamsLoadStart ? Date.now() - streamsLoadStart : 0; const showInitialLoading = streamsEmpty && (streamsLoadStart === null || loadElapsed < 10000); const showStillFetching = streamsEmpty && loadElapsed >= 10000; + + + + const heroStyle = useAnimatedStyle(() => ({ transform: [{ scale: heroScale.value }], @@ -1303,6 +1560,8 @@ export const StreamsScreen = () => { // Don't show loading for individual streams that are already available and displayed const isLoading = false; // If streams are being rendered, they're available and shouldn't be loading + + return ( { const isProviderLoading = loadingProviders[section.addonId]; return ( - - - {section.title} + + + {section.title} {isProviderLoading && ( - - - + + + Loading... @@ -1335,7 +1631,7 @@ export const StreamsScreen = () => { ); - }, [styles.streamGroupTitle, styles.sectionHeaderContainer, styles.sectionHeaderContent, styles.sectionLoadingIndicator, styles.sectionLoadingText, loadingProviders, colors.primary]); + }, [loadingProviders, colors.primary, colors.highEmphasis]); // Cleanup on unmount useEffect(() => { @@ -1549,15 +1845,12 @@ export const StreamsScreen = () => { {/* Show autoplay loading overlay if waiting for autoplay */} {isAutoplayWaiting && !autoplayTriggered && ( - + Starting best stream... - + )} { renderItem={renderItem} renderSectionHeader={renderSectionHeader} stickySectionHeadersEnabled={false} - initialNumToRender={6} - maxToRenderPerBatch={3} - windowSize={4} + initialNumToRender={Platform.isTV ? 6 : 6} + maxToRenderPerBatch={Platform.isTV ? 4 : 3} + windowSize={Platform.isTV ? 3 : 4} removeClippedSubviews={false} - contentContainerStyle={styles.streamsContainer} - style={styles.streamsContent} + getItemLayout={undefined} + contentContainerStyle={{ + paddingHorizontal: Platform.isTV ? 0 : 16, + paddingVertical: Platform.isTV ? 0 : 16, + paddingBottom: Platform.isTV ? 120 : 16, + width: '100%', + }} + style={{ + flex: 1, + width: '100%', + zIndex: 2, + backgroundColor: 'transparent', + minHeight: Platform.isTV ? 400 : 'auto', + }} showsVerticalScrollIndicator={false} bounces={true} overScrollMode="never" + ItemSeparatorComponent={() => Platform.isTV ? ( + + ) : null} ListFooterComponent={ (loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders ? ( - - - Loading more sources... + + + Loading more sources... ) : null } @@ -1620,14 +1941,14 @@ const createStyles = (colors: any) => StyleSheet.create({ streamsMainContent: { flex: 1, backgroundColor: colors.darkBackground, - paddingTop: 20, + paddingTop: Platform.isTV ? 0 : 20, zIndex: 1, }, streamsMainContentMovie: { paddingTop: Platform.OS === 'android' ? 10 : 15, }, filterContainer: { - paddingHorizontal: 16, + paddingHorizontal: Platform.isTV ? 0 : 16, paddingBottom: 12, }, filterScroll: { @@ -1679,12 +2000,12 @@ const createStyles = (colors: any) => StyleSheet.create({ streamCard: { flexDirection: 'row', alignItems: 'flex-start', - padding: 16, + padding: Platform.isTV ? 20 : 16, borderRadius: 10, - marginBottom: 12, - minHeight: 70, + marginBottom: Platform.isTV ? 16 : 12, + minHeight: Platform.isTV ? 90 : 70, backgroundColor: colors.card, - borderWidth: 1, + borderWidth: Platform.isTV ? 2 : 1, borderColor: colors.cardHighlight, width: '100%', zIndex: 1, @@ -1720,15 +2041,15 @@ const createStyles = (colors: any) => StyleSheet.create({ flex: 1, }, streamName: { - fontSize: 14, + fontSize: Platform.isTV ? 18 : 14, fontWeight: '600', marginBottom: 2, - lineHeight: 20, + lineHeight: Platform.isTV ? 24 : 20, color: colors.highEmphasis, }, streamAddonName: { - fontSize: 13, - lineHeight: 18, + fontSize: Platform.isTV ? 16 : 13, + lineHeight: Platform.isTV ? 22 : 18, color: colors.mediumEmphasis, marginBottom: 6, }, @@ -1770,9 +2091,9 @@ const createStyles = (colors: any) => StyleSheet.create({ marginLeft: 8, }, streamAction: { - width: 36, - height: 36, - borderRadius: 18, + width: Platform.isTV ? 48 : 36, + height: Platform.isTV ? 48 : 36, + borderRadius: Platform.isTV ? 24 : 18, backgroundColor: colors.card, justifyContent: 'center', alignItems: 'center', @@ -2057,10 +2378,10 @@ const createStyles = (colors: any) => StyleSheet.create({ fontWeight: '600', }, activeScrapersContainer: { - paddingHorizontal: 16, + paddingHorizontal: Platform.isTV ? 0 : 16, paddingVertical: 8, backgroundColor: 'transparent', - marginHorizontal: 16, + marginHorizontal: Platform.isTV ? 0 : 16, marginBottom: 4, }, activeScrapersTitle: { diff --git a/src/styles/homeStyles.ts b/src/styles/homeStyles.ts index c74c122..d579b4b 100644 --- a/src/styles/homeStyles.ts +++ b/src/styles/homeStyles.ts @@ -4,8 +4,9 @@ const { width, height } = Dimensions.get('window'); // Dynamic poster calculation based on screen width const calculatePosterLayout = (screenWidth: number) => { - const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability - const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters + // TV gets larger posters + const MIN_POSTER_WIDTH = Platform.isTV ? 140 : 110; + const MAX_POSTER_WIDTH = Platform.isTV ? 180 : 140; const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins // Calculate how many posters can fit @@ -62,4 +63,4 @@ export default { POSTER_WIDTH, POSTER_HEIGHT, HORIZONTAL_PADDING, -}; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/utils/posterUtils.ts b/src/utils/posterUtils.ts index 4fa4c1d..8283825 100644 --- a/src/utils/posterUtils.ts +++ b/src/utils/posterUtils.ts @@ -15,10 +15,12 @@ export interface PosterLayout { spacing: number; } +import { Platform } from 'react-native'; + // Default configuration for main home sections export const DEFAULT_POSTER_CONFIG: PosterLayoutConfig = { - minPosterWidth: 110, - maxPosterWidth: 140, + minPosterWidth: Platform.isTV ? 140 : 110, + maxPosterWidth: Platform.isTV ? 180 : 140, horizontalPadding: 50, minColumns: 3, maxColumns: 6, @@ -27,8 +29,8 @@ export const DEFAULT_POSTER_CONFIG: PosterLayoutConfig = { // Configuration for More Like This section (smaller posters, more items) export const MORE_LIKE_THIS_CONFIG: PosterLayoutConfig = { - minPosterWidth: 100, - maxPosterWidth: 130, + minPosterWidth: Platform.isTV ? 140 : 100, + maxPosterWidth: Platform.isTV ? 170 : 130, horizontalPadding: 48, minColumns: 3, maxColumns: 7, @@ -37,8 +39,8 @@ export const MORE_LIKE_THIS_CONFIG: PosterLayoutConfig = { // Configuration for Continue Watching section (larger posters, fewer items) export const CONTINUE_WATCHING_CONFIG: PosterLayoutConfig = { - minPosterWidth: 120, - maxPosterWidth: 160, + minPosterWidth: Platform.isTV ? 160 : 120, + maxPosterWidth: Platform.isTV ? 200 : 160, horizontalPadding: 40, minColumns: 2, maxColumns: 5, @@ -79,4 +81,4 @@ export const calculatePosterLayout = ( export const getCurrentPosterLayout = (config?: PosterLayoutConfig): PosterLayout => { const { width } = Dimensions.get('window'); return calculatePosterLayout(width, config); -}; \ No newline at end of file +}; \ No newline at end of file