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