mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
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:
parent
3b6fb438e3
commit
18a1672eed
8 changed files with 1241 additions and 211 deletions
147
src/components/home/CatalogSection.tsx
Normal file
147
src/components/home/CatalogSection.tsx
Normal 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;
|
||||
186
src/components/home/ContentItem.tsx
Normal file
186
src/components/home/ContentItem.tsx
Normal 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;
|
||||
257
src/components/home/DropUpMenu.tsx
Normal file
257
src/components/home/DropUpMenu.tsx
Normal 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;
|
||||
443
src/components/home/FeaturedContent.tsx
Normal file
443
src/components/home/FeaturedContent.tsx
Normal 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;
|
||||
70
src/components/home/SkeletonLoaders.tsx
Normal file
70
src/components/home/SkeletonLoaders.tsx
Normal 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
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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
55
src/styles/homeStyles.ts
Normal 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;
|
||||
Loading…
Reference in a new issue