This commit is contained in:
tapframe 2025-05-03 14:30:53 +05:30
commit 87c70625b9
8 changed files with 1234 additions and 211 deletions

View file

@ -0,0 +1,147 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, FlatList, Platform, Dimensions } from 'react-native';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeIn } from 'react-native-reanimated';
import { CatalogContent, StreamingContent } from '../../services/catalogService';
import { colors } from '../../styles/colors';
import ContentItem from './ContentItem';
import { RootStackParamList } from '../../navigation/AppNavigator';
interface CatalogSectionProps {
catalog: CatalogContent;
}
const { width } = Dimensions.get('window');
const POSTER_WIDTH = (width - 50) / 3;
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const handleContentPress = (id: string, type: string) => {
navigation.navigate('Metadata', { id, type });
};
const renderContentItem = ({ item, index }: { item: StreamingContent, index: number }) => {
return (
<Animated.View
entering={FadeIn.duration(300).delay(100 + (index * 40))}
>
<ContentItem
item={item}
onPress={handleContentPress}
/>
</Animated.View>
);
};
return (
<Animated.View
style={styles.catalogContainer}
entering={FadeIn.duration(400).delay(50)}
>
<View style={styles.catalogHeader}>
<View style={styles.titleContainer}>
<Text style={styles.catalogTitle}>{catalog.name}</Text>
<LinearGradient
colors={[colors.primary, colors.secondary]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.titleUnderline}
/>
</View>
<TouchableOpacity
onPress={() =>
navigation.navigate('Catalog', {
id: catalog.id,
type: catalog.type,
addonId: catalog.addon
})
}
style={styles.seeAllButton}
>
<Text style={styles.seeAllText}>See More</Text>
<MaterialIcons name="arrow-forward" color={colors.primary} size={16} />
</TouchableOpacity>
</View>
<FlatList
data={catalog.items}
renderItem={renderContentItem}
keyExtractor={(item) => `${item.id}-${item.type}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.catalogList}
snapToInterval={POSTER_WIDTH + 12}
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 12 }} />}
initialNumToRender={4}
maxToRenderPerBatch={4}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
getItemLayout={(data, index) => ({
length: POSTER_WIDTH + 12,
offset: (POSTER_WIDTH + 12) * index,
index,
})}
/>
</Animated.View>
);
};
const styles = StyleSheet.create({
catalogContainer: {
marginBottom: 24,
paddingTop: 0,
marginTop: 16,
},
catalogHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 12,
},
titleContainer: {
position: 'relative',
},
catalogTitle: {
fontSize: 18,
fontWeight: '800',
color: colors.highEmphasis,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
},
titleUnderline: {
position: 'absolute',
bottom: -4,
left: 0,
width: 60,
height: 3,
borderRadius: 1.5,
},
seeAllButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.elevation1,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
seeAllText: {
color: colors.primary,
fontSize: 13,
fontWeight: '700',
marginRight: 4,
},
catalogList: {
paddingHorizontal: 16,
paddingBottom: 12,
paddingTop: 6,
},
});
export default CatalogSection;

View file

@ -0,0 +1,186 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { colors } from '../../styles/colors';
import { catalogService, StreamingContent } from '../../services/catalogService';
import DropUpMenu from './DropUpMenu';
interface ContentItemProps {
item: StreamingContent;
onPress: (id: string, type: string) => void;
}
const { width } = Dimensions.get('window');
const POSTER_WIDTH = (width - 50) / 3;
const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
const [menuVisible, setMenuVisible] = useState(false);
const [localItem, setLocalItem] = useState(initialItem);
const [isWatched, setIsWatched] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const handleLongPress = useCallback(() => {
setMenuVisible(true);
}, []);
const handlePress = useCallback(() => {
onPress(localItem.id, localItem.type);
}, [localItem.id, localItem.type, onPress]);
const handleOptionSelect = useCallback((option: string) => {
switch (option) {
case 'library':
if (localItem.inLibrary) {
catalogService.removeFromLibrary(localItem.type, localItem.id);
} else {
catalogService.addToLibrary(localItem);
}
break;
case 'watched':
setIsWatched(prev => !prev);
break;
case 'playlist':
break;
case 'share':
break;
}
}, [localItem]);
const handleMenuClose = useCallback(() => {
setMenuVisible(false);
}, []);
useEffect(() => {
setLocalItem(initialItem);
}, [initialItem]);
useEffect(() => {
const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => {
const isInLibrary = libraryItems.some(
libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type
);
setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary }));
});
return () => unsubscribe();
}, [localItem.id, localItem.type]);
return (
<>
<TouchableOpacity
style={styles.contentItem}
activeOpacity={0.7}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
>
<View style={styles.contentItemContainer}>
<ExpoImage
source={{ uri: localItem.poster }}
style={styles.poster}
contentFit="cover"
transition={300}
cachePolicy="memory-disk"
recyclingKey={`poster-${localItem.id}`}
onLoadStart={() => {
setImageLoaded(false);
setImageError(false);
}}
onLoadEnd={() => setImageLoaded(true)}
onError={() => {
setImageError(true);
setImageLoaded(true);
}}
/>
{(!imageLoaded || imageError) && (
<View style={[styles.loadingOverlay, { backgroundColor: colors.elevation2 }]}>
{!imageError ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<MaterialIcons name="broken-image" size={24} color={colors.lightGray} />
)}
</View>
)}
{isWatched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={colors.success} />
</View>
)}
{localItem.inLibrary && (
<View style={styles.libraryBadge}>
<MaterialIcons name="bookmark" size={16} color={colors.white} />
</View>
)}
</View>
</TouchableOpacity>
<DropUpMenu
visible={menuVisible}
onClose={handleMenuClose}
item={localItem}
onOptionSelect={handleOptionSelect}
/>
</>
);
};
const styles = StyleSheet.create({
contentItem: {
width: POSTER_WIDTH,
aspectRatio: 2/3,
margin: 0,
borderRadius: 16,
overflow: 'hidden',
position: 'relative',
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
},
contentItemContainer: {
width: '100%',
height: '100%',
borderRadius: 16,
overflow: 'hidden',
position: 'relative',
},
poster: {
width: '100%',
height: '100%',
borderRadius: 16,
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 16,
},
watchedIndicator: {
position: 'absolute',
top: 8,
right: 8,
backgroundColor: colors.transparentDark,
borderRadius: 12,
padding: 2,
},
libraryBadge: {
position: 'absolute',
top: 8,
left: 8,
backgroundColor: colors.transparentDark,
borderRadius: 8,
padding: 4,
},
});
export default ContentItem;

View file

@ -0,0 +1,257 @@
import React, { useEffect } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
Pressable,
TouchableOpacity,
useColorScheme,
Dimensions,
Platform
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { Image as ExpoImage } from 'expo-image';
import { colors } from '../../styles/colors';
import Animated, {
useAnimatedStyle,
withTiming,
useSharedValue,
interpolate,
Extrapolate,
runOnJS,
} from 'react-native-reanimated';
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler';
import { StreamingContent } from '../../services/catalogService';
interface DropUpMenuProps {
visible: boolean;
onClose: () => void;
item: StreamingContent;
onOptionSelect: (option: string) => void;
}
export const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => {
const translateY = useSharedValue(300);
const opacity = useSharedValue(0);
const isDarkMode = useColorScheme() === 'dark';
const SNAP_THRESHOLD = 100;
useEffect(() => {
if (visible) {
opacity.value = withTiming(1, { duration: 200 });
translateY.value = withTiming(0, { duration: 300 });
} else {
opacity.value = withTiming(0, { duration: 200 });
translateY.value = withTiming(300, { duration: 300 });
}
}, [visible]);
const gesture = Gesture.Pan()
.onStart(() => {
// Store initial position if needed
})
.onUpdate((event) => {
if (event.translationY > 0) { // Only allow dragging downwards
translateY.value = event.translationY;
opacity.value = interpolate(
event.translationY,
[0, 300],
[1, 0],
Extrapolate.CLAMP
);
}
})
.onEnd((event) => {
if (event.translationY > SNAP_THRESHOLD || event.velocityY > 500) {
translateY.value = withTiming(300, { duration: 300 });
opacity.value = withTiming(0, { duration: 200 });
runOnJS(onClose)();
} else {
translateY.value = withTiming(0, { duration: 300 });
opacity.value = withTiming(1, { duration: 200 });
}
});
const overlayStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
const menuStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
}));
const menuOptions = [
{
icon: item.inLibrary ? 'bookmark' : 'bookmark-border',
label: item.inLibrary ? 'Remove from Library' : 'Add to Library',
action: 'library'
},
{
icon: 'check-circle',
label: 'Mark as Watched',
action: 'watched'
},
{
icon: 'playlist-add',
label: 'Add to Playlist',
action: 'playlist'
},
{
icon: 'share',
label: 'Share',
action: 'share'
}
];
const backgroundColor = isDarkMode ? '#1A1A1A' : '#FFFFFF';
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
>
<GestureHandlerRootView style={{ flex: 1 }}>
<Animated.View style={[styles.modalOverlay, overlayStyle]}>
<Pressable style={styles.modalOverlayPressable} onPress={onClose} />
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.menuContainer, menuStyle, { backgroundColor }]}>
<View style={styles.dragHandle} />
<View style={styles.menuHeader}>
<ExpoImage
source={{ uri: item.poster }}
style={styles.menuPoster}
contentFit="cover"
/>
<View style={styles.menuTitleContainer}>
<Text style={[styles.menuTitle, { color: isDarkMode ? '#FFFFFF' : '#000000' }]}>
{item.name}
</Text>
{item.year && (
<Text style={[styles.menuYear, { color: isDarkMode ? '#999999' : '#666666' }]}>
{item.year}
</Text>
)}
</View>
</View>
<View style={styles.menuOptions}>
{menuOptions.map((option, index) => (
<TouchableOpacity
key={option.action}
style={[
styles.menuOption,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)' },
index === menuOptions.length - 1 && styles.lastMenuOption
]}
onPress={() => {
onOptionSelect(option.action);
onClose();
}}
>
<MaterialIcons
name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"}
size={24}
color={colors.primary}
/>
<Text style={[
styles.menuOptionText,
{ color: isDarkMode ? '#FFFFFF' : '#000000' }
]}>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
</Animated.View>
</GestureDetector>
</Animated.View>
</GestureHandlerRootView>
</Modal>
);
};
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: colors.transparentDark,
},
modalOverlayPressable: {
flex: 1,
},
dragHandle: {
width: 40,
height: 4,
backgroundColor: colors.transparentLight,
borderRadius: 2,
alignSelf: 'center',
marginTop: 12,
marginBottom: 10,
},
menuContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingBottom: Platform.select({ ios: 40, android: 24 }),
...Platform.select({
ios: {
shadowColor: colors.black,
shadowOffset: { width: 0, height: -3 },
shadowOpacity: 0.1,
shadowRadius: 5,
},
android: {
elevation: 5,
},
}),
},
menuHeader: {
flexDirection: 'row',
padding: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
},
menuPoster: {
width: 60,
height: 90,
borderRadius: 12,
},
menuTitleContainer: {
flex: 1,
marginLeft: 12,
justifyContent: 'center',
},
menuTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
menuYear: {
fontSize: 14,
},
menuOptions: {
paddingTop: 8,
},
menuOption: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
},
lastMenuOption: {
borderBottomWidth: 0,
},
menuOptionText: {
fontSize: 16,
marginLeft: 16,
},
});
export default DropUpMenu;

View file

@ -0,0 +1,436 @@
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ImageBackground,
Dimensions,
ViewStyle,
TextStyle,
ImageStyle
} from 'react-native';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { LinearGradient } from 'expo-linear-gradient';
import { Image as ExpoImage } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { colors } from '../../styles/colors';
import Animated, {
FadeIn,
useAnimatedStyle,
useSharedValue,
withTiming,
Easing,
withDelay
} from 'react-native-reanimated';
import { StreamingContent } from '../../services/catalogService';
import { SkeletonFeatured } from './SkeletonLoaders';
interface FeaturedContentProps {
featuredContent: StreamingContent | null;
isSaved: boolean;
handleSaveToLibrary: () => void;
}
// Cache to store preloaded images
const imageCache: Record<string, boolean> = {};
const { width, height } = Dimensions.get('window');
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const [posterLoaded, setPosterLoaded] = useState(false);
const [logoLoaded, setLogoLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const prevContentIdRef = useRef<string | null>(null);
// Animation values
const posterOpacity = useSharedValue(0);
const logoOpacity = useSharedValue(0);
const contentOpacity = useSharedValue(0);
const posterAnimatedStyle = useAnimatedStyle(() => ({
opacity: posterOpacity.value,
}));
const logoAnimatedStyle = useAnimatedStyle(() => ({
opacity: logoOpacity.value,
}));
const contentAnimatedStyle = useAnimatedStyle(() => ({
opacity: contentOpacity.value,
}));
// Preload the image
const preloadImage = async (url: string): Promise<boolean> => {
if (!url) return false;
// If already cached, return true immediately
if (imageCache[url]) return true;
try {
await ExpoImage.prefetch(url);
imageCache[url] = true;
return true;
} catch (error) {
console.error('Error preloading image:', error);
return false;
}
};
// Load poster first, then logo
useEffect(() => {
if (!featuredContent) return;
const posterUrl = featuredContent.banner || featuredContent.poster;
const titleLogo = featuredContent.logo;
const contentId = featuredContent.id;
// Reset states for new content
if (contentId !== prevContentIdRef.current) {
setPosterLoaded(false);
setLogoLoaded(false);
setImageError(false);
posterOpacity.value = 0;
logoOpacity.value = 0;
contentOpacity.value = 0;
}
prevContentIdRef.current = contentId;
// Sequential loading: poster first, then logo
const loadImages = async () => {
// Step 1: Load poster
if (posterUrl) {
setBannerUrl(posterUrl);
const posterSuccess = await preloadImage(posterUrl);
if (posterSuccess) {
setPosterLoaded(true);
// Fade in poster
posterOpacity.value = withTiming(1, {
duration: 600,
easing: Easing.bezier(0.25, 0.1, 0.25, 1)
});
// After poster loads, start showing content with slight delay
contentOpacity.value = withDelay(150, withTiming(1, {
duration: 400,
easing: Easing.bezier(0.25, 0.1, 0.25, 1)
}));
} else {
setImageError(true);
}
}
// Step 2: Load logo if available
if (titleLogo) {
setLogoUrl(titleLogo);
const logoSuccess = await preloadImage(titleLogo);
if (logoSuccess) {
setLogoLoaded(true);
// Fade in logo with delay after poster
logoOpacity.value = withDelay(300, withTiming(1, {
duration: 500,
easing: Easing.bezier(0.25, 0.1, 0.25, 1)
}));
}
}
};
loadImages();
}, [featuredContent?.id]);
// Preload next content
useEffect(() => {
if (!featuredContent || !posterLoaded) return;
// After current poster loads, prefetch for potential next items
const preloadNextContent = async () => {
// Simulate preloading next item (in a real app, you'd get this from allFeaturedContent)
if (featuredContent.type === 'movie' && featuredContent.id) {
// Try to preload related content by ID pattern
const relatedIds = [
`tmdb:${parseInt(featuredContent.id.split(':')[1] || '0') + 1}`,
`tmdb:${parseInt(featuredContent.id.split(':')[1] || '0') + 2}`
];
for (const id of relatedIds) {
// This is just a simulation - in real app you'd have actual next content URLs
const potentialNextPoster = featuredContent.poster?.replace(
featuredContent.id,
id
);
if (potentialNextPoster) {
await preloadImage(potentialNextPoster);
}
}
}
};
preloadNextContent();
}, [posterLoaded, featuredContent]);
if (!featuredContent) {
return <SkeletonFeatured />;
}
return (
<TouchableOpacity
activeOpacity={0.9}
onPress={() => {
navigation.navigate('Metadata', {
id: featuredContent.id,
type: featuredContent.type
});
}}
style={styles.featuredContainer as ViewStyle}
>
<Animated.View style={[styles.imageContainer, posterAnimatedStyle]}>
<ImageBackground
source={{ uri: bannerUrl || '' }}
style={styles.featuredImage as ViewStyle}
resizeMode="cover"
imageStyle={{ opacity: imageError ? 0.5 : 1 }}
>
<LinearGradient
colors={[
'transparent',
'rgba(0,0,0,0.1)',
'rgba(0,0,0,0.7)',
colors.darkBackground,
]}
locations={[0, 0.3, 0.7, 1]}
style={styles.featuredGradient as ViewStyle}
>
<Animated.View
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
>
{featuredContent.logo ? (
<Animated.View style={logoAnimatedStyle}>
<ExpoImage
source={{ uri: logoUrl }}
style={styles.featuredLogo as ImageStyle}
contentFit="contain"
cachePolicy="memory-disk"
transition={400}
/>
</Animated.View>
) : (
<Text style={styles.featuredTitleText as TextStyle}>{featuredContent.name}</Text>
)}
<View style={styles.genreContainer as ViewStyle}>
{featuredContent.genres?.slice(0, 3).map((genre, index, array) => (
<React.Fragment key={index}>
<Text style={styles.genreText as TextStyle}>{genre}</Text>
{index < array.length - 1 && (
<Text style={styles.genreDot as TextStyle}></Text>
)}
</React.Fragment>
))}
</View>
<View style={styles.featuredButtons as ViewStyle}>
<TouchableOpacity
style={styles.myListButton as ViewStyle}
onPress={handleSaveToLibrary}
>
<MaterialIcons
name={isSaved ? "bookmark" : "bookmark-border"}
size={24}
color={colors.white}
/>
<Text style={styles.myListButtonText as TextStyle}>
{isSaved ? "Saved" : "Save"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.playButton as ViewStyle}
onPress={() => {
if (featuredContent) {
navigation.navigate('Streams', {
id: featuredContent.id,
type: featuredContent.type
});
}
}}
>
<MaterialIcons name="play-arrow" size={24} color={colors.black} />
<Text style={styles.playButtonText as TextStyle}>Play</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.infoButton as ViewStyle}
onPress={() => {
if (featuredContent) {
navigation.navigate('Metadata', {
id: featuredContent.id,
type: featuredContent.type
});
}
}}
>
<MaterialIcons name="info-outline" size={24} color={colors.white} />
<Text style={styles.infoButtonText as TextStyle}>Info</Text>
</TouchableOpacity>
</View>
</Animated.View>
</LinearGradient>
</ImageBackground>
</Animated.View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
featuredContainer: {
width: '100%',
height: height * 0.6,
marginTop: 0,
marginBottom: 8,
position: 'relative',
backgroundColor: colors.elevation1,
},
imageContainer: {
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 2,
},
featuredImage: {
width: '100%',
height: '100%',
},
backgroundFallback: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.elevation1,
justifyContent: 'center',
alignItems: 'center',
zIndex: 1,
},
featuredGradient: {
width: '100%',
height: '100%',
justifyContent: 'space-between',
},
featuredContentContainer: {
flex: 1,
justifyContent: 'flex-end',
paddingHorizontal: 16,
paddingBottom: 20,
},
featuredLogo: {
width: width * 0.7,
height: 100,
marginBottom: 0,
alignSelf: 'center',
},
featuredTitleText: {
color: colors.highEmphasis,
fontSize: 28,
fontWeight: '900',
marginBottom: 8,
textShadowColor: 'rgba(0,0,0,0.6)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
textAlign: 'center',
paddingHorizontal: 16,
},
genreContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
flexWrap: 'wrap',
gap: 4,
},
genreText: {
color: colors.white,
fontSize: 14,
fontWeight: '500',
opacity: 0.9,
},
genreDot: {
color: colors.white,
fontSize: 14,
fontWeight: '500',
opacity: 0.6,
marginHorizontal: 4,
},
featuredButtons: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-evenly',
width: '100%',
flex: 1,
maxHeight: 65,
paddingTop: 16,
},
playButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
paddingHorizontal: 32,
borderRadius: 30,
backgroundColor: colors.white,
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
flex: 0,
width: 150,
},
myListButton: {
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
padding: 0,
gap: 6,
width: 44,
height: 44,
flex: undefined,
},
infoButton: {
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
padding: 0,
gap: 4,
width: 44,
height: 44,
flex: undefined,
},
playButtonText: {
color: colors.black,
fontWeight: '600',
marginLeft: 8,
fontSize: 16,
},
myListButtonText: {
color: colors.white,
fontSize: 12,
fontWeight: '500',
},
infoButtonText: {
color: colors.white,
fontSize: 12,
fontWeight: '500',
},
});
export default FeaturedContent;

View file

@ -0,0 +1,70 @@
import React from 'react';
import { View, Text, ActivityIndicator, StyleSheet, Dimensions } from 'react-native';
import { colors } from '../../styles/colors';
const { height } = Dimensions.get('window');
export const SkeletonCatalog = () => (
<View style={styles.catalogContainer}>
<View style={styles.loadingPlaceholder}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
</View>
);
export const SkeletonFeatured = () => (
<View style={styles.featuredLoadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading featured content...</Text>
</View>
);
const styles = StyleSheet.create({
catalogContainer: {
marginBottom: 24,
paddingTop: 0,
marginTop: 16,
},
loadingPlaceholder: {
height: 200,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.elevation1,
borderRadius: 12,
marginHorizontal: 16,
},
featuredLoadingContainer: {
height: height * 0.4,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.elevation1,
},
loadingText: {
color: colors.textMuted,
marginTop: 12,
fontSize: 14,
},
skeletonBox: {
backgroundColor: colors.elevation2,
borderRadius: 16,
overflow: 'hidden',
},
skeletonFeatured: {
width: '100%',
height: height * 0.6,
backgroundColor: colors.elevation2,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
marginBottom: 0,
},
skeletonPoster: {
backgroundColor: colors.elevation1,
marginHorizontal: 4,
borderRadius: 16,
},
});
export default {
SkeletonCatalog,
SkeletonFeatured
};

View file

@ -6,11 +6,22 @@ import * as Haptics from 'expo-haptics';
import { useGenres } from '../contexts/GenreContext';
import { useSettings, settingsEmitter } from './useSettings';
// Create a persistent store outside of the hook to maintain state between navigation
const persistentStore = {
featuredContent: null as StreamingContent | null,
allFeaturedContent: [] as StreamingContent[],
lastFetchTime: 0,
isFirstLoad: true
};
// Cache timeout in milliseconds (e.g., 5 minutes)
const CACHE_TIMEOUT = 5 * 60 * 1000;
export function useFeaturedContent() {
const [featuredContent, setFeaturedContent] = useState<StreamingContent | null>(null);
const [allFeaturedContent, setAllFeaturedContent] = useState<StreamingContent[]>([]);
const [featuredContent, setFeaturedContent] = useState<StreamingContent | null>(persistentStore.featuredContent);
const [allFeaturedContent, setAllFeaturedContent] = useState<StreamingContent[]>(persistentStore.allFeaturedContent);
const [isSaved, setIsSaved] = useState(false);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(persistentStore.isFirstLoad);
const currentIndexRef = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null);
const { settings } = useSettings();
@ -32,7 +43,23 @@ export function useFeaturedContent() {
}
}, []);
const loadFeaturedContent = useCallback(async () => {
const loadFeaturedContent = useCallback(async (forceRefresh = false) => {
// Check if we should use cached data
const now = Date.now();
const cacheAge = now - persistentStore.lastFetchTime;
if (!forceRefresh &&
persistentStore.featuredContent &&
persistentStore.allFeaturedContent.length > 0 &&
cacheAge < CACHE_TIMEOUT) {
// Use cached data
setFeaturedContent(persistentStore.featuredContent);
setAllFeaturedContent(persistentStore.allFeaturedContent);
setLoading(false);
persistentStore.isFirstLoad = false;
return;
}
setLoading(true);
cleanup();
abortControllerRef.current = new AbortController();
@ -101,13 +128,10 @@ export function useFeaturedContent() {
const filteredCatalogs = selectedCatalogs && selectedCatalogs.length > 0
? catalogs.filter(catalog => {
const catalogId = `${catalog.addon}:${catalog.type}:${catalog.id}`;
console.log(`Checking catalog: ${catalogId}, selected: ${selectedCatalogs.includes(catalogId)}`);
return selectedCatalogs.includes(catalogId);
})
: catalogs; // Use all catalogs if none specifically selected
console.log(`Original catalogs: ${catalogs.length}, Filtered catalogs: ${filteredCatalogs.length}`);
// Flatten all catalog items into a single array, filter out items without posters
const allItems = filteredCatalogs.flatMap(catalog => catalog.items)
.filter(item => item.poster)
@ -122,16 +146,23 @@ export function useFeaturedContent() {
if (signal.aborted) return;
// Update persistent store with the new data
persistentStore.allFeaturedContent = formattedContent;
persistentStore.lastFetchTime = now;
persistentStore.isFirstLoad = false;
setAllFeaturedContent(formattedContent);
if (formattedContent.length > 0) {
persistentStore.featuredContent = formattedContent[0];
setFeaturedContent(formattedContent[0]);
currentIndexRef.current = 0;
} else {
persistentStore.featuredContent = null;
setFeaturedContent(null);
}
} catch (error) {
if (signal.aborted) {
if (signal.aborted) {
logger.info('Featured content fetch aborted');
} else {
logger.error('Failed to load featured content:', error);
@ -147,12 +178,17 @@ export function useFeaturedContent() {
// Load featured content initially and when content source changes
useEffect(() => {
// Force a full refresh to get updated logos
if (contentSource === 'tmdb') {
const shouldForceRefresh = contentSource === 'tmdb' &&
contentSource !== persistentStore.featuredContent?.type;
if (shouldForceRefresh) {
setAllFeaturedContent([]);
setFeaturedContent(null);
persistentStore.allFeaturedContent = [];
persistentStore.featuredContent = null;
}
loadFeaturedContent();
loadFeaturedContent(shouldForceRefresh);
}, [loadFeaturedContent, contentSource, selectedCatalogs]);
useEffect(() => {
@ -184,7 +220,10 @@ export function useFeaturedContent() {
const rotateContent = () => {
currentIndexRef.current = (currentIndexRef.current + 1) % allFeaturedContent.length;
if (allFeaturedContent[currentIndexRef.current]) {
setFeaturedContent(allFeaturedContent[currentIndexRef.current]);
const newContent = allFeaturedContent[currentIndexRef.current];
setFeaturedContent(newContent);
// Also update the persistent store
persistentStore.featuredContent = newContent;
}
};
@ -217,11 +256,14 @@ export function useFeaturedContent() {
}
}, [featuredContent, isSaved]);
// Function to force a refresh if needed
const refreshFeatured = useCallback(() => loadFeaturedContent(true), [loadFeaturedContent]);
return {
featuredContent,
loading,
isSaved,
handleSaveToLibrary,
refreshFeatured: loadFeaturedContent
refreshFeatured
};
}

View file

@ -55,6 +55,10 @@ import { storageService } from '../services/storageService';
import { useHomeCatalogs } from '../hooks/useHomeCatalogs';
import { useFeaturedContent } from '../hooks/useFeaturedContent';
import { useSettings, settingsEmitter } from '../hooks/useSettings';
import FeaturedContent from '../components/home/FeaturedContent';
import CatalogSection from '../components/home/CatalogSection';
import { SkeletonFeatured } from '../components/home/SkeletonLoaders';
import homeStyles from '../styles/homeStyles';
// Define interfaces for our data
interface Category {
@ -348,13 +352,6 @@ const SkeletonCatalog = () => (
</View>
);
const SkeletonFeatured = () => (
<View style={styles.featuredLoadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading featured content...</Text>
</View>
);
const HomeScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = useColorScheme() === 'dark';
@ -390,15 +387,15 @@ const HomeScreen = () => {
setFeaturedContentSource(settings.featuredContentSource);
}, [settings]);
// If featured content source changes, refresh featured content with debouncing
// Update the featured content refresh logic to handle persistence
useEffect(() => {
if (showHeroSection) {
if (showHeroSection && featuredContentSource !== settings.featuredContentSource) {
// Clear any existing timeout
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
// Set a new timeout to debounce the refresh
// Set a new timeout to debounce the refresh - only when settings actually change
refreshTimeoutRef.current = setTimeout(() => {
refreshFeatured();
refreshTimeoutRef.current = null;
@ -411,14 +408,14 @@ const HomeScreen = () => {
clearTimeout(refreshTimeoutRef.current);
}
};
}, [featuredContentSource, showHeroSection, refreshFeatured]);
}, [featuredContentSource, settings.featuredContentSource, showHeroSection, refreshFeatured]);
useFocusEffect(
useCallback(() => {
const statusBarConfig = () => {
StatusBar.setBarStyle("light-content");
StatusBar.setTranslucent(true);
StatusBar.setBackgroundColor('transparent');
StatusBar.setTranslucent(true);
StatusBar.setBackgroundColor('transparent');
};
statusBarConfig();
@ -476,7 +473,8 @@ const HomeScreen = () => {
continueWatchingRef.current?.refresh(),
];
// Only refresh featured content if hero section is enabled
// Only refresh featured content if hero section is enabled,
// and force refresh to bypass the cache
if (showHeroSection) {
refreshTasks.push(refreshFeatured());
}
@ -526,198 +524,24 @@ const HomeScreen = () => {
};
}, [navigation, refreshContinueWatching]);
const renderFeaturedContent = () => {
if (!featuredContent) {
return <SkeletonFeatured />;
}
return (
<TouchableOpacity
activeOpacity={0.9}
onPress={() => {
if (featuredContent) {
navigation.navigate('Metadata', {
id: featuredContent.id,
type: featuredContent.type
});
}
}}
style={styles.featuredContainer}
>
<ImageBackground
source={{ uri: featuredContent.banner || featuredContent.poster }}
style={styles.featuredImage}
resizeMode="cover"
>
<LinearGradient
colors={[
'transparent',
'rgba(0,0,0,0.1)',
'rgba(0,0,0,0.7)',
colors.darkBackground,
]}
locations={[0, 0.3, 0.7, 1]}
style={styles.featuredGradient}
>
<Animated.View style={styles.featuredContentContainer} entering={FadeIn.duration(600)}>
{featuredContent.logo ? (
<ExpoImage
source={{ uri: featuredContent.logo }}
style={styles.featuredLogo}
contentFit="contain"
/>
) : (
<Text style={styles.featuredTitleText}>{featuredContent.name}</Text>
)}
<View style={styles.genreContainer}>
{featuredContent.genres?.slice(0, 3).map((genre, index, array) => (
<React.Fragment key={index}>
<Text style={styles.genreText}>{genre}</Text>
{index < array.length - 1 && (
<Text style={styles.genreDot}></Text>
)}
</React.Fragment>
))}
</View>
<View style={styles.featuredButtons}>
<TouchableOpacity
style={styles.myListButton}
onPress={handleSaveToLibrary}
>
<MaterialIcons
name={isSaved ? "bookmark" : "bookmark-border"}
size={24}
color={colors.white}
/>
<Text style={styles.myListButtonText}>
{isSaved ? "Saved" : "Save"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.playButton}
onPress={() => {
if (featuredContent) {
navigation.navigate('Streams', {
id: featuredContent.id,
type: featuredContent.type
});
}
}}
>
<MaterialIcons name="play-arrow" size={24} color={colors.black} />
<Text style={styles.playButtonText}>Play</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.infoButton}
onPress={async () => {
if (featuredContent) {
navigation.navigate('Metadata', {
id: featuredContent.id,
type: featuredContent.type
});
}
}}
>
<MaterialIcons name="info-outline" size={24} color={colors.white} />
<Text style={styles.infoButtonText}>Info</Text>
</TouchableOpacity>
</View>
</Animated.View>
</LinearGradient>
</ImageBackground>
</TouchableOpacity>
);
};
const renderContentItem = useCallback(({ item, index }: { item: StreamingContent, index: number }) => {
return (
<Animated.View
entering={FadeIn.duration(300).delay(100 + (index * 40))}
>
<ContentItem
item={item}
onPress={handleContentPress}
/>
</Animated.View>
);
}, [handleContentPress]);
const renderCatalog = ({ item }: { item: CatalogContent }) => {
return (
<Animated.View
style={styles.catalogContainer}
entering={FadeIn.duration(400).delay(50)}
>
<View style={styles.catalogHeader}>
<View style={styles.titleContainer}>
<Text style={styles.catalogTitle}>{item.name}</Text>
<LinearGradient
colors={[colors.primary, colors.secondary]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.titleUnderline}
/>
</View>
<TouchableOpacity
onPress={() =>
navigation.navigate('Catalog', {
id: item.id,
type: item.type,
addonId: item.addon
})
}
style={styles.seeAllButton}
>
<Text style={styles.seeAllText}>See More</Text>
<MaterialIcons name="arrow-forward" color={colors.primary} size={16} />
</TouchableOpacity>
</View>
<FlatList
data={item.items}
renderItem={({ item, index }) => renderContentItem({ item, index })}
keyExtractor={(item) => `${item.id}-${item.type}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.catalogList}
snapToInterval={POSTER_WIDTH + 12}
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 12 }} />}
initialNumToRender={4}
maxToRenderPerBatch={4}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
getItemLayout={(data, index) => ({
length: POSTER_WIDTH + 12,
offset: (POSTER_WIDTH + 12) * index,
index,
})}
/>
</Animated.View>
);
};
if (isLoading && !isRefreshing) {
return (
<View style={[styles.container]}>
<View style={homeStyles.container}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
translucent
/>
<View style={styles.loadingMainContainer}>
<View style={homeStyles.loadingMainContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading your content...</Text>
<Text style={homeStyles.loadingText}>Loading your content...</Text>
</View>
</View>
);
}
return (
<SafeAreaView style={[styles.container]}>
<SafeAreaView style={homeStyles.container}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
@ -733,12 +557,18 @@ const HomeScreen = () => {
/>
}
contentContainerStyle={[
styles.scrollContent,
homeStyles.scrollContent,
{ paddingTop: Platform.OS === 'ios' ? 0 : 0 }
]}
showsVerticalScrollIndicator={false}
>
{showHeroSection && renderFeaturedContent()}
{showHeroSection && (
<FeaturedContent
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
/>
)}
<Animated.View entering={FadeIn.duration(400).delay(150)}>
<ThisWeekSection />
@ -753,22 +583,22 @@ const HomeScreen = () => {
{catalogs.length > 0 ? (
catalogs.map((catalog, index) => (
<View key={`${catalog.addon}-${catalog.id}-${index}`}>
{renderCatalog({ item: catalog })}
<CatalogSection catalog={catalog} />
</View>
))
) : (
!catalogsLoading && (
<View style={styles.emptyCatalog}>
<View style={homeStyles.emptyCatalog}>
<MaterialIcons name="movie-filter" size={40} color={colors.textDark} />
<Text style={{ color: colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
No content available
</Text>
<TouchableOpacity
style={styles.addCatalogButton}
style={homeStyles.addCatalogButton}
onPress={() => navigation.navigate('Settings')}
>
<MaterialIcons name="add-circle" size={20} color={colors.white} />
<Text style={styles.addCatalogButtonText}>Add Catalogs</Text>
<Text style={homeStyles.addCatalogButtonText}>Add Catalogs</Text>
</TouchableOpacity>
</View>
)

55
src/styles/homeStyles.ts Normal file
View file

@ -0,0 +1,55 @@
import { StyleSheet, Dimensions, Platform } from 'react-native';
import { colors } from './colors';
const { width, height } = Dimensions.get('window');
export const POSTER_WIDTH = (width - 50) / 3;
export const homeStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.darkBackground,
},
scrollContent: {
paddingBottom: 40,
},
loadingMainContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingBottom: 40,
},
loadingText: {
color: colors.textMuted,
marginTop: 12,
fontSize: 14,
},
emptyCatalog: {
padding: 32,
alignItems: 'center',
backgroundColor: colors.elevation1,
margin: 16,
borderRadius: 16,
},
addCatalogButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.primary,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 30,
marginTop: 16,
elevation: 3,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 3,
},
addCatalogButtonText: {
color: colors.white,
fontSize: 14,
fontWeight: '600',
marginLeft: 8,
},
});
export default homeStyles;