Implement caching mechanism in useFeaturedContent hook to optimize data fetching and reduce unnecessary network requests; update HomeScreen to utilize new FeaturedContent component for improved structure and readability. Enhance refresh logic to handle persistence and ensure timely updates based on content source changes.

This commit is contained in:
tapframe 2025-05-03 13:58:06 +05:30
parent 3b6fb438e3
commit 18a1672eed
8 changed files with 1241 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,443 @@
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ImageBackground,
Dimensions,
ViewStyle,
TextStyle,
ImageStyle,
ActivityIndicator
} 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>
{!posterLoaded && (
<View style={styles.backgroundFallback}>
<ActivityIndicator color={colors.primary} size="large" />
</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;