Ios #4

Merged
tapframe merged 13 commits from ios into main 2025-05-03 10:17:18 +00:00
13 changed files with 1555 additions and 356 deletions

View file

@ -8,15 +8,15 @@ An app I built with React Native/Expo for browsing and watching movies & shows.
Built for iOS and Android.
## Key Features
## Key Features
* **Home Screen:** Highlights new content, your watch history, and content categories.
* **Discover:** Browse trending and popular movies & TV shows.
* **Details:** Displays detailed info (descriptions, cast, ratings).
* **Video Player:** Integrated player that remembers playback progress.
* **Video Player:** Integrated player(still broken on IOS,supports External PLayer for now).
* **Stream Finding:** Finds available streams using Stremio addons.
* **Search:** Quickly find specific movies or shows.
* **Trakt Sync:** Option to connect your Trakt.tv account.
* **Trakt Sync:** Planned integration (coming soon).
* **Addon Management:** Add and manage your Stremio addons.
* **UI:** Focuses on a clean, interactive user experience.
@ -28,7 +28,7 @@ Built for iOS and Android.
| **Metadata** | **Seasons & Episodes** | **Rating** |
| ![Metadata](src/assets/metadascreen.jpg) | ![Seasons](src/assets/seasonandepisode.jpg)| ![Rating](src/assets/ratingscreen.jpg) |
## Wanna run it? 🚀
## Development
1. You'll need Node.js, npm/yarn, and the Expo Go app (or native build tools like Android Studio/Xcode).
2. `git clone https://github.com/nayifleo1/NuvioExpo.git`
@ -37,11 +37,11 @@ Built for iOS and Android.
5. `npx expo start` (Easiest way: Scan QR code with Expo Go app)
* Or `npx expo run:android` / `npx expo run:ios` for native builds.
## Found a bug or have an idea? 🐛
## Found a bug or have an idea?
Great! Please open an [Issue on GitHub](https://github.com/nayifleo1/NuvioExpo/issues). Describe the problem or your suggestion.
## Want to contribute? 🤝
## Contribution
Contributions are welcome! Fork the repository, make your changes, and submit a Pull Request.

View file

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

View file

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

View file

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

View file

@ -0,0 +1,391 @@
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ImageBackground,
Dimensions,
ViewStyle,
TextStyle,
ImageStyle,
ActivityIndicator,
Platform
} 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 [logoUrl, setLogoUrl] = useState<string | null>(null);
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
const prevContentIdRef = useRef<string | null>(null);
// Animation values
const posterOpacity = useSharedValue(0);
const logoOpacity = useSharedValue(0);
const contentOpacity = useSharedValue(1); // Start visible
const buttonsOpacity = useSharedValue(1);
const posterAnimatedStyle = useAnimatedStyle(() => ({
opacity: posterOpacity.value,
}));
const logoAnimatedStyle = useAnimatedStyle(() => ({
opacity: logoOpacity.value,
}));
const contentAnimatedStyle = useAnimatedStyle(() => ({
opacity: contentOpacity.value,
}));
const buttonsAnimatedStyle = useAnimatedStyle(() => ({
opacity: buttonsOpacity.value,
}));
// Preload the image
const preloadImage = async (url: string): Promise<boolean> => {
if (!url) return false;
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 and 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) {
posterOpacity.value = 0;
logoOpacity.value = 0;
}
prevContentIdRef.current = contentId;
// Set URLs immediately for instant display
if (posterUrl) setBannerUrl(posterUrl);
if (titleLogo) setLogoUrl(titleLogo);
// Load images in background
const loadImages = async () => {
// Load poster
if (posterUrl) {
const posterSuccess = await preloadImage(posterUrl);
if (posterSuccess) {
posterOpacity.value = withTiming(1, {
duration: 600,
easing: Easing.bezier(0.25, 0.1, 0.25, 1)
});
}
}
// Load logo if available
if (titleLogo) {
const logoSuccess = await preloadImage(titleLogo);
if (logoSuccess) {
logoOpacity.value = withDelay(300, withTiming(1, {
duration: 500,
easing: Easing.bezier(0.25, 0.1, 0.25, 1)
}));
}
}
};
loadImages();
}, [featuredContent?.id]);
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 || featuredContent.poster }}
style={styles.featuredImage as ViewStyle}
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 as ViewStyle}
>
<Animated.View
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
>
{featuredContent.logo ? (
<Animated.View style={logoAnimatedStyle}>
<ExpoImage
source={{ uri: logoUrl || featuredContent.logo }}
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>
</Animated.View>
<Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}>
<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>
</Animated.View>
</LinearGradient>
</ImageBackground>
</Animated.View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
featuredContainer: {
width: '100%',
height: height * 0.48,
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: 4,
},
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: 4,
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: 55,
paddingTop: 0,
},
playButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
paddingHorizontal: 32,
borderRadius: 30,
backgroundColor: colors.white,
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
flex: 0,
width: 150,
},
myListButton: {
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
padding: 0,
gap: 6,
width: 44,
height: 44,
flex: undefined,
},
infoButton: {
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
padding: 0,
gap: 4,
width: 44,
height: 44,
flex: undefined,
},
playButtonText: {
color: colors.black,
fontWeight: '600',
marginLeft: 8,
fontSize: 16,
},
myListButtonText: {
color: colors.white,
fontSize: 12,
fontWeight: '500',
},
infoButtonText: {
color: colors.white,
fontSize: 12,
fontWeight: '500',
},
});
export default FeaturedContent;

