mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
Merge branch 'ios' of https://github.com/nayifleo1/NuvioExpo into ios
This commit is contained in:
commit
87c70625b9
8 changed files with 1234 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;
|
||||
436
src/components/home/FeaturedContent.tsx
Normal file
436
src/components/home/FeaturedContent.tsx
Normal 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;
|
||||
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