Enhance FeaturedContent and HeroCatalogsScreen components with improved loading animations and settings updates. Refactor image loading logic for better performance and add saved indicator in HeroCatalogsScreen. Update HomeScreen to handle settings changes dynamically and adjust layout for better user experience.

This commit is contained in:
tapframe 2025-05-03 15:05:46 +05:30
parent 3632c44718
commit 4f04ae874f
5 changed files with 214 additions and 129 deletions

View file

@ -8,7 +8,9 @@ import {
Dimensions, Dimensions,
ViewStyle, ViewStyle,
TextStyle, TextStyle,
ImageStyle ImageStyle,
ActivityIndicator,
Platform
} from 'react-native'; } from 'react-native';
import { NavigationProp, useNavigation } from '@react-navigation/native'; import { NavigationProp, useNavigation } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator'; import { RootStackParamList } from '../../navigation/AppNavigator';
@ -40,17 +42,15 @@ const { width, height } = Dimensions.get('window');
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => { const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); 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 [logoUrl, setLogoUrl] = useState<string | null>(null);
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
const prevContentIdRef = useRef<string | null>(null); const prevContentIdRef = useRef<string | null>(null);
// Animation values // Animation values
const posterOpacity = useSharedValue(0); const posterOpacity = useSharedValue(0);
const logoOpacity = useSharedValue(0); const logoOpacity = useSharedValue(0);
const contentOpacity = useSharedValue(0); const contentOpacity = useSharedValue(1); // Start visible
const buttonsOpacity = useSharedValue(1);
const posterAnimatedStyle = useAnimatedStyle(() => ({ const posterAnimatedStyle = useAnimatedStyle(() => ({
opacity: posterOpacity.value, opacity: posterOpacity.value,
@ -64,11 +64,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
opacity: contentOpacity.value, opacity: contentOpacity.value,
})); }));
const buttonsAnimatedStyle = useAnimatedStyle(() => ({
opacity: buttonsOpacity.value,
}));
// Preload the image // Preload the image
const preloadImage = async (url: string): Promise<boolean> => { const preloadImage = async (url: string): Promise<boolean> => {
if (!url) return false; if (!url) return false;
// If already cached, return true immediately
if (imageCache[url]) return true; if (imageCache[url]) return true;
try { try {
@ -81,7 +83,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
} }
}; };
// Load poster first, then logo // Load poster and logo
useEffect(() => { useEffect(() => {
if (!featuredContent) return; if (!featuredContent) return;
@ -91,49 +93,33 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
// Reset states for new content // Reset states for new content
if (contentId !== prevContentIdRef.current) { if (contentId !== prevContentIdRef.current) {
setPosterLoaded(false);
setLogoLoaded(false);
setImageError(false);
posterOpacity.value = 0; posterOpacity.value = 0;
logoOpacity.value = 0; logoOpacity.value = 0;
contentOpacity.value = 0;
} }
prevContentIdRef.current = contentId; prevContentIdRef.current = contentId;
// Sequential loading: poster first, then logo // Set URLs immediately for instant display
const loadImages = async () => { if (posterUrl) setBannerUrl(posterUrl);
// Step 1: Load poster if (titleLogo) setLogoUrl(titleLogo);
if (posterUrl) {
setBannerUrl(posterUrl);
const posterSuccess = await preloadImage(posterUrl);
// Load images in background
const loadImages = async () => {
// Load poster
if (posterUrl) {
const posterSuccess = await preloadImage(posterUrl);
if (posterSuccess) { if (posterSuccess) {
setPosterLoaded(true);
// Fade in poster
posterOpacity.value = withTiming(1, { posterOpacity.value = withTiming(1, {
duration: 600, duration: 600,
easing: Easing.bezier(0.25, 0.1, 0.25, 1) 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 // Load logo if available
if (titleLogo) { if (titleLogo) {
setLogoUrl(titleLogo);
const logoSuccess = await preloadImage(titleLogo); const logoSuccess = await preloadImage(titleLogo);
if (logoSuccess) { if (logoSuccess) {
setLogoLoaded(true);
// Fade in logo with delay after poster
logoOpacity.value = withDelay(300, withTiming(1, { logoOpacity.value = withDelay(300, withTiming(1, {
duration: 500, duration: 500,
easing: Easing.bezier(0.25, 0.1, 0.25, 1) easing: Easing.bezier(0.25, 0.1, 0.25, 1)
@ -145,37 +131,6 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
loadImages(); loadImages();
}, [featuredContent?.id]); }, [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) { if (!featuredContent) {
return <SkeletonFeatured />; return <SkeletonFeatured />;
} }
@ -193,10 +148,9 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
> >
<Animated.View style={[styles.imageContainer, posterAnimatedStyle]}> <Animated.View style={[styles.imageContainer, posterAnimatedStyle]}>
<ImageBackground <ImageBackground
source={{ uri: bannerUrl || '' }} source={{ uri: bannerUrl || featuredContent.poster }}
style={styles.featuredImage as ViewStyle} style={styles.featuredImage as ViewStyle}
resizeMode="cover" resizeMode="cover"
imageStyle={{ opacity: imageError ? 0.5 : 1 }}
> >
<LinearGradient <LinearGradient
colors={[ colors={[
@ -214,7 +168,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
{featuredContent.logo ? ( {featuredContent.logo ? (
<Animated.View style={logoAnimatedStyle}> <Animated.View style={logoAnimatedStyle}>
<ExpoImage <ExpoImage
source={{ uri: logoUrl }} source={{ uri: logoUrl || featuredContent.logo }}
style={styles.featuredLogo as ImageStyle} style={styles.featuredLogo as ImageStyle}
contentFit="contain" contentFit="contain"
cachePolicy="memory-disk" cachePolicy="memory-disk"
@ -234,51 +188,52 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
</React.Fragment> </React.Fragment>
))} ))}
</View> </View>
<View style={styles.featuredButtons as ViewStyle}> </Animated.View>
<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 <Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}>
style={styles.playButton as ViewStyle} <TouchableOpacity
onPress={() => { style={styles.myListButton as ViewStyle}
if (featuredContent) { onPress={handleSaveToLibrary}
navigation.navigate('Streams', { >
id: featuredContent.id, <MaterialIcons
type: featuredContent.type name={isSaved ? "bookmark" : "bookmark-border"}
}); size={24}
} color={colors.white}
}} />
> <Text style={styles.myListButtonText as TextStyle}>
<MaterialIcons name="play-arrow" size={24} color={colors.black} /> {isSaved ? "Saved" : "Save"}
<Text style={styles.playButtonText as TextStyle}>Play</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.infoButton as ViewStyle} style={styles.playButton as ViewStyle}
onPress={() => { onPress={() => {
if (featuredContent) { if (featuredContent) {
navigation.navigate('Metadata', { navigation.navigate('Streams', {
id: featuredContent.id, id: featuredContent.id,
type: featuredContent.type type: featuredContent.type
}); });
} }
}} }}
> >
<MaterialIcons name="info-outline" size={24} color={colors.white} /> <MaterialIcons name="play-arrow" size={24} color={colors.black} />
<Text style={styles.infoButtonText as TextStyle}>Info</Text> <Text style={styles.playButtonText as TextStyle}>Play</Text>
</TouchableOpacity> </TouchableOpacity>
</View>
<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> </Animated.View>
</LinearGradient> </LinearGradient>
</ImageBackground> </ImageBackground>
@ -290,7 +245,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
const styles = StyleSheet.create({ const styles = StyleSheet.create({
featuredContainer: { featuredContainer: {
width: '100%', width: '100%',
height: height * 0.6, height: height * 0.5,
marginTop: 0, marginTop: 0,
marginBottom: 8, marginBottom: 8,
position: 'relative', position: 'relative',
@ -330,7 +285,7 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
justifyContent: 'flex-end', justifyContent: 'flex-end',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 20, paddingBottom: 12,
}, },
featuredLogo: { featuredLogo: {
width: width * 0.7, width: width * 0.7,
@ -353,7 +308,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: 16, marginBottom: 8,
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 4, gap: 4,
}, },
@ -376,8 +331,8 @@ const styles = StyleSheet.create({
justifyContent: 'space-evenly', justifyContent: 'space-evenly',
width: '100%', width: '100%',
flex: 1, flex: 1,
maxHeight: 65, maxHeight: 60,
paddingTop: 16, paddingTop: 0,
}, },
playButton: { playButton: {
flexDirection: 'row', flexDirection: 'row',

View file

@ -176,6 +176,19 @@ export function useFeaturedContent() {
} }
}, [cleanup, genreMap, loadingGenres, contentSource, selectedCatalogs]); }, [cleanup, genreMap, loadingGenres, contentSource, selectedCatalogs]);
// Subscribe directly to settings emitter for immediate updates
useEffect(() => {
const handleSettingsChange = () => {
// Force refresh when settings change
loadFeaturedContent(true);
};
// Subscribe to settings changes
const unsubscribe = settingsEmitter.addListener(handleSettingsChange);
return unsubscribe;
}, [loadFeaturedContent]);
// Load featured content initially and when content source changes // Load featured content initially and when content source changes
useEffect(() => { useEffect(() => {
const shouldForceRefresh = contentSource === 'tmdb' && const shouldForceRefresh = contentSource === 'tmdb' &&

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState, useRef } from 'react';
import { import {
View, View,
Text, Text,
@ -12,9 +12,10 @@ import {
useColorScheme, useColorScheme,
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Animated
} from 'react-native'; } from 'react-native';
import { useSettings } from '../hooks/useSettings'; import { useSettings, settingsEmitter } from '../hooks/useSettings';
import { useNavigation } from '@react-navigation/native'; import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { colors } from '../styles/colors'; import { colors } from '../styles/colors';
import { catalogService, StreamingAddon } from '../services/catalogService'; import { catalogService, StreamingAddon } from '../services/catalogService';
@ -38,6 +39,58 @@ const HeroCatalogsScreen: React.FC = () => {
const [catalogs, setCatalogs] = useState<CatalogItem[]>([]); const [catalogs, setCatalogs] = useState<CatalogItem[]>([]);
const [selectedCatalogs, setSelectedCatalogs] = useState<string[]>(settings.selectedHeroCatalogs || []); const [selectedCatalogs, setSelectedCatalogs] = useState<string[]>(settings.selectedHeroCatalogs || []);
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames(); 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(() => { const handleBack = useCallback(() => {
navigation.goBack(); navigation.goBack();
@ -84,11 +137,6 @@ const HeroCatalogsScreen: React.FC = () => {
setSelectedCatalogs([]); setSelectedCatalogs([]);
}, []); }, []);
const handleSave = useCallback(() => {
updateSetting('selectedHeroCatalogs', selectedCatalogs);
navigation.goBack();
}, [navigation, selectedCatalogs, updateSetting]);
const toggleCatalog = useCallback((catalogId: string) => { const toggleCatalog = useCallback((catalogId: string) => {
setSelectedCatalogs(prev => { setSelectedCatalogs(prev => {
if (prev.includes(catalogId)) { if (prev.includes(catalogId)) {
@ -127,6 +175,21 @@ const HeroCatalogsScreen: React.FC = () => {
</Text> </Text>
</View> </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 ? ( {loading || isLoadingCustomNames ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
@ -153,13 +216,14 @@ const HeroCatalogsScreen: React.FC = () => {
style={[styles.saveButton, { backgroundColor: colors.primary }]} style={[styles.saveButton, { backgroundColor: colors.primary }]}
onPress={handleSave} onPress={handleSave}
> >
<MaterialIcons name="save" size={16} color={colors.white} style={styles.saveIcon} />
<Text style={styles.saveButtonText}>Save</Text> <Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.infoCard}> <View style={styles.infoCard}>
<Text style={[styles.infoText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}> <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> </Text>
</View> </View>
@ -256,6 +320,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center',
}, },
actionButton: { actionButton: {
paddingHorizontal: 12, paddingHorizontal: 12,
@ -271,12 +336,25 @@ const styles = StyleSheet.create({
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 8, paddingVertical: 8,
borderRadius: 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: { saveButtonText: {
color: colors.white, color: colors.white,
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
}, },
saveIcon: {
marginRight: 6,
},
infoCard: { infoCard: {
marginHorizontal: 16, marginHorizontal: 16,
marginBottom: 16, marginBottom: 16,
@ -320,6 +398,28 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
marginTop: 2, 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; export default HeroCatalogsScreen;

View file

@ -387,6 +387,24 @@ const HomeScreen = () => {
setFeaturedContentSource(settings.featuredContentSource); setFeaturedContentSource(settings.featuredContentSource);
}, [settings]); }, [settings]);
// Subscribe directly to settings emitter for immediate updates
useEffect(() => {
const handleSettingsChange = () => {
setShowHeroSection(settings.showHeroSection);
setFeaturedContentSource(settings.featuredContentSource);
// If hero section is enabled, force a refresh of featured content
if (settings.showHeroSection) {
refreshFeatured();
}
};
// Subscribe to settings changes
const unsubscribe = settingsEmitter.addListener(handleSettingsChange);
return unsubscribe;
}, [refreshFeatured, settings]);
// Update the featured content refresh logic to handle persistence // Update the featured content refresh logic to handle persistence
useEffect(() => { useEffect(() => {
if (showHeroSection && featuredContentSource !== settings.featuredContentSource) { if (showHeroSection && featuredContentSource !== settings.featuredContentSource) {
@ -558,12 +576,13 @@ const HomeScreen = () => {
} }
contentContainerStyle={[ contentContainerStyle={[
homeStyles.scrollContent, homeStyles.scrollContent,
{ paddingTop: Platform.OS === 'ios' ? 0 : 0 } { paddingTop: Platform.OS === 'ios' ? 39 : 90 }
]} ]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{showHeroSection && ( {showHeroSection && (
<FeaturedContent <FeaturedContent
key={`featured-${showHeroSection}`}
featuredContent={featuredContent} featuredContent={featuredContent}
isSaved={isSaved} isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary} handleSaveToLibrary={handleSaveToLibrary}

View file

@ -249,8 +249,9 @@ const HomeScreenSettings: React.FC = () => {
icon="settings-input-component" icon="settings-input-component"
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
renderControl={() => <View />} renderControl={() => <View />}
isLast={!settings.showHeroSection || settings.featuredContentSource !== 'catalogs'}
/> />
{settings.featuredContentSource === 'catalogs' && ( {settings.showHeroSection && settings.featuredContentSource === 'catalogs' && (
<SettingItem <SettingItem
title="Select Catalogs" title="Select Catalogs"
description={getSelectedCatalogsText()} description={getSelectedCatalogsText()}
@ -261,9 +262,6 @@ const HomeScreenSettings: React.FC = () => {
isLast={true} isLast={true}
/> />
)} )}
{settings.featuredContentSource !== 'catalogs' && (
<View style={{ height: 0 }} /> // Placeholder to maintain layout
)}
</SettingsCard> </SettingsCard>
{settings.showHeroSection && ( {settings.showHeroSection && (