mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-04 16:59:42 +00:00
Merge branch 'ios' of https://github.com/nayifleo1/NuvioExpo into ios
This commit is contained in:
commit
3632c44718
15 changed files with 2140 additions and 1245 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
|
||||
};
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
import React from 'react';
|
||||
import { StyleSheet, View, Text, ImageBackground } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||
import { colors } from '../../styles/colors';
|
||||
import { tmdbService } from '../../services/tmdbService';
|
||||
|
||||
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
|
||||
|
||||
interface EpisodeHeroProps {
|
||||
currentEpisode: {
|
||||
name: string;
|
||||
overview?: string;
|
||||
still_path?: string;
|
||||
air_date?: string | null;
|
||||
vote_average?: number;
|
||||
runtime?: number;
|
||||
episodeString: string;
|
||||
season_number?: number;
|
||||
episode_number?: number;
|
||||
} | null;
|
||||
metadata: {
|
||||
poster?: string;
|
||||
} | null;
|
||||
animatedStyle: any;
|
||||
}
|
||||
|
||||
const EpisodeHero = ({ currentEpisode, metadata, animatedStyle }: EpisodeHeroProps) => {
|
||||
if (!currentEpisode) return null;
|
||||
|
||||
const episodeImage = currentEpisode.still_path
|
||||
? tmdbService.getImageUrl(currentEpisode.still_path, 'original')
|
||||
: metadata?.poster || null;
|
||||
|
||||
// Format air date safely
|
||||
const formattedAirDate = currentEpisode.air_date !== undefined
|
||||
? tmdbService.formatAirDate(currentEpisode.air_date)
|
||||
: 'Unknown';
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.streamsHeroContainer, animatedStyle]}>
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(600).springify()}
|
||||
style={StyleSheet.absoluteFill}
|
||||
>
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(800).delay(100).springify().withInitialValues({
|
||||
transform: [{ scale: 1.05 }]
|
||||
})}
|
||||
style={StyleSheet.absoluteFill}
|
||||
>
|
||||
<ImageBackground
|
||||
source={episodeImage ? { uri: episodeImage } : undefined}
|
||||
style={styles.streamsHeroBackground}
|
||||
fadeDuration={0}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(0,0,0,0)',
|
||||
'rgba(0,0,0,0.4)',
|
||||
'rgba(0,0,0,0.7)',
|
||||
'rgba(0,0,0,0.85)',
|
||||
'rgba(0,0,0,0.95)',
|
||||
colors.darkBackground
|
||||
]}
|
||||
locations={[0, 0.3, 0.5, 0.7, 0.85, 1]}
|
||||
style={styles.streamsHeroGradient}
|
||||
>
|
||||
<View style={styles.streamsHeroContent}>
|
||||
<View style={styles.streamsHeroInfo}>
|
||||
<Text style={styles.streamsHeroEpisodeNumber}>
|
||||
{currentEpisode.episodeString}
|
||||
</Text>
|
||||
<Text style={styles.streamsHeroTitle} numberOfLines={1}>
|
||||
{currentEpisode.name}
|
||||
</Text>
|
||||
{currentEpisode.overview && (
|
||||
<Text style={styles.streamsHeroOverview} numberOfLines={2}>
|
||||
{currentEpisode.overview}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.streamsHeroMeta}>
|
||||
<Text style={styles.streamsHeroReleased}>
|
||||
{formattedAirDate}
|
||||
</Text>
|
||||
{currentEpisode.vote_average && currentEpisode.vote_average > 0 && (
|
||||
<View style={styles.streamsHeroRating}>
|
||||
<Image
|
||||
source={{ uri: TMDB_LOGO }}
|
||||
style={styles.tmdbLogo}
|
||||
contentFit="contain"
|
||||
/>
|
||||
<Text style={styles.streamsHeroRatingText}>
|
||||
{currentEpisode.vote_average.toFixed(1)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{currentEpisode.runtime && (
|
||||
<View style={styles.streamsHeroRuntime}>
|
||||
<MaterialIcons name="schedule" size={16} color={colors.mediumEmphasis} />
|
||||
<Text style={styles.streamsHeroRuntimeText}>
|
||||
{currentEpisode.runtime >= 60
|
||||
? `${Math.floor(currentEpisode.runtime / 60)}h ${currentEpisode.runtime % 60}m`
|
||||
: `${currentEpisode.runtime}m`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</ImageBackground>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
streamsHeroContainer: {
|
||||
width: '100%',
|
||||
height: 300,
|
||||
marginBottom: 0,
|
||||
position: 'relative',
|
||||
backgroundColor: colors.black,
|
||||
pointerEvents: 'box-none',
|
||||
},
|
||||
streamsHeroBackground: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: colors.black,
|
||||
},
|
||||
streamsHeroGradient: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
padding: 16,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
streamsHeroContent: {
|
||||
width: '100%',
|
||||
},
|
||||
streamsHeroInfo: {
|
||||
width: '100%',
|
||||
},
|
||||
streamsHeroEpisodeNumber: {
|
||||
color: colors.primary,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 2,
|
||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
streamsHeroTitle: {
|
||||
color: colors.highEmphasis,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 3,
|
||||
},
|
||||
streamsHeroOverview: {
|
||||
color: colors.mediumEmphasis,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 2,
|
||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
streamsHeroMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginTop: 0,
|
||||
},
|
||||
streamsHeroReleased: {
|
||||
color: colors.mediumEmphasis,
|
||||
fontSize: 14,
|
||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
streamsHeroRating: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 4,
|
||||
marginTop: 0,
|
||||
},
|
||||
tmdbLogo: {
|
||||
width: 20,
|
||||
height: 14,
|
||||
},
|
||||
streamsHeroRatingText: {
|
||||
color: '#01b4e4',
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
marginLeft: 4,
|
||||
},
|
||||
streamsHeroRuntime: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
},
|
||||
streamsHeroRuntimeText: {
|
||||
color: colors.mediumEmphasis,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(EpisodeHero);
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import React from 'react';
|
||||
import { StyleSheet, Text, View, ImageBackground, Dimensions, Platform } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Image } from 'expo-image';
|
||||
import Animated from 'react-native-reanimated';
|
||||
import { colors } from '../../styles/colors';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
interface MovieHeroProps {
|
||||
metadata: {
|
||||
name: string;
|
||||
logo?: string;
|
||||
banner?: string;
|
||||
poster?: string;
|
||||
} | null;
|
||||
animatedStyle: any;
|
||||
}
|
||||
|
||||
const MovieHero = ({ metadata, animatedStyle }: MovieHeroProps) => {
|
||||
if (!metadata) return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.movieTitleContainer, animatedStyle]}>
|
||||
<ImageBackground
|
||||
source={{ uri: metadata.banner || metadata.poster }}
|
||||
style={styles.movieTitleBackground}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(0,0,0,0.4)',
|
||||
'rgba(0,0,0,0.6)',
|
||||
'rgba(0,0,0,0.8)',
|
||||
colors.darkBackground
|
||||
]}
|
||||
locations={[0, 0.3, 0.7, 1]}
|
||||
style={styles.movieTitleGradient}
|
||||
>
|
||||
<View style={styles.movieTitleContent}>
|
||||
{metadata.logo ? (
|
||||
<Image
|
||||
source={{ uri: metadata.logo }}
|
||||
style={styles.movieLogo}
|
||||
contentFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.movieTitle} numberOfLines={2}>
|
||||
{metadata.name}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</ImageBackground>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
movieTitleContainer: {
|
||||
width: '100%',
|
||||
height: 180,
|
||||
backgroundColor: colors.black,
|
||||
pointerEvents: 'box-none',
|
||||
},
|
||||
movieTitleBackground: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: colors.black,
|
||||
},
|
||||
movieTitleGradient: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
movieTitleContent: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
marginTop: Platform.OS === 'android' ? 35 : 45,
|
||||
},
|
||||
movieLogo: {
|
||||
width: width * 0.6,
|
||||
height: 70,
|
||||
marginBottom: 8,
|
||||
},
|
||||
movieTitle: {
|
||||
color: colors.highEmphasis,
|
||||
fontSize: 28,
|
||||
fontWeight: '900',
|
||||
textAlign: 'center',
|
||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||
textShadowOffset: { width: 0, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(MovieHero);
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { FlatList, StyleSheet, Text, TouchableOpacity } from 'react-native';
|
||||
import { colors } from '../../styles/colors';
|
||||
|
||||
interface ProviderFilterProps {
|
||||
selectedProvider: string;
|
||||
providers: Array<{ id: string; name: string; }>;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const ProviderFilter = ({ selectedProvider, providers, onSelect }: ProviderFilterProps) => {
|
||||
const renderItem = useCallback(({ item }: { item: { id: string; name: string } }) => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={[
|
||||
styles.filterChip,
|
||||
selectedProvider === item.id && styles.filterChipSelected
|
||||
]}
|
||||
onPress={() => onSelect(item.id)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.filterChipText,
|
||||
selectedProvider === item.id && styles.filterChipTextSelected
|
||||
]}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
), [selectedProvider, onSelect]);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={providers}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={item => item.id}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.filterScroll}
|
||||
bounces={true}
|
||||
overScrollMode="never"
|
||||
decelerationRate="fast"
|
||||
initialNumToRender={5}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={3}
|
||||
getItemLayout={(data, index) => ({
|
||||
length: 100, // Approximate width of each item
|
||||
offset: 100 * index,
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
filterScroll: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
filterChip: {
|
||||
backgroundColor: colors.transparentLight,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
marginRight: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.transparent,
|
||||
},
|
||||
filterChipSelected: {
|
||||
backgroundColor: colors.transparentLight,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
filterChipText: {
|
||||
color: colors.text,
|
||||
fontWeight: '500',
|
||||
},
|
||||
filterChipTextSelected: {
|
||||
color: colors.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(ProviderFilter);
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { colors } from '../../styles/colors';
|
||||
import { Stream } from '../../types/metadata';
|
||||
import QualityBadge from '../metadata/QualityBadge';
|
||||
|
||||
interface StreamCardProps {
|
||||
stream: Stream;
|
||||
onPress: () => void;
|
||||
index: number;
|
||||
isLoading?: boolean;
|
||||
statusMessage?: string;
|
||||
}
|
||||
|
||||
const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: StreamCardProps) => {
|
||||
const quality = stream.title?.match(/(\d+)p/)?.[1] || null;
|
||||
const isHDR = stream.title?.toLowerCase().includes('hdr');
|
||||
const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV');
|
||||
const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1];
|
||||
const isDebrid = stream.behaviorHints?.cached;
|
||||
|
||||
const displayTitle = stream.name || stream.title || 'Unnamed Stream';
|
||||
const displayAddonName = stream.title || '';
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.streamCard,
|
||||
isLoading && styles.streamCardLoading
|
||||
]}
|
||||
onPress={onPress}
|
||||
disabled={isLoading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.streamDetails}>
|
||||
<View style={styles.streamNameRow}>
|
||||
<View style={styles.streamTitleContainer}>
|
||||
<Text style={styles.streamName}>
|
||||
{displayTitle}
|
||||
</Text>
|
||||
{displayAddonName && displayAddonName !== displayTitle && (
|
||||
<Text style={styles.streamAddonName}>
|
||||
{displayAddonName}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Show loading indicator if stream is loading */}
|
||||
{isLoading && (
|
||||
<View style={styles.loadingIndicator}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<Text style={styles.loadingText}>
|
||||
{statusMessage || "Loading..."}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.streamMetaRow}>
|
||||
{quality && quality >= "720" && (
|
||||
<QualityBadge type="HD" />
|
||||
)}
|
||||
|
||||
{isDolby && (
|
||||
<QualityBadge type="VISION" />
|
||||
)}
|
||||
|
||||
{size && (
|
||||
<View style={[styles.chip, { backgroundColor: colors.darkGray }]}>
|
||||
<Text style={styles.chipText}>{size}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isDebrid && (
|
||||
<View style={[styles.chip, { backgroundColor: colors.success }]}>
|
||||
<Text style={styles.chipText}>DEBRID</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.streamAction}>
|
||||
<MaterialIcons
|
||||
name="play-arrow"
|
||||
size={24}
|
||||
color={colors.primary}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
streamCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
minHeight: 70,
|
||||
backgroundColor: colors.elevation1,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
},
|
||||
streamCardLoading: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
streamDetails: {
|
||||
flex: 1,
|
||||
},
|
||||
streamNameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8
|
||||
},
|
||||
streamTitleContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
streamName: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
lineHeight: 20,
|
||||
color: colors.highEmphasis,
|
||||
},
|
||||
streamAddonName: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
color: colors.mediumEmphasis,
|
||||
marginBottom: 6,
|
||||
},
|
||||
streamMetaRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
marginBottom: 6,
|
||||
alignItems: 'center',
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
marginRight: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
chipText: {
|
||||
color: colors.highEmphasis,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loadingIndicator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
loadingText: {
|
||||
color: colors.primary,
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
streamAction: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.elevation2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(StreamCard);
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
import { Platform, Linking } from 'react-native';
|
||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { Stream } from '../types/metadata';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
interface UseStreamNavigationProps {
|
||||
metadata: {
|
||||
name?: string;
|
||||
year?: number;
|
||||
} | null;
|
||||
currentEpisode?: {
|
||||
name?: string;
|
||||
season_number?: number;
|
||||
episode_number?: number;
|
||||
} | null;
|
||||
id: string;
|
||||
type: string;
|
||||
selectedEpisode?: string;
|
||||
useExternalPlayer?: boolean;
|
||||
preferredPlayer?: 'internal' | 'vlc' | 'outplayer' | 'infuse' | 'vidhub' | 'external';
|
||||
}
|
||||
|
||||
export const useStreamNavigation = ({
|
||||
metadata,
|
||||
currentEpisode,
|
||||
id,
|
||||
type,
|
||||
selectedEpisode,
|
||||
useExternalPlayer,
|
||||
preferredPlayer
|
||||
}: UseStreamNavigationProps) => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
||||
const navigateToPlayer = useCallback((stream: Stream) => {
|
||||
navigation.navigate('Player', {
|
||||
uri: stream.url,
|
||||
title: metadata?.name || '',
|
||||
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
|
||||
season: type === 'series' ? currentEpisode?.season_number : undefined,
|
||||
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
|
||||
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
|
||||
year: metadata?.year,
|
||||
streamProvider: stream.name,
|
||||
id,
|
||||
type,
|
||||
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined
|
||||
});
|
||||
}, [metadata, type, currentEpisode, navigation, id, selectedEpisode]);
|
||||
|
||||
const handleStreamPress = useCallback(async (stream: Stream) => {
|
||||
try {
|
||||
if (stream.url) {
|
||||
logger.log('handleStreamPress called with stream:', {
|
||||
url: stream.url,
|
||||
behaviorHints: stream.behaviorHints,
|
||||
useExternalPlayer,
|
||||
preferredPlayer
|
||||
});
|
||||
|
||||
// For iOS, try to open with the preferred external player
|
||||
if (Platform.OS === 'ios' && preferredPlayer !== 'internal') {
|
||||
try {
|
||||
// Format the URL for the selected player
|
||||
const streamUrl = encodeURIComponent(stream.url);
|
||||
let externalPlayerUrls: string[] = [];
|
||||
|
||||
// Configure URL formats based on the selected player
|
||||
switch (preferredPlayer) {
|
||||
case 'vlc':
|
||||
externalPlayerUrls = [
|
||||
`vlc://${stream.url}`,
|
||||
`vlc-x-callback://x-callback-url/stream?url=${streamUrl}`,
|
||||
`vlc://${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
case 'outplayer':
|
||||
externalPlayerUrls = [
|
||||
`outplayer://${stream.url}`,
|
||||
`outplayer://${streamUrl}`,
|
||||
`outplayer://play?url=${streamUrl}`,
|
||||
`outplayer://stream?url=${streamUrl}`,
|
||||
`outplayer://play/browser?url=${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
case 'infuse':
|
||||
externalPlayerUrls = [
|
||||
`infuse://x-callback-url/play?url=${streamUrl}`,
|
||||
`infuse://play?url=${streamUrl}`,
|
||||
`infuse://${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
case 'vidhub':
|
||||
externalPlayerUrls = [
|
||||
`vidhub://play?url=${streamUrl}`,
|
||||
`vidhub://${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
default:
|
||||
// If no matching player or the setting is somehow invalid, use internal player
|
||||
navigateToPlayer(stream);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Attempting to open stream in ${preferredPlayer}`);
|
||||
|
||||
// Try each URL format in sequence
|
||||
const tryNextUrl = (index: number) => {
|
||||
if (index >= externalPlayerUrls.length) {
|
||||
console.log(`All ${preferredPlayer} formats failed, falling back to direct URL`);
|
||||
// Try direct URL as last resort
|
||||
Linking.openURL(stream.url)
|
||||
.then(() => console.log('Opened with direct URL'))
|
||||
.catch(() => {
|
||||
console.log('Direct URL failed, falling back to built-in player');
|
||||
navigateToPlayer(stream);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = externalPlayerUrls[index];
|
||||
console.log(`Trying ${preferredPlayer} URL format ${index + 1}: ${url}`);
|
||||
|
||||
Linking.openURL(url)
|
||||
.then(() => console.log(`Successfully opened stream with ${preferredPlayer} format ${index + 1}`))
|
||||
.catch(err => {
|
||||
console.log(`Format ${index + 1} failed: ${err.message}`, err);
|
||||
tryNextUrl(index + 1);
|
||||
});
|
||||
};
|
||||
|
||||
// Start with the first URL format
|
||||
tryNextUrl(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error with ${preferredPlayer}:`, error);
|
||||
// Fallback to the built-in player
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
}
|
||||
// For Android with external player preference
|
||||
else if (Platform.OS === 'android' && useExternalPlayer) {
|
||||
try {
|
||||
console.log('Opening stream with Android native app chooser');
|
||||
|
||||
// For Android, determine if the URL is a direct http/https URL or a magnet link
|
||||
const isMagnet = stream.url.startsWith('magnet:');
|
||||
|
||||
if (isMagnet) {
|
||||
// For magnet links, open directly which will trigger the torrent app chooser
|
||||
console.log('Opening magnet link directly');
|
||||
Linking.openURL(stream.url)
|
||||
.then(() => console.log('Successfully opened magnet link'))
|
||||
.catch(err => {
|
||||
console.error('Failed to open magnet link:', err);
|
||||
// No good fallback for magnet links
|
||||
navigateToPlayer(stream);
|
||||
});
|
||||
} else {
|
||||
// For direct video URLs, use the S.Browser.ACTION_VIEW approach
|
||||
// This is a more reliable way to force Android to show all video apps
|
||||
|
||||
// Strip query parameters if they exist as they can cause issues with some apps
|
||||
let cleanUrl = stream.url;
|
||||
if (cleanUrl.includes('?')) {
|
||||
cleanUrl = cleanUrl.split('?')[0];
|
||||
}
|
||||
|
||||
// Create an Android intent URL that forces the chooser
|
||||
// Set component=null to ensure chooser is shown
|
||||
// Set action=android.intent.action.VIEW to open the content
|
||||
const intentUrl = `intent:${cleanUrl}#Intent;action=android.intent.action.VIEW;category=android.intent.category.DEFAULT;component=;type=video/*;launchFlags=0x10000000;end`;
|
||||
|
||||
console.log(`Using intent URL: ${intentUrl}`);
|
||||
|
||||
Linking.openURL(intentUrl)
|
||||
.then(() => console.log('Successfully opened with intent URL'))
|
||||
.catch(err => {
|
||||
console.error('Failed to open with intent URL:', err);
|
||||
|
||||
// First fallback: Try direct URL with regular Linking API
|
||||
console.log('Trying plain URL as fallback');
|
||||
Linking.openURL(stream.url)
|
||||
.then(() => console.log('Opened with direct URL'))
|
||||
.catch(directErr => {
|
||||
console.error('Failed to open direct URL:', directErr);
|
||||
|
||||
// Final fallback: Use built-in player
|
||||
console.log('All external player attempts failed, using built-in player');
|
||||
navigateToPlayer(stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error with external player:', error);
|
||||
// Fallback to the built-in player
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// For internal player or if other options failed, use the built-in player
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in handleStreamPress:', error);
|
||||
// Final fallback: Use built-in player
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
}, [navigateToPlayer, preferredPlayer, useExternalPlayer]);
|
||||
|
||||
return {
|
||||
handleStreamPress,
|
||||
navigateToPlayer
|
||||
};
|
||||
};
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { stremioService } from '../services/stremioService';
|
||||
import { Stream } from '../types/metadata';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
interface StreamGroups {
|
||||
[addonId: string]: {
|
||||
addonName: string;
|
||||
streams: Stream[];
|
||||
};
|
||||
}
|
||||
|
||||
export const useStreamProviders = (
|
||||
groupedStreams: StreamGroups,
|
||||
episodeStreams: StreamGroups,
|
||||
type: string,
|
||||
loadingStreams: boolean,
|
||||
loadingEpisodeStreams: boolean
|
||||
) => {
|
||||
const [selectedProvider, setSelectedProvider] = useState('all');
|
||||
const [availableProviders, setAvailableProviders] = useState<Set<string>>(new Set());
|
||||
const [loadingProviders, setLoadingProviders] = useState<{[key: string]: boolean}>({});
|
||||
const [providerStatus, setProviderStatus] = useState<{
|
||||
[key: string]: {
|
||||
loading: boolean;
|
||||
success: boolean;
|
||||
error: boolean;
|
||||
message: string;
|
||||
timeStarted: number;
|
||||
timeCompleted: number;
|
||||
}
|
||||
}>({});
|
||||
const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({});
|
||||
const [loadStartTime, setLoadStartTime] = useState(0);
|
||||
|
||||
// Update available providers when streams change - converted to useEffect
|
||||
useEffect(() => {
|
||||
const streams = type === 'series' ? episodeStreams : groupedStreams;
|
||||
const providers = new Set(Object.keys(streams));
|
||||
setAvailableProviders(providers);
|
||||
}, [type, groupedStreams, episodeStreams]);
|
||||
|
||||
// Start tracking load time when loading begins - converted to useEffect
|
||||
useEffect(() => {
|
||||
if (loadingStreams || loadingEpisodeStreams) {
|
||||
logger.log("⏱️ Stream loading started");
|
||||
const now = Date.now();
|
||||
setLoadStartTime(now);
|
||||
setProviderLoadTimes({});
|
||||
|
||||
// Reset provider status - only for stremio addons
|
||||
setProviderStatus({
|
||||
'stremio': {
|
||||
loading: true,
|
||||
success: false,
|
||||
error: false,
|
||||
message: 'Loading...',
|
||||
timeStarted: now,
|
||||
timeCompleted: 0
|
||||
}
|
||||
});
|
||||
|
||||
// Also update the simpler loading state - only for stremio
|
||||
setLoadingProviders({
|
||||
'stremio': true
|
||||
});
|
||||
}
|
||||
}, [loadingStreams, loadingEpisodeStreams]);
|
||||
|
||||
// Generate filter items for the provider selector
|
||||
const filterItems = useMemo(() => {
|
||||
const installedAddons = stremioService.getInstalledAddons();
|
||||
const streams = type === 'series' ? episodeStreams : groupedStreams;
|
||||
|
||||
return [
|
||||
{ id: 'all', name: 'All Providers' },
|
||||
...Array.from(availableProviders)
|
||||
.sort((a, b) => {
|
||||
const indexA = installedAddons.findIndex(addon => addon.id === a);
|
||||
const indexB = installedAddons.findIndex(addon => addon.id === b);
|
||||
|
||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map(provider => {
|
||||
const addonInfo = streams[provider];
|
||||
const installedAddon = installedAddons.find(addon => addon.id === provider);
|
||||
|
||||
let displayName = provider;
|
||||
if (installedAddon) displayName = installedAddon.name;
|
||||
else if (addonInfo?.addonName) displayName = addonInfo.addonName;
|
||||
|
||||
return { id: provider, name: displayName };
|
||||
})
|
||||
];
|
||||
}, [availableProviders, type, episodeStreams, groupedStreams]);
|
||||
|
||||
// Filter streams to show only selected provider (or all)
|
||||
const filteredSections = useMemo(() => {
|
||||
const streams = type === 'series' ? episodeStreams : groupedStreams;
|
||||
const installedAddons = stremioService.getInstalledAddons();
|
||||
|
||||
return Object.entries(streams)
|
||||
.filter(([addonId]) => {
|
||||
// If "all" is selected, show all providers
|
||||
if (selectedProvider === 'all') {
|
||||
return true;
|
||||
}
|
||||
// Otherwise only show the selected provider
|
||||
return addonId === selectedProvider;
|
||||
})
|
||||
.sort(([addonIdA], [addonIdB]) => {
|
||||
const indexA = installedAddons.findIndex(addon => addon.id === addonIdA);
|
||||
const indexB = installedAddons.findIndex(addon => addon.id === addonIdB);
|
||||
|
||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map(([addonId, { addonName, streams }]) => ({
|
||||
title: addonName,
|
||||
addonId,
|
||||
data: streams
|
||||
}));
|
||||
}, [selectedProvider, type, episodeStreams, groupedStreams]);
|
||||
|
||||
// Handler for changing the selected provider
|
||||
const handleProviderChange = useCallback((provider: string) => {
|
||||
setSelectedProvider(provider);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedProvider,
|
||||
availableProviders,
|
||||
loadingProviders,
|
||||
providerStatus,
|
||||
filterItems,
|
||||
filteredSections,
|
||||
handleProviderChange,
|
||||
setLoadingProviders,
|
||||
setProviderStatus
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
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