This commit is contained in:
tapframe 2025-08-02 16:41:40 +05:30
parent 332cf99f67
commit 8da78d1b0d
9 changed files with 942 additions and 485 deletions

View file

@ -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,
},
})}
/>
</Animated.View>
);
@ -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);
export default React.memo(CatalogSection);

View file

@ -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 (
<>
<View style={styles.itemContainer}>
<TouchableOpacity
style={styles.contentItem}
activeOpacity={0.7}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
>
<View style={styles.contentItemContainer}>
<ExpoImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
cachePolicy="memory"
transition={200}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
/>
{isWatched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success} />
<Animated.View style={animatedContainerStyle}>
<TouchableOpacity
style={styles.contentItem}
activeOpacity={0.7}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={false}
>
<View style={styles.contentItemContainer}>
<ExpoImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
cachePolicy="memory"
transition={200}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
/>
{isWatched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success} />
</View>
)}
{item.inLibrary && (
<View style={styles.libraryBadge}>
<MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} />
</View>
)}
</View>
)}
{item.inLibrary && (
<View style={styles.libraryBadge}>
<MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} />
</View>
)}
</View>
</TouchableOpacity>
</TouchableOpacity>
</Animated.View>
<Text style={[styles.title, { color: currentTheme.colors.text }]} numberOfLines={2}>
{item.name}
</Text>
@ -199,4 +240,4 @@ const styles = StyleSheet.create({
}
});
export default ContentItem;
export default ContentItem;

View file

@ -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 (
<Animated.View style={animatedContainerStyle}>
<TouchableOpacity
style={[styles.wideContentItem, {
backgroundColor: currentTheme.colors.elevation1,
borderColor: currentTheme.colors.border,
shadowColor: currentTheme.colors.black
}]}
activeOpacity={0.8}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={800}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={false}
>
{/* Poster Image */}
<View style={styles.posterContainer}>
<ExpoImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.continueWatchingPoster}
contentFit="cover"
cachePolicy="memory"
transition={200}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
/>
{/* Delete Indicator Overlay */}
{deletingItemId === item.id && (
<View style={styles.deletingOverlay}>
<ActivityIndicator size="large" color="#FFFFFF" />
</View>
)}
</View>
{/* Content Details */}
<View style={styles.contentDetails}>
<View style={styles.titleRow}>
{(() => {
const isUpNext = item.progress === 0;
return (
<View style={styles.titleRow}>
<Text
style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]}
numberOfLines={1}
>
{item.name}
</Text>
{isUpNext && (
<View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.progressText}>Up Next</Text>
</View>
)}
</View>
);
})()}
</View>
{/* Episode Info or Year */}
{(() => {
if (item.type === 'series' && item.season && item.episode) {
return (
<View style={styles.episodeRow}>
<Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}>
Season {item.season}
</Text>
{item.episodeTitle && (
<Text
style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]}
numberOfLines={1}
>
Episode {item.episode}: {item.episodeTitle}
</Text>
)}
</View>
);
} else {
return (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumEmphasis }]}>
{item.year}
</Text>
);
}
})()}
{/* Progress Bar */}
<View style={styles.wideProgressContainer}>
<View style={styles.wideProgressTrack}>
<View
style={[
styles.wideProgressBar,
{
backgroundColor: currentTheme.colors.primary,
width: `${item.progress}%`
}
]}
/>
</View>
<Text style={[styles.progressLabel, { color: currentTheme.colors.mediumEmphasis }]}>
{item.progress}% watched
</Text>
</View>
</View>
</TouchableOpacity>
</Animated.View>
);
});
const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
@ -583,109 +740,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
<FlatList
data={continueWatchingItems}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.wideContentItem, {
backgroundColor: currentTheme.colors.elevation1,
borderColor: currentTheme.colors.border,
shadowColor: currentTheme.colors.black
}]}
activeOpacity={0.8}
<ContinueWatchingItem
item={item}
onPress={() => handleContentPress(item.id, item.type)}
onLongPress={() => handleLongPress(item)}
delayLongPress={800}
>
{/* Poster Image */}
<View style={styles.posterContainer}>
<ExpoImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.continueWatchingPoster}
contentFit="cover"
cachePolicy="memory"
transition={200}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
/>
{/* Delete Indicator Overlay */}
{deletingItemId === item.id && (
<View style={styles.deletingOverlay}>
<ActivityIndicator size="large" color="#FFFFFF" />
</View>
)}
</View>
{/* Content Details */}
<View style={styles.contentDetails}>
<View style={styles.titleRow}>
{(() => {
const isUpNext = item.progress === 0;
return (
<View style={styles.titleRow}>
<Text
style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]}
numberOfLines={1}
>
{item.name}
</Text>
{isUpNext && (
<View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.progressText}>Up Next</Text>
</View>
)}
</View>
);
})()}
</View>
{/* Episode Info or Year */}
{(() => {
if (item.type === 'series' && item.season && item.episode) {
return (
<View style={styles.episodeRow}>
<Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}>
Season {item.season}
</Text>
{item.episodeTitle && (
<Text
style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]}
numberOfLines={1}
>
{item.episodeTitle}
</Text>
)}
</View>
);
} else {
return (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumEmphasis }]}>
{item.year} {item.type === 'movie' ? 'Movie' : 'Series'}
</Text>
);
}
})()}
{/* Progress Bar */}
{item.progress > 0 && (
<View style={styles.wideProgressContainer}>
<View style={styles.wideProgressTrack}>
<View
style={[
styles.wideProgressBar,
{
width: `${item.progress}%`,
backgroundColor: currentTheme.colors.primary
}
]}
/>
</View>
<Text style={[styles.progressLabel, { color: currentTheme.colors.textMuted }]}>
{Math.round(item.progress)}% watched
</Text>
</View>
)}
</View>
</TouchableOpacity>
deletingItemId={deletingItemId}
currentTheme={currentTheme}
/>
)}
keyExtractor={(item) => `continue-${item.id}-${item.type}`}
horizontal
@ -695,6 +756,16 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
// TV-specific focus navigation properties
{...(Platform.isTV && {
directionalLockEnabled: true,
horizontal: true,
scrollEnabled: true,
focusable: false,
tvParallaxProperties: {
enabled: false,
},
})}
/>
</View>
);
@ -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,

