mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-20 16:22:04 +00:00
some UI optimization
This commit is contained in:
parent
57036aaffb
commit
b43957e6f9
5 changed files with 86 additions and 96 deletions
48
App.tsx
48
App.tsx
|
|
@ -144,37 +144,29 @@ const ThemedApp = () => {
|
||||||
const shouldShowApp = isAppReady && hasCompletedOnboarding !== null;
|
const shouldShowApp = isAppReady && hasCompletedOnboarding !== null;
|
||||||
const initialRouteName = hasCompletedOnboarding ? 'MainTabs' : 'Onboarding';
|
const initialRouteName = hasCompletedOnboarding ? 'MainTabs' : 'Onboarding';
|
||||||
|
|
||||||
const NavigationWithRef = () => {
|
|
||||||
const { navigationRef } = useAccount() as any;
|
|
||||||
return (
|
|
||||||
<NavigationContainer
|
|
||||||
ref={navigationRef as any}
|
|
||||||
theme={customNavigationTheme}
|
|
||||||
linking={undefined}
|
|
||||||
>
|
|
||||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
|
||||||
<StatusBar style="light" />
|
|
||||||
{!isAppReady && <SplashScreen onFinish={handleSplashComplete} />}
|
|
||||||
{shouldShowApp && <AppNavigator initialRouteName={initialRouteName} />}
|
|
||||||
{Platform.OS === 'ios' && (
|
|
||||||
<UpdatePopup
|
|
||||||
visible={showUpdatePopup}
|
|
||||||
updateInfo={updateInfo}
|
|
||||||
onUpdateNow={handleUpdateNow}
|
|
||||||
onUpdateLater={handleUpdateLater}
|
|
||||||
onDismiss={handleDismiss}
|
|
||||||
isInstalling={isInstalling}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</NavigationContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccountProvider>
|
<AccountProvider>
|
||||||
<PaperProvider theme={customDarkTheme}>
|
<PaperProvider theme={customDarkTheme}>
|
||||||
<NavigationWithRef />
|
<NavigationContainer
|
||||||
|
theme={customNavigationTheme}
|
||||||
|
linking={undefined}
|
||||||
|
>
|
||||||
|
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
{!isAppReady && <SplashScreen onFinish={handleSplashComplete} />}
|
||||||
|
{shouldShowApp && <AppNavigator initialRouteName={initialRouteName} />}
|
||||||
|
{Platform.OS === 'ios' && (
|
||||||
|
<UpdatePopup
|
||||||
|
visible={showUpdatePopup}
|
||||||
|
updateInfo={updateInfo}
|
||||||
|
onUpdateNow={handleUpdateNow}
|
||||||
|
onUpdateLater={handleUpdateLater}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
isInstalling={isInstalling}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</NavigationContainer>
|
||||||
</PaperProvider>
|
</PaperProvider>
|
||||||
</AccountProvider>
|
</AccountProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -56,49 +56,32 @@ const POSTER_WIDTH = posterLayout.posterWidth;
|
||||||
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
// Simplified visibility tracking to reduce state updates and re-renders
|
// Simplified visibility tracking - just load all images immediately for better performance
|
||||||
const [visibleIndexSet, setVisibleIndexSet] = useState<Set<number>>(new Set([0, 1, 2, 3, 4, 5, 6, 7]));
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
const viewabilityConfig = useMemo(() => ({
|
|
||||||
itemVisiblePercentThreshold: 15,
|
|
||||||
minimumViewTime: 100,
|
|
||||||
}), []);
|
|
||||||
|
|
||||||
const onViewableItemsChanged = useRef(({ viewableItems }: { viewableItems: Array<{ index?: number | null }> }) => {
|
// Load all images after a short delay to prevent blocking initial render
|
||||||
const next = new Set<number>();
|
React.useEffect(() => {
|
||||||
viewableItems.forEach(v => { if (typeof v.index === 'number') next.add(v.index); });
|
const timer = setTimeout(() => {
|
||||||
// Only pre-warm immediate neighbors to reduce overhead
|
setHasLoaded(true);
|
||||||
const neighbors: number[] = [];
|
}, 100);
|
||||||
next.forEach(i => {
|
return () => clearTimeout(timer);
|
||||||
neighbors.push(i - 1, i + 1);
|
}, []);
|
||||||
});
|
|
||||||
neighbors.forEach(i => { if (i >= 0) next.add(i); });
|
|
||||||
setVisibleIndexSet(next);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [minVisible, maxVisible] = useMemo(() => {
|
|
||||||
if (visibleIndexSet.size === 0) return [0, 7];
|
|
||||||
let min = Number.POSITIVE_INFINITY;
|
|
||||||
let max = 0;
|
|
||||||
visibleIndexSet.forEach(i => { if (i < min) min = i; if (i > max) max = i; });
|
|
||||||
return [min, max];
|
|
||||||
}, [visibleIndexSet]);
|
|
||||||
|
|
||||||
const handleContentPress = useCallback((id: string, type: string) => {
|
const handleContentPress = useCallback((id: string, type: string) => {
|
||||||
navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
|
navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
|
||||||
}, [navigation, catalog.addon]);
|
}, [navigation, catalog.addon]);
|
||||||
|
|
||||||
const renderContentItem = useCallback(({ item, index }: { item: StreamingContent, index: number }) => {
|
const renderContentItem = useCallback(({ item, index }: { item: StreamingContent, index: number }) => {
|
||||||
// Simplify visibility logic to reduce re-renders
|
// Load images immediately for better scrolling performance
|
||||||
const isVisible = visibleIndexSet.has(index) || index < 8;
|
|
||||||
return (
|
return (
|
||||||
<ContentItem
|
<ContentItem
|
||||||
item={item}
|
item={item}
|
||||||
onPress={handleContentPress}
|
onPress={handleContentPress}
|
||||||
shouldLoadImage={isVisible}
|
shouldLoadImage={hasLoaded}
|
||||||
deferMs={0}
|
deferMs={index * 10} // Small stagger to prevent blocking
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [handleContentPress, visibleIndexSet]);
|
}, [handleContentPress, hasLoaded]);
|
||||||
|
|
||||||
// Memoize the ItemSeparatorComponent to prevent re-creation
|
// Memoize the ItemSeparatorComponent to prevent re-creation
|
||||||
const ItemSeparator = useCallback(() => <View style={{ width: 8 }} />, []);
|
const ItemSeparator = useCallback(() => <View style={{ width: 8 }} />, []);
|
||||||
|
|
@ -139,8 +122,6 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
||||||
onEndReachedThreshold={0.7}
|
onEndReachedThreshold={0.7}
|
||||||
onEndReached={() => {}}
|
onEndReached={() => {}}
|
||||||
scrollEventThrottle={64}
|
scrollEventThrottle={64}
|
||||||
viewabilityConfig={viewabilityConfig as any}
|
|
||||||
onViewableItemsChanged={onViewableItemsChanged.current as any}
|
|
||||||
removeClippedSubviews={true}
|
removeClippedSubviews={true}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,6 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
||||||
const [isWatched, setIsWatched] = useState(false);
|
const [isWatched, setIsWatched] = useState(false);
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const [shouldLoadImageState, setShouldLoadImageState] = useState(false);
|
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const { settings, isLoaded } = useSettings();
|
const { settings, isLoaded } = useSettings();
|
||||||
|
|
@ -126,22 +125,6 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
||||||
setMenuVisible(false);
|
setMenuVisible(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Lazy load images - only load when asked by parent (viewability) or after small defer
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldLoadImageProp !== undefined) {
|
|
||||||
if (shouldLoadImageProp) {
|
|
||||||
const t = setTimeout(() => setShouldLoadImageState(true), deferMs);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
} else {
|
|
||||||
setShouldLoadImageState(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setShouldLoadImageState(true);
|
|
||||||
}, 80);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [shouldLoadImageProp, deferMs]);
|
|
||||||
|
|
||||||
// Memoize optimized poster URL to prevent recalculating
|
// Memoize optimized poster URL to prevent recalculating
|
||||||
const optimizedPosterUrl = React.useMemo(() => {
|
const optimizedPosterUrl = React.useMemo(() => {
|
||||||
|
|
@ -213,16 +196,16 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
||||||
delayLongPress={300}
|
delayLongPress={300}
|
||||||
>
|
>
|
||||||
<View ref={itemRef} style={[styles.contentItemContainer, { borderRadius: posterRadius }] }>
|
<View ref={itemRef} style={[styles.contentItemContainer, { borderRadius: posterRadius }] }>
|
||||||
{/* Only load image when shouldLoadImage is true (lazy loading) */}
|
{/* Always load image for horizontal scrolling to prevent blank posters */}
|
||||||
{(shouldLoadImageProp ?? shouldLoadImageState) && item.poster ? (
|
{item.poster ? (
|
||||||
<ExpoImage
|
<ExpoImage
|
||||||
source={{ uri: optimizedPosterUrl }}
|
source={{ uri: optimizedPosterUrl }}
|
||||||
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius: posterRadius }]}
|
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius: posterRadius }]}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
cachePolicy={Platform.OS === 'android' ? 'disk' : 'memory-disk'}
|
cachePolicy={Platform.OS === 'android' ? 'disk' : 'memory-disk'}
|
||||||
transition={140}
|
transition={100} // Faster transition for scrolling
|
||||||
allowDownscaling
|
allowDownscaling
|
||||||
priority="low" // Deprioritize decode for long lists
|
priority="normal" // Normal priority for horizontal scrolling
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
setImageLoaded(true);
|
setImageLoaded(true);
|
||||||
setImageError(false);
|
setImageError(false);
|
||||||
|
|
@ -241,7 +224,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
||||||
recyclingKey={item.id} // Add recycling key for better performance
|
recyclingKey={item.id} // Add recycling key for better performance
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Show placeholder until lazy load triggers
|
// Show placeholder for items without posters
|
||||||
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center', borderRadius: posterRadius }] }>
|
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center', borderRadius: posterRadius }] }>
|
||||||
<Text style={{ color: currentTheme.colors.textMuted, fontSize: 10, textAlign: 'center' }}>
|
<Text style={{ color: currentTheme.colors.textMuted, fontSize: 10, textAlign: 'center' }}>
|
||||||
{item.name.substring(0, 20)}...
|
{item.name.substring(0, 20)}...
|
||||||
|
|
@ -349,9 +332,8 @@ const styles = StyleSheet.create({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default React.memo(ContentItem, (prev, next) => {
|
export default React.memo(ContentItem, (prev, next) => {
|
||||||
// Re-render when identity changes or when visibility-driven loading flips
|
// Re-render when identity changes or poster changes
|
||||||
if (prev.item.id !== next.item.id) return false;
|
if (prev.item.id !== next.item.id) return false;
|
||||||
if (prev.item.poster !== next.item.poster) return false;
|
if (prev.item.poster !== next.item.poster) return false;
|
||||||
if ((prev.shouldLoadImage ?? false) !== (next.shouldLoadImage ?? false)) return false;
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
@ -48,21 +48,35 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth state listener
|
// Auth state listener
|
||||||
const { data: subscription } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
const { data: subscription } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||||
setLoading(true);
|
// Only set loading for actual auth changes, not initial session
|
||||||
|
if (event !== 'INITIAL_SESSION') {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const fullUser = session?.user ? await accountService.getCurrentUser() : null;
|
const fullUser = session?.user ? await accountService.getCurrentUser() : null;
|
||||||
setUser(fullUser);
|
setUser(fullUser);
|
||||||
|
// Immediately clear loading so UI can transition to MainTabs/Auth
|
||||||
|
setLoading(false);
|
||||||
if (fullUser) {
|
if (fullUser) {
|
||||||
await syncService.migrateLocalScopeToUser();
|
// Run sync in background without blocking UI
|
||||||
await syncService.subscribeRealtime();
|
setTimeout(async () => {
|
||||||
// Pull first to hydrate local state, then push to avoid wiping server with empty local
|
try {
|
||||||
await syncService.fullPull();
|
await syncService.migrateLocalScopeToUser();
|
||||||
await syncService.fullPush();
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
await syncService.subscribeRealtime();
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
await syncService.fullPull();
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
await syncService.fullPush();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AccountContext] Background sync failed:', error);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
} else {
|
} else {
|
||||||
syncService.unsubscribeRealtime();
|
syncService.unsubscribeRealtime();
|
||||||
}
|
}
|
||||||
} finally {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -588,6 +588,20 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
return (availableWidth - totalSpacing) / effectiveNumColumns;
|
return (availableWidth - totalSpacing) / effectiveNumColumns;
|
||||||
}, [effectiveNumColumns, screenData.width, screenData.itemWidth]);
|
}, [effectiveNumColumns, screenData.width, screenData.itemWidth]);
|
||||||
|
|
||||||
|
// Helper function to optimize poster URLs
|
||||||
|
const optimizePosterUrl = useCallback((poster: string | undefined) => {
|
||||||
|
if (!poster || poster.includes('placeholder')) {
|
||||||
|
return 'https://via.placeholder.com/300x450/333333/666666?text=No+Image';
|
||||||
|
}
|
||||||
|
|
||||||
|
// For TMDB images, use smaller sizes for better performance
|
||||||
|
if (poster.includes('image.tmdb.org')) {
|
||||||
|
return poster.replace(/\/w\d+\//, '/w300/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return poster;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const renderItem = useCallback(({ item, index }: { item: Meta; index: number }) => {
|
const renderItem = useCallback(({ item, index }: { item: Meta; index: number }) => {
|
||||||
// Calculate if this is the last item in a row
|
// Calculate if this is the last item in a row
|
||||||
const isLastInRow = (index + 1) % effectiveNumColumns === 0;
|
const isLastInRow = (index + 1) % effectiveNumColumns === 0;
|
||||||
|
|
@ -607,12 +621,14 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: item.poster || 'https://via.placeholder.com/300x450/333333/666666?text=No+Image' }}
|
source={{ uri: optimizePosterUrl(item.poster) }}
|
||||||
style={styles.poster}
|
style={styles.poster}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
cachePolicy="disk"
|
cachePolicy={Platform.OS === 'android' ? 'memory-disk' : 'memory-disk'}
|
||||||
transition={0}
|
transition={100}
|
||||||
allowDownscaling
|
allowDownscaling
|
||||||
|
priority="normal"
|
||||||
|
recyclingKey={`${item.id}-${item.type}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{type === 'movie' && nowPlayingMovies.has(item.id) && (
|
{type === 'movie' && nowPlayingMovies.has(item.id) && (
|
||||||
|
|
@ -644,7 +660,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}, [navigation, styles, effectiveNumColumns, effectiveItemWidth, type, nowPlayingMovies]);
|
}, [navigation, styles, effectiveNumColumns, effectiveItemWidth, type, nowPlayingMovies, colors.white, optimizePosterUrl]);
|
||||||
|
|
||||||
const renderEmptyState = () => (
|
const renderEmptyState = () => (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
|
|
@ -754,6 +770,11 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
}
|
}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
maxToRenderPerBatch={effectiveNumColumns * 3}
|
||||||
|
windowSize={10}
|
||||||
|
initialNumToRender={effectiveNumColumns * 4}
|
||||||
|
getItemType={() => 'item'}
|
||||||
/>
|
/>
|
||||||
) : renderEmptyState()}
|
) : renderEmptyState()}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue