mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
minor ui changes
This commit is contained in:
parent
cabfedad23
commit
8c7563590b
6 changed files with 211 additions and 17 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit 6591e3bc12a64a191367829d3fa5eb3783085c95
|
||||
Subproject commit f56983d17df532f0342cf9bb9d11ae74f64637ff
|
||||
|
|
@ -38,6 +38,7 @@ interface FeaturedContentProps {
|
|||
featuredContent: StreamingContent | null;
|
||||
isSaved: boolean;
|
||||
handleSaveToLibrary: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// Cache to store preloaded images
|
||||
|
|
@ -122,7 +123,7 @@ const NoFeaturedContent = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => {
|
||||
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loading }: FeaturedContentProps) => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
|
||||
|
|
@ -184,8 +185,18 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
// Simplified validation to reduce CPU overhead
|
||||
if (!url || typeof url !== 'string') return false;
|
||||
|
||||
// Use our optimized cache service instead of direct prefetch
|
||||
await imageCacheService.getCachedImageUrl(url);
|
||||
// Add timeout guard to prevent hanging preloads
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
const t = setTimeout(() => {
|
||||
clearTimeout(t as any);
|
||||
reject(new Error('preload-timeout'));
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
imageCacheService.getCachedImageUrl(url),
|
||||
timeout,
|
||||
]);
|
||||
imageCache[url] = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -446,7 +457,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
}
|
||||
};
|
||||
|
||||
// Show skeleton while loading to avoid empty state flash and sluggish feel
|
||||
if (loading) {
|
||||
return <SkeletonFeatured />;
|
||||
}
|
||||
|
||||
if (!featuredContent) {
|
||||
// Suppress empty state while loading to avoid flash on startup/hydration
|
||||
return <NoFeaturedContent />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React from 'react';
|
||||
import { View, Text, ActivityIndicator, StyleSheet, Dimensions } from 'react-native';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Text, ActivityIndicator, StyleSheet, Dimensions, Animated, Easing } from 'react-native';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import type { Theme } from '../../contexts/ThemeContext';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
const { height } = Dimensions.get('window');
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
||||
export const SkeletonCatalog = () => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
|
@ -18,10 +20,74 @@ export const SkeletonCatalog = () => {
|
|||
|
||||
export const SkeletonFeatured = () => {
|
||||
const { currentTheme } = useTheme();
|
||||
const shimmerAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const loop = Animated.loop(
|
||||
Animated.timing(shimmerAnim, {
|
||||
toValue: 1,
|
||||
duration: 1400,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [shimmerAnim]);
|
||||
|
||||
const translateX = shimmerAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [-width, width],
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.featuredLoadingContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>Loading featured content...</Text>
|
||||
<View style={[styles.featuredSkeletonContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
{/* Shimmer overlay */}
|
||||
<Animated.View style={[styles.shimmerOverlay, { transform: [{ translateX }] }]}>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(255,255,255,0)',
|
||||
'rgba(255,255,255,0.08)',
|
||||
'rgba(255,255,255,0)'
|
||||
]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Bottom gradient to mimic hero */}
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(0,0,0,0.10)',
|
||||
'rgba(0,0,0,0.20)',
|
||||
'rgba(0,0,0,0.40)',
|
||||
'rgba(0,0,0,0.80)',
|
||||
currentTheme.colors.darkBackground,
|
||||
]}
|
||||
locations={[0, 0.2, 0.5, 0.8, 1]}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
|
||||
{/* Placeholder content near bottom like hero */}
|
||||
<View style={styles.featuredSkeletonContent}>
|
||||
{/* Logo/title bar */}
|
||||
<View style={[styles.logoBar, { backgroundColor: currentTheme.colors.elevation2 }]} />
|
||||
|
||||
{/* Genre dots */}
|
||||
<View style={styles.genreRow}>
|
||||
<View style={[styles.genreDot, { backgroundColor: currentTheme.colors.elevation2 }]} />
|
||||
<View style={[styles.genreDot, { backgroundColor: currentTheme.colors.elevation2 }]} />
|
||||
<View style={[styles.genreDot, { backgroundColor: currentTheme.colors.elevation2 }]} />
|
||||
</View>
|
||||
|
||||
{/* Buttons row */}
|
||||
<View style={styles.buttonsRow}>
|
||||
<View style={[styles.circleBtn, { backgroundColor: currentTheme.colors.elevation2 }]} />
|
||||
<View style={[styles.primaryBtn, { backgroundColor: currentTheme.colors.white }]} />
|
||||
<View style={[styles.circleBtn, { backgroundColor: currentTheme.colors.elevation2 }]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
@ -39,11 +105,69 @@ const styles = StyleSheet.create({
|
|||
borderRadius: 12,
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
featuredLoadingContainer: {
|
||||
height: height * 0.4,
|
||||
justifyContent: 'center',
|
||||
featuredSkeletonContainer: {
|
||||
width: '100%',
|
||||
height: isTablet ? height * 0.7 : height * 0.55,
|
||||
marginTop: 0,
|
||||
marginBottom: 12,
|
||||
position: 'relative',
|
||||
borderRadius: isTablet ? 16 : 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
shimmerOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
featuredSkeletonContent: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingHorizontal: isTablet ? 40 : 20,
|
||||
paddingBottom: isTablet ? 50 : 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
logoBar: {
|
||||
width: isTablet ? width * 0.32 : width * 0.8,
|
||||
height: isTablet ? 28 : 22,
|
||||
borderRadius: 6,
|
||||
marginBottom: isTablet ? 20 : 12,
|
||||
},
|
||||
genreRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginBottom: isTablet ? 28 : 12,
|
||||
},
|
||||
genreDot: {
|
||||
width: 50,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
opacity: 0.8,
|
||||
},
|
||||
buttonsRow: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-evenly',
|
||||
alignItems: 'center',
|
||||
minHeight: isTablet ? 70 : 60,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
circleBtn: {
|
||||
width: isTablet ? 48 : 44,
|
||||
height: isTablet ? 48 : 44,
|
||||
borderRadius: 100,
|
||||
opacity: 0.9,
|
||||
},
|
||||
primaryBtn: {
|
||||
width: isTablet ? 180 : 140,
|
||||
height: isTablet ? 48 : 44,
|
||||
borderRadius: 30,
|
||||
opacity: 0.95,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { StreamingContent, catalogService } from '../services/catalogService';
|
||||
import { tmdbService } from '../services/tmdbService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
|
@ -22,6 +23,7 @@ const persistentStore = {
|
|||
|
||||
// Cache timeout in milliseconds (e.g., 5 minutes)
|
||||
const CACHE_TIMEOUT = 5 * 60 * 1000;
|
||||
const STORAGE_KEY = 'featured_content_cache_v1';
|
||||
|
||||
export function useFeaturedContent() {
|
||||
const [featuredContent, setFeaturedContent] = useState<StreamingContent | null>(persistentStore.featuredContent);
|
||||
|
|
@ -167,6 +169,22 @@ export function useFeaturedContent() {
|
|||
|
||||
if (signal.aborted) return;
|
||||
|
||||
// Safety guard: if nothing came back within a reasonable time, stop loading
|
||||
if (!formattedContent || formattedContent.length === 0) {
|
||||
// Fall back to any cached featured item so UI can render something
|
||||
const cachedJson = await AsyncStorage.getItem(STORAGE_KEY).catch(() => null);
|
||||
if (cachedJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(cachedJson);
|
||||
if (parsed?.featuredContent) {
|
||||
formattedContent = Array.isArray(parsed.allFeaturedContent) && parsed.allFeaturedContent.length > 0
|
||||
? parsed.allFeaturedContent
|
||||
: [parsed.featuredContent];
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Update persistent store with the new data
|
||||
persistentStore.allFeaturedContent = formattedContent;
|
||||
persistentStore.lastFetchTime = now;
|
||||
|
|
@ -178,9 +196,22 @@ export function useFeaturedContent() {
|
|||
persistentStore.featuredContent = formattedContent[0];
|
||||
setFeaturedContent(formattedContent[0]);
|
||||
currentIndexRef.current = 0;
|
||||
// Persist cache for fast startup
|
||||
try {
|
||||
await AsyncStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
ts: now,
|
||||
featuredContent: formattedContent[0],
|
||||
allFeaturedContent: formattedContent,
|
||||
})
|
||||
);
|
||||
} catch {}
|
||||
} else {
|
||||
persistentStore.featuredContent = null;
|
||||
setFeaturedContent(null);
|
||||
// Clear persisted cache on empty
|
||||
try { await AsyncStorage.removeItem(STORAGE_KEY); } catch {}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
|
|
@ -197,6 +228,29 @@ export function useFeaturedContent() {
|
|||
}
|
||||
}, [cleanup, genreMap, loadingGenres, contentSource, selectedCatalogs]);
|
||||
|
||||
// Hydrate from persisted cache immediately for instant render
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const json = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (!json) return;
|
||||
const parsed = JSON.parse(json);
|
||||
if (cancelled) return;
|
||||
if (parsed?.featuredContent) {
|
||||
persistentStore.featuredContent = parsed.featuredContent;
|
||||
persistentStore.allFeaturedContent = Array.isArray(parsed.allFeaturedContent) ? parsed.allFeaturedContent : [];
|
||||
persistentStore.lastFetchTime = typeof parsed.ts === 'number' ? parsed.ts : Date.now();
|
||||
persistentStore.isFirstLoad = false;
|
||||
setFeaturedContent(parsed.featuredContent);
|
||||
setAllFeaturedContent(persistentStore.allFeaturedContent);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Check for settings changes, including during app restart
|
||||
useEffect(() => {
|
||||
// Check if settings changed while app was closed
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { colors } from '../styles/colors';
|
||||
import { NuvioHeader } from '../components/NuvioHeader';
|
||||
import { Stream } from '../types/streams';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
|
@ -587,8 +586,7 @@ const MainTabs = () => {
|
|||
],
|
||||
},
|
||||
}),
|
||||
header: () => (route.name === 'Home' ? <NuvioHeader /> : null),
|
||||
headerShown: route.name === 'Home',
|
||||
headerShown: false,
|
||||
tabBarShowLabel: false,
|
||||
tabBarStyle: {
|
||||
position: 'absolute',
|
||||
|
|
|
|||
|
|
@ -603,6 +603,7 @@ const HomeScreen = () => {
|
|||
featuredContent={featuredContent}
|
||||
isSaved={isSaved}
|
||||
handleSaveToLibrary={handleSaveToLibrary}
|
||||
loading={featuredLoading}
|
||||
/>
|
||||
), [showHeroSection, featuredContentSource, featuredContent, isSaved, handleSaveToLibrary]);
|
||||
|
||||
|
|
@ -714,7 +715,7 @@ const HomeScreen = () => {
|
|||
keyExtractor={item => item.key}
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingTop: Platform.OS === 'ios' ? 100 : 90 }
|
||||
{ paddingTop: insets.top }
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListHeaderComponent={showHeroSection ? memoizedFeaturedContent : null}
|
||||
|
|
|
|||
Loading…
Reference in a new issue