View file

@ -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<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const [isFocused, setIsFocused] = useState(false);
return (
<View style={[styles.featuredContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={styles.backgroundFallback}>
<MaterialIcons name="movie" size={64} color={currentTheme.colors.mediumEmphasis} />
<MaterialIcons name="movie" size={Platform.isTV ? 96 : 64} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.noContentText, { color: currentTheme.colors.mediumEmphasis }]}>
No featured content available
</Text>
<TouchableOpacity
style={[styles.exploreButton, { backgroundColor: currentTheme.colors.primary }]}
style={[
styles.exploreButton,
{ backgroundColor: currentTheme.colors.primary },
isFocused && { transform: [{ scale: 1.05 }] }
]}
onPress={() => navigation.navigate('Search')}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
hasTVPreferredFocus={true}
>
<Text style={styles.exploreButtonText}>Explore Content</Text>
</TouchableOpacity>
@ -59,15 +59,17 @@ const NoFeaturedContent = () => {
);
};
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => {
const FeaturedContent = ({ featuredContent }: FeaturedContentProps) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { settings } = useSettings();
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [isLogoLoading, setIsLogoLoading] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
// Removed TMDB service integration
const focusGuideRef = useRef<any>(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 <NoFeaturedContent />;
}
const posterUrl = featuredContent.poster;
const backdropUrl = featuredContent.banner || featuredContent.poster;
const formattedGenres = formatGenres(featuredContent.genres);
return (
<View style={styles.featuredContainer}>
{/* Background Image */}
{/* Background Image with Parallax Effect */}
<View style={styles.imageContainer}>
{posterUrl && !imageError ? (
<ExpoImage
source={{ uri: posterUrl }}
style={styles.featuredImage}
contentFit="cover"
cachePolicy="memory-disk"
transition={300}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
placeholder={{ uri: 'https://via.placeholder.com/400x600' }}
placeholderContentFit="cover"
/>
{backdropUrl && !imageError ? (
<Animated.View style={[
styles.imageWrapper,
{
transform: [{ scale: scaleAnim }],
opacity: opacityAnim,
}
]}>
<ExpoImage
source={{ uri: backdropUrl }}
style={styles.featuredImage}
contentFit="cover"
cachePolicy="memory-disk"
transition={500}
onError={() => setImageError(true)}
placeholder={{ uri: 'https://via.placeholder.com/400x600' }}
placeholderContentFit="cover"
/>
</Animated.View>
) : (
<View style={[styles.backgroundFallback, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="movie" size={64} color={currentTheme.colors.mediumEmphasis} />
<MaterialIcons name="movie" size={Platform.isTV ? 96 : 64} color={currentTheme.colors.mediumEmphasis} />
</View>
)}
</View>
{/* Content Overlay */}
<View style={styles.contentOverlay} />
{/* Gradient Overlay */}
{/* Left Side Dark Gradient Fade */}
<LinearGradient
colors={[
'rgba(0,0,0,0.9)',
'rgba(0,0,0,0.7)',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.1)',
'transparent'
]}
locations={[0, 0.25, 0.5, 0.75, 1]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.leftGradient}
/>
{/* Enhanced Gradient Overlay for TV */}
<LinearGradient
colors={Platform.isTV ? [
'transparent',
'rgba(0,0,0,0.2)',
'rgba(0,0,0,0.5)',
'rgba(0,0,0,0.8)',
'rgba(0,0,0,0.95)'
] : [
'transparent',
'rgba(0,0,0,0.3)',
'rgba(0,0,0,0.7)',
'rgba(0,0,0,0.9)'
]}
locations={[0, 0.4, 0.7, 1]}
locations={Platform.isTV ? [0, 0.3, 0.5, 0.7, 1] : [0, 0.4, 0.7, 1]}
style={styles.featuredGradient}
>
<View style={styles.featuredContentContainer}>
{/* Logo or Title */}
{logoUrl && !isLogoLoading ? (
<ExpoImage
source={{ uri: logoUrl }}
style={styles.featuredLogo}
contentFit="contain"
cachePolicy="memory-disk"
transition={200}
/>
) : (
<Text style={[styles.featuredTitleText, { color: '#FFFFFF' }]} numberOfLines={2}>
{featuredContent.name}
</Text>
)}
{/* Genres */}
{formattedGenres && (
<View style={styles.genreContainer}>
<Text style={[styles.genreText, { color: '#FFFFFF' }]}>
{formattedGenres}
</Text>
<TVFocusGuideView
ref={focusGuideRef}
style={styles.tvFocusGuide}
>
<View style={styles.featuredContentContainer}>
{/* Logo or Title with TV Scaling - Left Aligned */}
<View style={styles.titleContainer}>
{logoUrl && !isLogoLoading ? (
<ExpoImage
source={{ uri: logoUrl }}
style={[
styles.featuredLogo,
Platform.isTV && styles.featuredLogoTV
]}
contentFit="contain"
cachePolicy="memory-disk"
transition={300}
/>
) : (
<Text style={[
styles.featuredTitleText,
{ color: '#FFFFFF' },
Platform.isTV && styles.featuredTitleTextTV
]} numberOfLines={Platform.isTV ? 3 : 2}>
{featuredContent.name}
</Text>
)}
</View>
)}
{/* Action Buttons */}
<View style={styles.featuredButtons}>
{/* Play Button */}
<TouchableOpacity
style={[styles.playButton, { backgroundColor: '#FFFFFF' }]}
onPress={handlePlayPress}
activeOpacity={0.8}
>
<MaterialIcons name="play-arrow" size={20} color="#000000" />
<Text style={[styles.playButtonText, { color: '#000000' }]}>Play</Text>
</TouchableOpacity>
{/* Enhanced Metadata Section */}
<View style={styles.metadataContainer}>
{/* Genres */}
{formattedGenres && (
<View style={styles.genreContainer}>
<Text style={[
styles.genreText,
{ color: '#FFFFFF' },
Platform.isTV && styles.genreTextTV
]}>
{formattedGenres}
</Text>
</View>
)}
{/* Additional metadata for TV */}
{Platform.isTV && featuredContent.year && (
<View style={styles.yearContainer}>
<Text style={styles.yearText}>{featuredContent.year}</Text>
</View>
)}
</View>
{/* My List Button */}
<TouchableOpacity
style={styles.myListButton}
onPress={handleSaveToLibrary}
activeOpacity={0.7}
>
<MaterialIcons
name={isSaved ? "check" : "add"}
size={20}
color="#FFFFFF"
/>
<Text style={[styles.myListButtonText, { color: '#FFFFFF' }]}>
{isSaved ? 'Saved' : 'My List'}
</Text>
</TouchableOpacity>
{/* Info Button */}
<TouchableOpacity
style={styles.infoButton}
onPress={handleInfoPress}
activeOpacity={0.7}
>
<MaterialIcons name="info-outline" size={20} color="#FFFFFF" />
<Text style={[styles.infoButtonText, { color: '#FFFFFF' }]}>Info</Text>
</TouchableOpacity>
</View>
</View>
</TVFocusGuideView>
</LinearGradient>
</View>
);
@ -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',
},
});

View file

@ -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',
},
});
});