View file

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

View file

@ -6,11 +6,28 @@ 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,
// Track last used settings to detect changes on app restart
lastSettings: {
showHeroSection: true,
featuredContentSource: 'tmdb' as 'tmdb' | 'catalogs',
selectedHeroCatalogs: [] as string[]
}
};
// 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();
@ -19,7 +36,7 @@ export function useFeaturedContent() {
const { genreMap, loadingGenres } = useGenres();
// Update local state when settings change
// Simple update for state variables
useEffect(() => {
setContentSource(settings.featuredContentSource);
setSelectedCatalogs(settings.selectedHeroCatalogs || []);
@ -32,7 +49,33 @@ export function useFeaturedContent() {
}
}, []);
const loadFeaturedContent = useCallback(async () => {
const loadFeaturedContent = useCallback(async (forceRefresh = false) => {
// First, ensure contentSource matches current settings (could be outdated due to async updates)
if (contentSource !== settings.featuredContentSource) {
console.log(`Updating content source from ${contentSource} to ${settings.featuredContentSource}`);
setContentSource(settings.featuredContentSource);
// We return here and let the effect triggered by contentSource change handle the loading
return;
}
// 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
console.log('Using cached featured content data');
setFeaturedContent(persistentStore.featuredContent);
setAllFeaturedContent(persistentStore.allFeaturedContent);
setLoading(false);
persistentStore.isFirstLoad = false;
return;
}
console.log(`Loading featured content from ${contentSource}`);
setLoading(true);
cleanup();
abortControllerRef.current = new AbortController();
@ -101,13 +144,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 +162,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);
@ -145,14 +192,75 @@ export function useFeaturedContent() {
}
}, [cleanup, genreMap, loadingGenres, contentSource, selectedCatalogs]);
// Check for settings changes, including during app restart
useEffect(() => {
// Check if settings changed while app was closed
const settingsChanged =
persistentStore.lastSettings.showHeroSection !== settings.showHeroSection ||
persistentStore.lastSettings.featuredContentSource !== settings.featuredContentSource ||
JSON.stringify(persistentStore.lastSettings.selectedHeroCatalogs) !== JSON.stringify(settings.selectedHeroCatalogs);
// Update our tracking of last used settings
persistentStore.lastSettings = {
showHeroSection: settings.showHeroSection,
featuredContentSource: settings.featuredContentSource,
selectedHeroCatalogs: [...settings.selectedHeroCatalogs]
};
// Force refresh if settings changed during app restart
if (settingsChanged) {
loadFeaturedContent(true);
}
}, [settings, loadFeaturedContent]);
// Subscribe directly to settings emitter for immediate updates
useEffect(() => {
const handleSettingsChange = () => {
// Only refresh if current content source is different from settings
// This prevents duplicate refreshes when HomeScreen also handles this event
if (contentSource !== settings.featuredContentSource) {
console.log('Content source changed, refreshing featured content');
console.log('Current content source:', contentSource);
console.log('New settings source:', settings.featuredContentSource);
// Content source will be updated in the next render cycle due to state updates
// No need to call loadFeaturedContent here as it will be triggered by contentSource change
} else if (
contentSource === 'catalogs' &&
JSON.stringify(selectedCatalogs) !== JSON.stringify(settings.selectedHeroCatalogs)
) {
// Only refresh if using catalogs and selected catalogs changed
console.log('Selected catalogs changed, refreshing featured content');
loadFeaturedContent(true);
}
};
// Subscribe to settings changes
const unsubscribe = settingsEmitter.addListener(handleSettingsChange);
return unsubscribe;
}, [loadFeaturedContent, settings, contentSource, selectedCatalogs]);
// Load featured content initially and when content source changes
useEffect(() => {
// Force a full refresh to get updated logos
if (contentSource === 'tmdb') {
// Force refresh when switching to catalogs or when catalog selection changes
if (contentSource === 'catalogs') {
// Clear cache when switching to catalogs mode
setAllFeaturedContent([]);
setFeaturedContent(null);
persistentStore.allFeaturedContent = [];
persistentStore.featuredContent = null;
loadFeaturedContent(true);
} else if (contentSource === 'tmdb' && contentSource !== persistentStore.featuredContent?.type) {
// Clear cache when switching to TMDB mode from catalogs
setAllFeaturedContent([]);
setFeaturedContent(null);
persistentStore.allFeaturedContent = [];
persistentStore.featuredContent = null;
loadFeaturedContent(true);
} else {
// Normal load (might use cache if available)
loadFeaturedContent(false);
}
loadFeaturedContent();
}, [loadFeaturedContent, contentSource, selectedCatalogs]);
useEffect(() => {
@ -184,7 +292,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 +328,14 @@ export function useFeaturedContent() {
}
}, [featuredContent, isSaved]);
// Function to force a refresh if needed
const refreshFeatured = useCallback(() => loadFeaturedContent(true), [loadFeaturedContent]);
return {
featuredContent,
loading,
isSaved,
handleSaveToLibrary,
refreshFeatured: loadFeaturedContent
refreshFeatured
};
}

View file

@ -84,8 +84,11 @@ export const useSettings = () => {
try {
await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings));
setSettings(newSettings);
console.log(`Setting updated: ${key}`, value);
// Notify all subscribers that settings have changed (if requested)
if (emitEvent) {
console.log('Emitting settings change event');
settingsEmitter.emit();
}
} catch (error) {

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import {
View,
Text,
@ -12,9 +12,10 @@ import {
useColorScheme,
ActivityIndicator,
Alert,
Animated
} from 'react-native';
import { useSettings } from '../hooks/useSettings';
import { useNavigation } from '@react-navigation/native';
import { useSettings, settingsEmitter } from '../hooks/useSettings';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { colors } from '../styles/colors';
import { catalogService, StreamingAddon } from '../services/catalogService';
@ -38,6 +39,58 @@ const HeroCatalogsScreen: React.FC = () => {
const [catalogs, setCatalogs] = useState<CatalogItem[]>([]);
const [selectedCatalogs, setSelectedCatalogs] = useState<string[]>(settings.selectedHeroCatalogs || []);
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames();
const [showSavedIndicator, setShowSavedIndicator] = useState(false);
const fadeAnim = useRef(new Animated.Value(0)).current;
// Ensure selected catalogs state is refreshed whenever the screen gains focus
useFocusEffect(
useCallback(() => {
setSelectedCatalogs(settings.selectedHeroCatalogs || []);
}, [settings.selectedHeroCatalogs])
);
// Subscribe to settings changes
useEffect(() => {
const unsubscribe = settingsEmitter.addListener(() => {
// Refresh selected catalogs when settings change
setSelectedCatalogs(settings.selectedHeroCatalogs || []);
});
return unsubscribe;
}, [settings.selectedHeroCatalogs]);
// Fade in/out animation for the "Changes saved" indicator
useEffect(() => {
if (showSavedIndicator) {
Animated.sequence([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true
}),
Animated.delay(1500),
Animated.timing(fadeAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true
})
]).start(() => setShowSavedIndicator(false));
}
}, [showSavedIndicator, fadeAnim]);
const handleSave = useCallback(() => {
// First update the settings
updateSetting('selectedHeroCatalogs', selectedCatalogs);
// Show the confirmation indicator
setShowSavedIndicator(true);
// Short delay before navigating back to allow settings to save
// and the user to see the confirmation message
setTimeout(() => {
navigation.goBack();
}, 800);
}, [navigation, selectedCatalogs, updateSetting]);
const handleBack = useCallback(() => {
navigation.goBack();
@ -84,11 +137,6 @@ const HeroCatalogsScreen: React.FC = () => {
setSelectedCatalogs([]);
}, []);
const handleSave = useCallback(() => {
updateSetting('selectedHeroCatalogs', selectedCatalogs);
navigation.goBack();
}, [navigation, selectedCatalogs, updateSetting]);
const toggleCatalog = useCallback((catalogId: string) => {
setSelectedCatalogs(prev => {
if (prev.includes(catalogId)) {
@ -127,6 +175,21 @@ const HeroCatalogsScreen: React.FC = () => {
</Text>
</View>
{/* Saved indicator */}
<Animated.View
style={[
styles.savedIndicator,
{
opacity: fadeAnim,
backgroundColor: isDarkMode ? 'rgba(0, 180, 150, 0.9)' : 'rgba(0, 180, 150, 0.9)'
}
]}
pointerEvents="none"
>
<MaterialIcons name="check-circle" size={20} color="#FFFFFF" />
<Text style={styles.savedIndicatorText}>Settings Saved</Text>
</Animated.View>
{loading || isLoadingCustomNames ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
@ -153,13 +216,14 @@ const HeroCatalogsScreen: React.FC = () => {
style={[styles.saveButton, { backgroundColor: colors.primary }]}
onPress={handleSave}
>
<MaterialIcons name="save" size={16} color={colors.white} style={styles.saveIcon} />
<Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity>
</View>
<View style={styles.infoCard}>
<Text style={[styles.infoText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
Select which catalogs to display in the hero section. If none are selected, all catalogs will be used.
Select which catalogs to display in the hero section. If none are selected, all catalogs will be used. Don't forget to press Save when you're done.
</Text>
</View>
@ -256,6 +320,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 12,
justifyContent: 'space-between',
alignItems: 'center',
},
actionButton: {
paddingHorizontal: 12,
@ -271,12 +336,25 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
backgroundColor: colors.primary,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
minWidth: 100,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 1.5,
},
saveButtonText: {
color: colors.white,
fontSize: 14,
fontWeight: '600',
},
saveIcon: {
marginRight: 6,
},
infoCard: {
marginHorizontal: 16,
marginBottom: 16,
@ -320,6 +398,28 @@ const styles = StyleSheet.create({
fontSize: 14,
marginTop: 2,
},
savedIndicator: {
position: 'absolute',
top: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 60 : 90,
alignSelf: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
elevation: 5,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
},
savedIndicatorText: {
color: '#FFFFFF',
marginLeft: 6,
fontWeight: '600',
},
});
export default HeroCatalogsScreen;

View file

@ -55,6 +55,10 @@ import { storageService } from '../services/storageService';
import { useHomeCatalogs } from '../hooks/useHomeCatalogs';
import { useFeaturedContent } from '../hooks/useFeaturedContent';
import { useSettings, settingsEmitter } from '../hooks/useSettings';
import FeaturedContent from '../components/home/FeaturedContent';
import CatalogSection from '../components/home/CatalogSection';
import { SkeletonFeatured } from '../components/home/SkeletonLoaders';
import homeStyles from '../styles/homeStyles';
// Define interfaces for our data
interface Category {
@ -348,13 +352,6 @@ const SkeletonCatalog = () => (
</View>
);
const SkeletonFeatured = () => (
<View style={styles.featuredLoadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading featured content...</Text>
</View>
);
const HomeScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = useColorScheme() === 'dark';
@ -390,35 +387,40 @@ const HomeScreen = () => {
setFeaturedContentSource(settings.featuredContentSource);
}, [settings]);
// If featured content source changes, refresh featured content with debouncing
// Subscribe directly to settings emitter for immediate updates
useEffect(() => {
if (showHeroSection) {
// Clear any existing timeout
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
const handleSettingsChange = () => {
setShowHeroSection(settings.showHeroSection);
setFeaturedContentSource(settings.featuredContentSource);
// Set a new timeout to debounce the refresh
refreshTimeoutRef.current = setTimeout(() => {
refreshFeatured();
refreshTimeoutRef.current = null;
}, 300);
// The featured content refresh is now handled by the useFeaturedContent hook
// No need to call refreshFeatured() here to avoid duplicate refreshes
};
// Subscribe to settings changes
const unsubscribe = settingsEmitter.addListener(handleSettingsChange);
return unsubscribe;
}, [settings]);
// Update the featured content refresh logic to handle persistence
useEffect(() => {
// This effect was causing duplicate refreshes - it's now handled in useFeaturedContent
// We'll keep it just to sync the local state with settings
if (showHeroSection && featuredContentSource !== settings.featuredContentSource) {
// Just update the local state
setFeaturedContentSource(settings.featuredContentSource);
}
// Cleanup the timeout on unmount
return () => {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
};
}, [featuredContentSource, showHeroSection, refreshFeatured]);
// No timeout needed since we're not refreshing here
}, [settings.featuredContentSource, showHeroSection]);
useFocusEffect(
useCallback(() => {
const statusBarConfig = () => {
StatusBar.setBarStyle("light-content");
StatusBar.setTranslucent(true);
StatusBar.setBackgroundColor('transparent');
StatusBar.setTranslucent(true);
StatusBar.setBackgroundColor('transparent');
};
statusBarConfig();
@ -476,7 +478,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 +529,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 +562,19 @@ const HomeScreen = () => {
/>
}
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: Platform.OS === 'ios' ? 0 : 0 }
homeStyles.scrollContent,
{ paddingTop: Platform.OS === 'ios' ? 39 : 90 }
]}
showsVerticalScrollIndicator={false}
>
{showHeroSection && renderFeaturedContent()}
{showHeroSection && (
<FeaturedContent
key={`featured-${showHeroSection}`}
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
/>
)}
<Animated.View entering={FadeIn.duration(400).delay(150)}>
<ThisWeekSection />
@ -753,22 +589,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>
)

View file

@ -249,8 +249,9 @@ const HomeScreenSettings: React.FC = () => {
icon="settings-input-component"
isDarkMode={isDarkMode}
renderControl={() => <View />}
isLast={!settings.showHeroSection || settings.featuredContentSource !== 'catalogs'}
/>
{settings.featuredContentSource === 'catalogs' && (
{settings.showHeroSection && settings.featuredContentSource === 'catalogs' && (
<SettingItem
title="Select Catalogs"
description={getSelectedCatalogsText()}
@ -261,9 +262,6 @@ const HomeScreenSettings: React.FC = () => {
isLast={true}
/>
)}
{settings.featuredContentSource !== 'catalogs' && (
<View style={{ height: 0 }} /> // Placeholder to maintain layout
)}
</SettingsCard>
{settings.showHeroSection && (
@ -271,7 +269,10 @@ const HomeScreenSettings: React.FC = () => {
<View style={styles.radioCardContainer}>
<RadioOption
selected={settings.featuredContentSource === 'tmdb'}
onPress={() => handleUpdateSetting('featuredContentSource', 'tmdb')}
onPress={() => {
console.log('Selected TMDB source');
handleUpdateSetting('featuredContentSource', 'tmdb');
}}
label="TMDB Trending Movies"
/>
<View style={styles.radioDescription}>
@ -284,7 +285,10 @@ const HomeScreenSettings: React.FC = () => {
<View style={styles.radioCardContainer}>
<RadioOption
selected={settings.featuredContentSource === 'catalogs'}
onPress={() => handleUpdateSetting('featuredContentSource', 'catalogs')}
onPress={() => {
console.log('Selected Catalogs source');
handleUpdateSetting('featuredContentSource', 'catalogs');
}}
label="Installed Catalogs"
/>
<View style={styles.radioDescription}>

View file

@ -147,12 +147,14 @@ class CatalogService {
async getHomeCatalogs(): Promise<CatalogContent[]> {
const addons = await this.getAllAddons();
const catalogs: CatalogContent[] = [];
// Load enabled/disabled settings
const catalogSettingsJson = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY);
const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
// Create an array of promises for all catalog fetches
const catalogPromises: Promise<CatalogContent | null>[] = [];
// Process addons in order (they're already returned in order from getAllAddons)
for (const addon of addons) {
if (addon.catalogs) {
@ -161,54 +163,65 @@ class CatalogService {
const isEnabled = catalogSettings[settingKey] ?? true;
if (isEnabled) {
try {
const addonManifest = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifest.find(a => a.id === addon.id);
if (!manifest) continue;
// Create a promise for each catalog fetch
const catalogPromise = (async () => {
try {
const addonManifest = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifest.find(a => a.id === addon.id);
if (!manifest) return null;
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
if (metas && metas.length > 0) {
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
// Get potentially custom display name
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
// Remove duplicate words and clean up the name (case-insensitive)
const words = displayName.split(' ');
const uniqueWords = [];
const seenWords = new Set();
for (const word of words) {
const lowerWord = word.toLowerCase();
if (!seenWords.has(lowerWord)) {
uniqueWords.push(word);
seenWords.add(lowerWord);
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
if (metas && metas.length > 0) {
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
// Get potentially custom display name
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
// Remove duplicate words and clean up the name (case-insensitive)
const words = displayName.split(' ');
const uniqueWords = [];
const seenWords = new Set();
for (const word of words) {
const lowerWord = word.toLowerCase();
if (!seenWords.has(lowerWord)) {
uniqueWords.push(word);
seenWords.add(lowerWord);
}
}
displayName = uniqueWords.join(' ');
// Add content type if not present
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
displayName = `${displayName} ${contentType}`;
}
return {
addon: addon.id,
type: catalog.type,
id: catalog.id,
name: displayName,
items
};
}
displayName = uniqueWords.join(' ');
// Add content type if not present
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
displayName = `${displayName} ${contentType}`;
}
catalogs.push({
addon: addon.id,
type: catalog.type,
id: catalog.id,
name: displayName,
items
});
return null;
} catch (error) {
logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error);
return null;
}
} catch (error) {
logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error);
}
})();
catalogPromises.push(catalogPromise);
}
}
}
}
return catalogs;
// Wait for all catalog fetch promises to resolve in parallel
const catalogResults = await Promise.all(catalogPromises);
// Filter out null results
return catalogResults.filter(catalog => catalog !== null) as CatalogContent[];
}
async getCatalogByType(type: string, genreFilter?: string): Promise<CatalogContent[]> {
@ -222,46 +235,58 @@ class CatalogService {
// Otherwise use the original Stremio addons method
const addons = await this.getAllAddons();
const catalogs: CatalogContent[] = [];
const typeAddons = addons.filter(addon =>
addon.catalogs && addon.catalogs.some(catalog => catalog.type === type)
);
// Create an array of promises for all catalog fetches
const catalogPromises: Promise<CatalogContent | null>[] = [];
for (const addon of typeAddons) {
const typeCatalogs = addon.catalogs.filter(catalog => catalog.type === type);
for (const catalog of typeCatalogs) {
try {
const addonManifest = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifest.find(a => a.id === addon.id);
if (!manifest) continue;
const catalogPromise = (async () => {
try {
const addonManifest = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifest.find(a => a.id === addon.id);
if (!manifest) return null;
const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (metas && metas.length > 0) {
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
// Get potentially custom display name
const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
catalogs.push({
addon: addon.id,
type,
id: catalog.id,
name: displayName,
genre: genreFilter,
items
});
if (metas && metas.length > 0) {
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
// Get potentially custom display name
const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
return {
addon: addon.id,
type,
id: catalog.id,
name: displayName,
genre: genreFilter,
items
};
}
return null;
} catch (error) {
logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error);
return null;
}
} catch (error) {
logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error);
}
})();
catalogPromises.push(catalogPromise);
}
}
return catalogs;
// Wait for all catalog fetch promises to resolve in parallel
const catalogResults = await Promise.all(catalogPromises);
// Filter out null results
return catalogResults.filter(catalog => catalog !== null) as CatalogContent[];
}
/**
@ -277,64 +302,75 @@ class CatalogService {
// If no genre filter or All is selected, get multiple catalogs
if (!genreFilter || genreFilter === 'All') {
// Get trending
const trendingItems = await tmdbService.getTrending(tmdbType, 'week');
const trendingItemsPromises = trendingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const trendingStreamingItems = await Promise.all(trendingItemsPromises);
// Create an array of promises for all catalog fetches
const catalogFetchPromises = [
// Trending catalog
(async () => {
const trendingItems = await tmdbService.getTrending(tmdbType, 'week');
const trendingItemsPromises = trendingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const trendingStreamingItems = await Promise.all(trendingItemsPromises);
return {
addon: 'tmdb',
type,
id: 'trending',
name: `Trending ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
items: trendingStreamingItems
};
})(),
// Popular catalog
(async () => {
const popularItems = await tmdbService.getPopular(tmdbType, 1);
const popularItemsPromises = popularItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const popularStreamingItems = await Promise.all(popularItemsPromises);
return {
addon: 'tmdb',
type,
id: 'popular',
name: `Popular ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
items: popularStreamingItems
};
})(),
// Upcoming/on air catalog
(async () => {
const upcomingItems = await tmdbService.getUpcoming(tmdbType, 1);
const upcomingItemsPromises = upcomingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const upcomingStreamingItems = await Promise.all(upcomingItemsPromises);
return {
addon: 'tmdb',
type,
id: 'upcoming',
name: type === 'movie' ? 'Upcoming Movies' : 'On Air TV Shows',
items: upcomingStreamingItems
};
})()
];
catalogs.push({
addon: 'tmdb',
type,
id: 'trending',
name: `Trending ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
items: trendingStreamingItems
});
// Get popular
const popularItems = await tmdbService.getPopular(tmdbType, 1);
const popularItemsPromises = popularItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const popularStreamingItems = await Promise.all(popularItemsPromises);
catalogs.push({
addon: 'tmdb',
type,
id: 'popular',
name: `Popular ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
items: popularStreamingItems
});
// Get upcoming/on air
const upcomingItems = await tmdbService.getUpcoming(tmdbType, 1);
const upcomingItemsPromises = upcomingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const upcomingStreamingItems = await Promise.all(upcomingItemsPromises);
catalogs.push({
addon: 'tmdb',
type,
id: 'upcoming',
name: type === 'movie' ? 'Upcoming Movies' : 'On Air TV Shows',
items: upcomingStreamingItems
});
// Wait for all catalog fetches to complete in parallel
return await Promise.all(catalogFetchPromises);
} else {
// Get content by genre
const genreItems = await tmdbService.discoverByGenre(tmdbType, genreFilter);
const streamingItemsPromises = genreItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const streamingItems = await Promise.all(streamingItemsPromises);
catalogs.push({
return [{
addon: 'tmdb',
type,
id: 'discover',
name: `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
genre: genreFilter,
items: streamingItems
});
}];
}
} catch (error) {
logger.error(`Failed to get catalog from TMDB for type ${type}, genre ${genreFilter}:`, error);
return [];
}
return catalogs;
}
/**

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

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