updating
This commit is contained in:
parent
332cf99f67
commit
8da78d1b0d
9 changed files with 942 additions and 485 deletions
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
};
|
||||
Loading…
Reference in a new issue