View file

@ -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

View file

@ -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 (
<TouchableOpacity
style={[
styles.streamCard,
isLoading && styles.streamCardLoading
cardStyle,
isLoading && { opacity: 0.75 }
]}
onPress={onPress}
onLongPress={handleLongPress}
disabled={isLoading}
activeOpacity={0.7}
activeOpacity={0.85}
hasTVPreferredFocus={index === 0 && isTV}
tvParallaxProperties={isTV ? {
enabled: true,
shiftDistanceX: 10.0,
shiftDistanceY: 10.0,
tiltAngle: 0.25,
magnification: 1.08,
pressMagnification: 0.92,
pressDuration: 0.12,
} : undefined}
>
{/* Scraper Logo */}
{showLogos && scraperLogo && (
<View style={styles.scraperLogoContainer}>
<Image
source={{ uri: scraperLogo }}
style={styles.scraperLogo}
resizeMode="contain"
/>
</View>
)}
<View style={styles.streamDetails}>
<View style={styles.streamNameRow}>
<View style={styles.streamTitleContainer}>
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
{streamInfo.displayName}
</Text>
{streamInfo.subTitle && (
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
{streamInfo.subTitle}
</Text>
)}
{/* Left Section - Logo and Quality Indicators */}
<View style={{
width: isTV ? 140 : 90,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: isTV ? '#2a2a2a' : 'rgba(0, 0, 0, 0.3)',
paddingVertical: isTV ? 24 : 16,
paddingHorizontal: isTV ? 20 : 12,
borderTopLeftRadius: isTV ? 24 : 12,
borderBottomLeftRadius: isTV ? 24 : 12,
}}>
{/* Scraper Logo */}
{showLogos && scraperLogo ? (
<View style={{
width: isTV ? 72 : 48,
height: isTV ? 72 : 48,
marginBottom: isTV ? 16 : 10,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.12)',
borderRadius: isTV ? 20 : 12,
borderWidth: isTV ? 3 : 2,
borderColor: 'rgba(255, 255, 255, 0.25)',
}}>
<Image
source={{ uri: scraperLogo }}
style={{
width: isTV ? 56 : 36,
height: isTV ? 56 : 36,
}}
resizeMode="contain"
/>
</View>
{/* Show loading indicator if stream is loading */}
{isLoading && (
<View style={styles.loadingIndicator}>
<ActivityIndicator size="small" color={theme.colors.primary} />
<Text style={[styles.loadingText, { color: theme.colors.primary }]}>
{statusMessage || "Loading..."}
</Text>
</View>
)}
</View>
) : (
<View style={{
width: isTV ? 72 : 48,
height: isTV ? 72 : 48,
marginBottom: isTV ? 16 : 10,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
borderRadius: isTV ? 20 : 12,
borderWidth: isTV ? 3 : 2,
borderColor: 'rgba(255, 255, 255, 0.15)',
}}>
<MaterialIcons
name="movie"
size={isTV ? 40 : 24}
color="rgba(255, 255, 255, 0.5)"
/>
</View>
)}
<View style={styles.streamMetaRow}>
{streamInfo.isDolby && (
<QualityBadge type="VISION" />
)}
{streamInfo.size && (
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>💾 {streamInfo.size}</Text>
{/* Quality and HDR Badges */}
<View style={{
alignItems: 'center',
gap: isTV ? 8 : 4,
}}>
{streamInfo.quality && (
<View style={{
backgroundColor: theme.colors.primary,
paddingHorizontal: isTV ? 16 : 10,
paddingVertical: isTV ? 8 : 5,
borderRadius: isTV ? 12 : 8,
borderWidth: isTV ? 2 : 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
}}>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 16 : 12,
fontWeight: '900',
textAlign: 'center',
textShadowColor: 'rgba(0,0,0,0.5)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 2,
}}>{streamInfo.quality}p</Text>
</View>
)}
{streamInfo.isDebrid && (
<View style={[styles.chip, { backgroundColor: theme.colors.success }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
{streamInfo.isDolby && (
<View style={{
backgroundColor: '#FF6B35',
paddingHorizontal: isTV ? 12 : 8,
paddingVertical: isTV ? 6 : 4,
borderRadius: isTV ? 10 : 6,
borderWidth: isTV ? 2 : 1,
borderColor: '#FF8C69',
}}>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 14 : 10,
fontWeight: '800',
textAlign: 'center',
}}>HDR</Text>
</View>
)}
</View>
</View>
<View style={styles.streamAction}>
<MaterialIcons
name="play-arrow"
size={24}
color={theme.colors.primary}
/>
{/* Center Section - Stream Information */}
<View style={{
flex: 1,
paddingVertical: isTV ? 28 : 20,
paddingHorizontal: isTV ? 24 : 16,
justifyContent: 'space-between',
}}>
{/* Title Section */}
<View style={{ flex: 1, justifyContent: 'center' }}>
<Text style={{
fontSize: isTV ? 28 : 18,
fontWeight: '900',
color: isTV ? '#FFFFFF' : theme.colors.highEmphasis,
lineHeight: isTV ? 36 : 24,
marginBottom: isTV ? 12 : 6,
textShadowColor: isTV ? 'rgba(0,0,0,0.9)' : 'transparent',
textShadowOffset: isTV ? { width: 2, height: 2 } : { width: 0, height: 0 },
textShadowRadius: isTV ? 4 : 0,
}} numberOfLines={isTV ? 2 : 1}>
{streamInfo.displayName}
</Text>
{streamInfo.subTitle && (
<Text style={{
fontSize: isTV ? 20 : 15,
color: isTV ? 'rgba(255, 255, 255, 0.85)' : theme.colors.mediumEmphasis,
lineHeight: isTV ? 28 : 22,
fontWeight: isTV ? '700' : '600',
marginBottom: isTV ? 16 : 8,
textShadowColor: isTV ? 'rgba(0,0,0,0.7)' : 'transparent',
textShadowOffset: isTV ? { width: 1, height: 1 } : { width: 0, height: 0 },
textShadowRadius: isTV ? 2 : 0,
}} numberOfLines={isTV ? 2 : 1}>
{streamInfo.subTitle}
</Text>
)}
</View>
{/* Bottom Section - Size and Debrid */}
<View style={{
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: isTV ? 16 : 8,
}}>
{streamInfo.size && (
<View style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.15)',
paddingHorizontal: isTV ? 16 : 12,
paddingVertical: isTV ? 10 : 6,
borderRadius: isTV ? 12 : 8,
borderWidth: isTV ? 2 : 1,
borderColor: 'rgba(255, 255, 255, 0.25)',
}}>
<MaterialIcons
name="storage"
size={isTV ? 20 : 14}
color="rgba(255, 255, 255, 0.9)"
style={{ marginRight: isTV ? 8 : 6 }}
/>
<Text style={{
color: 'rgba(255, 255, 255, 0.95)',
fontSize: isTV ? 16 : 13,
fontWeight: '700',
}}>{streamInfo.size}</Text>
</View>
)}
{streamInfo.isDebrid && (
<View style={{
backgroundColor: '#00C851',
paddingHorizontal: isTV ? 16 : 12,
paddingVertical: isTV ? 10 : 6,
borderRadius: isTV ? 12 : 8,
borderWidth: isTV ? 2 : 1,
borderColor: '#00E676',
shadowColor: '#00C851',
shadowOffset: { width: 0, height: isTV ? 4 : 2 },
shadowOpacity: isTV ? 0.6 : 0.3,
shadowRadius: isTV ? 8 : 4,
elevation: isTV ? 8 : 4,
}}>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 16 : 13,
fontWeight: '800',
textShadowColor: 'rgba(0,0,0,0.3)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 2,
}}>PREMIUM</Text>
</View>
)}
</View>
</View>
{/* Right Section - Play Button and Loading */}
<View style={{
width: isTV ? 120 : 80,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: isTV ? 24 : 16,
paddingHorizontal: isTV ? 20 : 12,
backgroundColor: isTV ? '#2a2a2a' : 'rgba(0, 0, 0, 0.1)',
borderTopRightRadius: isTV ? 24 : 12,
borderBottomRightRadius: isTV ? 24 : 12,
}}>
{isLoading ? (
<View style={{
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderRadius: isTV ? 24 : 16,
padding: isTV ? 20 : 12,
borderWidth: isTV ? 3 : 2,
borderColor: theme.colors.primary,
}}>
<ActivityIndicator size={isTV ? "large" : "small"} color={theme.colors.primary} />
<Text style={{
color: theme.colors.primary,
fontSize: isTV ? 14 : 11,
marginTop: isTV ? 12 : 6,
fontWeight: '700',
textAlign: 'center',
}} numberOfLines={1}>
{statusMessage || "Loading"}
</Text>
</View>
) : (
<View style={{
width: isTV ? 96 : 60,
height: isTV ? 96 : 60,
borderRadius: isTV ? 48 : 30,
backgroundColor: theme.colors.primary,
justifyContent: 'center',
alignItems: 'center',
borderWidth: isTV ? 5 : 3,
borderColor: '#FFFFFF',
shadowColor: theme.colors.primary,
shadowOffset: { width: 0, height: isTV ? 12 : 6 },
shadowOpacity: isTV ? 0.8 : 0.4,
shadowRadius: isTV ? 16 : 8,
elevation: isTV ? 16 : 8,
}}>
<MaterialIcons
name="play-arrow"
size={isTV ? 56 : 36}
color="#FFFFFF"
style={{ marginLeft: isTV ? 6 : 3 }}
/>
</View>
)}
</View>
</TouchableOpacity>
);
@ -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 }) => (
<TouchableOpacity
key={item.id}
style={[
styles.filterChip,
selectedProvider === item.id && styles.filterChipSelected
]}
onPress={() => onSelect(item.id)}
>
<Text style={[
styles.filterChipText,
selectedProvider === item.id && styles.filterChipTextSelected
]}>
{item.name}
</Text>
</TouchableOpacity>
), [selectedProvider, onSelect, styles]);
const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => {
const isTV = Platform.isTV;
return (
<TouchableOpacity
key={item.id}
style={[
styles.filterChip,
selectedProvider === item.id && styles.filterChipSelected
]}
onPress={() => 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}
>
<Text style={[
styles.filterChipText,
selectedProvider === item.id && styles.filterChipTextSelected
]}>
{item.name}
</Text>
</TouchableOpacity>
);
}, [selectedProvider, onSelect, styles]);
return (
<View>
@ -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 (
<StreamCard
key={`${stream.url}-${index}`}
@ -1321,13 +1580,50 @@ export const StreamsScreen = () => {
const isProviderLoading = loadingProviders[section.addonId];
return (
<View style={styles.sectionHeaderContainer}>
<View style={styles.sectionHeaderContent}>
<Text style={styles.streamGroupTitle}>{section.title}</Text>
<View style={{
padding: Platform.isTV ? 0 : 16,
paddingHorizontal: Platform.isTV ? 0 : 16,
paddingVertical: Platform.isTV ? 12 : 0,
backgroundColor: Platform.isTV ? 'rgba(0,0,0,0.6)' : 'transparent',
borderRadius: Platform.isTV ? 12 : 0,
marginBottom: Platform.isTV ? 8 : 0,
borderWidth: Platform.isTV ? 1 : 0,
borderColor: Platform.isTV ? '#333333' : 'transparent',
}}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<Text style={{
color: Platform.isTV ? '#FFFFFF' : colors.highEmphasis,
fontSize: Platform.isTV ? 20 : 15,
fontWeight: Platform.isTV ? '800' : '700',
marginBottom: Platform.isTV ? 0 : 8,
marginTop: 0,
backgroundColor: 'transparent',
textShadowColor: Platform.isTV ? 'rgba(0,0,0,0.8)' : 'transparent',
textShadowOffset: Platform.isTV ? { width: 1, height: 1 } : { width: 0, height: 0 },
textShadowRadius: Platform.isTV ? 2 : 0,
}}>{section.title}</Text>
{isProviderLoading && (
<View style={styles.sectionLoadingIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={[styles.sectionLoadingText, { color: colors.primary }]}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: Platform.isTV ? 'rgba(0,0,0,0.8)' : 'transparent',
paddingHorizontal: Platform.isTV ? 12 : 0,
paddingVertical: Platform.isTV ? 6 : 0,
borderRadius: Platform.isTV ? 8 : 0,
borderWidth: Platform.isTV ? 1 : 0,
borderColor: Platform.isTV ? colors.primary : 'transparent',
}}>
<ActivityIndicator size={Platform.isTV ? "large" : "small"} color={colors.primary} />
<Text style={{
marginLeft: Platform.isTV ? 12 : 8,
color: colors.primary,
fontSize: Platform.isTV ? 16 : 12,
fontWeight: Platform.isTV ? '700' : '500',
}}>
Loading...
</Text>
</View>
@ -1335,7 +1631,7 @@ export const StreamsScreen = () => {
</View>
</View>
);
}, [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 = () => {
<View collapsable={false} style={{ flex: 1 }}>
{/* Show autoplay loading overlay if waiting for autoplay */}
{isAutoplayWaiting && !autoplayTriggered && (
<Animated.View
entering={FadeIn.duration(300)}
style={styles.autoplayOverlay}
>
<View style={styles.autoplayOverlay}>
<View style={styles.autoplayIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.autoplayText}>Starting best stream...</Text>
</View>
</Animated.View>
</View>
)}
<SectionList
@ -1566,20 +1859,48 @@ export const StreamsScreen = () => {
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 ? (
<View style={{ height: 12 }} />
) : null}
ListFooterComponent={
(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders ? (
<View style={styles.footerLoading}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.footerLoadingText}>Loading more sources...</Text>
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: Platform.isTV ? 24 : 16,
backgroundColor: Platform.isTV ? 'rgba(0,0,0,0.8)' : 'transparent',
borderRadius: Platform.isTV ? 12 : 0,
marginTop: Platform.isTV ? 20 : 0,
}}>
<ActivityIndicator size={Platform.isTV ? "large" : "small"} color={colors.primary} />
<Text style={{
color: colors.primary,
fontSize: Platform.isTV ? 18 : 12,
marginLeft: Platform.isTV ? 12 : 8,
fontWeight: Platform.isTV ? '700' : '500',
}}>Loading more sources...</Text>
</View>
) : 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: {

View file

@ -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,
};
};

View file

@ -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);
};
};