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,
ViewStyle,
TextStyle,
ImageStyle
ImageStyle,
ActivityIndicator,
Platform
} from 'react-native';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
@ -40,17 +42,15 @@ 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 [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(0);
const contentOpacity = useSharedValue(1); // Start visible
const buttonsOpacity = useSharedValue(1);
const posterAnimatedStyle = useAnimatedStyle(() => ({
opacity: posterOpacity.value,
@ -64,11 +64,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
opacity: contentOpacity.value,
}));
const buttonsAnimatedStyle = useAnimatedStyle(() => ({
opacity: buttonsOpacity.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 {
@ -81,7 +83,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
}
};
// Load poster first, then logo
// Load poster and logo
useEffect(() => {
if (!featuredContent) return;
@ -91,49 +93,33 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
// 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
// Set URLs immediately for instant display
if (posterUrl) setBannerUrl(posterUrl);
if (titleLogo) setLogoUrl(titleLogo);
// Load images in background
const loadImages = async () => {
// Step 1: Load poster
// 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
// 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)
@ -145,37 +131,6 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
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 />;
}
@ -193,10 +148,9 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
>
<Animated.View style={[styles.imageContainer, posterAnimatedStyle]}>
<ImageBackground
source={{ uri: bannerUrl || '' }}
source={{ uri: bannerUrl || featuredContent.poster }}
style={styles.featuredImage as ViewStyle}
resizeMode="cover"
imageStyle={{ opacity: imageError ? 0.5 : 1 }}
>
<LinearGradient
colors={[
@ -214,7 +168,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
{featuredContent.logo ? (
<Animated.View style={logoAnimatedStyle}>
<ExpoImage
source={{ uri: logoUrl }}
source={{ uri: logoUrl || featuredContent.logo }}
style={styles.featuredLogo as ImageStyle}
contentFit="contain"
cachePolicy="memory-disk"
@ -234,51 +188,52 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
</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>
</Animated.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>
</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>
@ -290,7 +245,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
const styles = StyleSheet.create({
featuredContainer: {
width: '100%',
height: height * 0.6,
height: height * 0.5,
marginTop: 0,
marginBottom: 8,
position: 'relative',
@ -330,7 +285,7 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'flex-end',
paddingHorizontal: 16,
paddingBottom: 20,
paddingBottom: 12,
},
featuredLogo: {
width: width * 0.7,
@ -353,7 +308,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
marginBottom: 8,
flexWrap: 'wrap',
gap: 4,
},
@ -376,8 +331,8 @@ const styles = StyleSheet.create({
justifyContent: 'space-evenly',
width: '100%',
flex: 1,
maxHeight: 65,
paddingTop: 16,
maxHeight: 60,
paddingTop: 0,
},
playButton: {
flexDirection: 'row',

View file

@ -176,6 +176,19 @@ export function useFeaturedContent() {
}
}, [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
useEffect(() => {
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 {
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

@ -387,6 +387,24 @@ const HomeScreen = () => {
setFeaturedContentSource(settings.featuredContentSource);
}, [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
useEffect(() => {
if (showHeroSection && featuredContentSource !== settings.featuredContentSource) {
@ -558,12 +576,13 @@ const HomeScreen = () => {
}
contentContainerStyle={[
homeStyles.scrollContent,
{ paddingTop: Platform.OS === 'ios' ? 0 : 0 }
{ paddingTop: Platform.OS === 'ios' ? 39 : 90 }
]}
showsVerticalScrollIndicator={false}
>
{showHeroSection && (
<FeaturedContent
key={`featured-${showHeroSection}`}
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}

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 && (