potential heat fix

This commit is contained in:
tapframe 2025-09-10 21:38:43 +05:30
parent 097073fcd3
commit bc2a15f81f
12 changed files with 386 additions and 405 deletions

View file

@ -59,6 +59,14 @@ enableScreens(true);
// Inner app component that uses the theme context
const ThemedApp = () => {
// Log JS engine once at startup
useEffect(() => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const engine = (global as any).HermesInternal ? 'Hermes' : 'JSC';
console.log('JS Engine:', engine);
} catch {}
}, []);
const { currentTheme } = useTheme();
const [isAppReady, setIsAppReady] = useState(false);
const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState<boolean | null>(null);

View file

@ -16,7 +16,7 @@
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="30000"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|locale|layoutDirection" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified">
<intent-filter>

View file

@ -20,7 +20,7 @@ interface UpdatePopupProps {
updateInfo: {
isAvailable: boolean;
manifest?: {
id: string;
id?: string;
version?: string;
description?: string;
};

View file

@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions } from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { NavigationProp, useNavigation } from '@react-navigation/native';
@ -56,23 +56,49 @@ const POSTER_WIDTH = posterLayout.posterWidth;
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
// Simplified visibility tracking to reduce state updates and re-renders
const [visibleIndexSet, setVisibleIndexSet] = useState<Set<number>>(new Set([0, 1, 2, 3, 4, 5, 6, 7]));
const viewabilityConfig = useMemo(() => ({
itemVisiblePercentThreshold: 15,
minimumViewTime: 100,
}), []);
const onViewableItemsChanged = useRef(({ viewableItems }: { viewableItems: Array<{ index?: number | null }> }) => {
const next = new Set<number>();
viewableItems.forEach(v => { if (typeof v.index === 'number') next.add(v.index); });
// Only pre-warm immediate neighbors to reduce overhead
const neighbors: number[] = [];
next.forEach(i => {
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) => {
navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
}, [navigation, catalog.addon]);
const renderContentItem = useCallback(({ item, index }: { item: StreamingContent, index: number }) => {
// Only load images for the first few items eagerly; others defer based on viewability
const eager = index < 6;
// Simplify visibility logic to reduce re-renders
const isVisible = visibleIndexSet.has(index) || index < 8;
return (
<ContentItem
item={item}
onPress={handleContentPress}
shouldLoadImage={eager}
deferMs={eager ? 0 : Math.min(400 + index * 15, 1500)}
shouldLoadImage={isVisible}
deferMs={0}
/>
);
}, [handleContentPress]);
}, [handleContentPress, visibleIndexSet]);
// Memoize the ItemSeparatorComponent to prevent re-creation
const ItemSeparator = useCallback(() => <View style={{ width: 8 }} />, []);
@ -112,7 +138,10 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
ItemSeparatorComponent={ItemSeparator}
onEndReachedThreshold={0.7}
onEndReached={() => {}}
scrollEventThrottle={32}
scrollEventThrottle={64}
viewabilityConfig={viewabilityConfig as any}
onViewableItemsChanged={onViewableItemsChanged.current as any}
removeClippedSubviews={true}
/>
</Animated.View>
);

View file

@ -68,6 +68,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
const { currentTheme } = useTheme();
const { settings } = useSettings();
const posterRadius = typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12;
// Memoize poster width calculation to avoid recalculating on every render
const posterWidth = React.useMemo(() => {
switch (settings.posterSize) {
case 'small':
@ -130,31 +131,31 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
return () => clearTimeout(timer);
}, [shouldLoadImageProp, deferMs]);
// Get optimized poster URL for smaller tiles
const getOptimizedPosterUrl = useCallback((originalUrl: string) => {
if (!originalUrl || originalUrl.includes('placeholder')) {
// Memoize optimized poster URL to prevent recalculating
const optimizedPosterUrl = React.useMemo(() => {
if (!item.poster || item.poster.includes('placeholder')) {
return 'https://via.placeholder.com/154x231/333/666?text=No+Image';
}
// If we've had an error, try metahub fallback
if (retryCount > 0 && !originalUrl.includes('metahub.space')) {
if (retryCount > 0 && !item.poster.includes('metahub.space')) {
return `https://images.metahub.space/poster/small/${item.id}/img`;
}
// For TMDB images, use smaller sizes
if (originalUrl.includes('image.tmdb.org')) {
if (item.poster.includes('image.tmdb.org')) {
// Replace any size with w154 (fits 100-130px tiles perfectly)
return originalUrl.replace(/\/w\d+\//, '/w154/');
return item.poster.replace(/\/w\d+\//, '/w154/');
}
// For metahub images, use smaller sizes
if (originalUrl.includes('images.metahub.space')) {
return originalUrl.replace('/medium/', '/small/');
if (item.poster.includes('images.metahub.space')) {
return item.poster.replace('/medium/', '/small/');
}
// Return original URL for other sources to avoid breaking them
return originalUrl;
}, [retryCount, item.id]);
return item.poster;
}, [item.poster, retryCount, item.id]);
return (
<>
@ -170,7 +171,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
{/* Only load image when shouldLoadImage is true (lazy loading) */}
{(shouldLoadImageProp ?? shouldLoadImageState) && item.poster ? (
<ExpoImage
source={{ uri: getOptimizedPosterUrl(item.poster) }}
source={{ uri: optimizedPosterUrl }}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius: posterRadius }]}
contentFit="cover"
cachePolicy={Platform.OS === 'android' ? 'disk' : 'memory-disk'}
@ -303,7 +304,9 @@ const styles = StyleSheet.create({
});
export default React.memo(ContentItem, (prev, next) => {
// Aggressive memoization - only re-render if ID changes (different item entirely)
// This keeps loaded posters stable during fast scrolls
return prev.item.id === next.item.id;
// Re-render when identity changes or when visibility-driven loading flips
if (prev.item.id !== next.item.id) return false;
if (prev.item.poster !== next.item.poster) return false;
if ((prev.shouldLoadImage ?? false) !== (next.shouldLoadImage ?? false)) return false;
return true;
});

View file

@ -96,8 +96,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const [deletingItemId, setDeletingItemId] = useState<string | null>(null);
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Use a state to track if a background refresh is in progress
const [isRefreshing, setIsRefreshing] = useState(false);
// Use a ref to track if a background refresh is in progress to avoid state updates
const isRefreshingRef = useRef(false);
// Cache for metadata to avoid redundant API calls
const metadataCache = useRef<Record<string, { metadata: any; basicContent: StreamingContent | null; timestamp: number }>>({});
@ -133,12 +133,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Modified loadContinueWatching to render incrementally
const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => {
if (isRefreshing) return;
if (isRefreshingRef.current) return;
if (!isBackgroundRefresh) {
setLoading(true);
}
setIsRefreshing(true);
isRefreshingRef.current = true;
// Helper to merge a batch of items into state (dedupe by type:id, keep newest)
const mergeBatchIntoState = (batch: ContinueWatchingItem[]) => {
@ -361,9 +361,9 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
logger.error('Failed to load continue watching items:', error);
} finally {
setLoading(false);
setIsRefreshing(false);
isRefreshingRef.current = false;
}
}, [isRefreshing, getCachedMetadata]);
}, [getCachedMetadata]);
// Clear cache when component unmounts or when needed
useEffect(() => {
@ -398,7 +398,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
refreshTimerRef.current = setTimeout(() => {
// Trigger a background refresh
loadContinueWatching(true);
}, 500); // Increased debounce time slightly
}, 2000); // Increased debounce time significantly to reduce churn
};
// Try to set up a custom event listener or use a timer as fallback
@ -415,8 +415,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
};
} else {
// Reduced polling frequency from 30s to 2 minutes to reduce heating
const intervalId = setInterval(() => loadContinueWatching(true), 120000);
// Reduced polling frequency from 30s to 5 minutes to reduce heating and battery drain
const intervalId = setInterval(() => loadContinueWatching(true), 300000);
return () => {
subscription.remove();
clearInterval(intervalId);
@ -448,7 +448,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
navigation.navigate('Metadata', { id, type });
}, [navigation]);
// Handle long press to delete
// Handle long press to delete (moved before renderContinueWatchingItem)
const handleLongPress = useCallback((item: ContinueWatchingItem) => {
try {
// Trigger haptic feedback
@ -503,6 +503,119 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
);
}, []);
// Memoized render function for continue watching items
const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
<TouchableOpacity
style={[styles.wideContentItem, {
backgroundColor: currentTheme.colors.elevation1,
borderColor: currentTheme.colors.border,
shadowColor: currentTheme.colors.black
}]}
activeOpacity={0.8}
onPress={() => handleContentPress(item.id, item.type)}
onLongPress={() => handleLongPress(item)}
delayLongPress={800}
>
{/* Poster Image */}
<View style={styles.posterContainer}>
<ExpoImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.continueWatchingPoster}
contentFit="cover"
cachePolicy="memory"
transition={0}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
/>
{/* Delete Indicator Overlay */}
{deletingItemId === item.id && (
<View style={styles.deletingOverlay}>
<ActivityIndicator size="large" color="#FFFFFF" />
</View>
)}
</View>
{/* Content Details */}
<View style={styles.contentDetails}>
<View style={styles.titleRow}>
{(() => {
const isUpNext = item.progress === 0;
return (
<View style={styles.titleRow}>
<Text
style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]}
numberOfLines={1}
>
{item.name}
</Text>
{isUpNext && (
<View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.progressText}>Up Next</Text>
</View>
)}
</View>
);
})()}
</View>
{/* Episode Info or Year */}
{(() => {
if (item.type === 'series' && item.season && item.episode) {
return (
<View style={styles.episodeRow}>
<Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}>
Season {item.season}
</Text>
{item.episodeTitle && (
<Text
style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]}
numberOfLines={1}
>
{item.episodeTitle}
</Text>
)}
</View>
);
} else {
return (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumEmphasis }]}>
{item.year} {item.type === 'movie' ? 'Movie' : 'Series'}
</Text>
);
}
})()}
{/* Progress Bar */}
{item.progress > 0 && (
<View style={styles.wideProgressContainer}>
<View style={styles.wideProgressTrack}>
<View
style={[
styles.wideProgressBar,
{
width: `${item.progress}%`,
backgroundColor: currentTheme.colors.primary
}
]}
/>
</View>
<Text style={[styles.progressLabel, { color: currentTheme.colors.textMuted }]}>
{Math.round(item.progress)}% watched
</Text>
</View>
)}
</View>
</TouchableOpacity>
), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId]);
// Memoized key extractor
const keyExtractor = useCallback((item: ContinueWatchingItem) => `continue-${item.id}-${item.type}`, []);
// Memoized item separator
const ItemSeparator = useCallback(() => <View style={{ width: 16 }} />, []);
// If no continue watching items, don't render anything
if (continueWatchingItems.length === 0) {
return null;
@ -519,119 +632,15 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
<FlashList
data={continueWatchingItems}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.wideContentItem, {
backgroundColor: currentTheme.colors.elevation1,
borderColor: currentTheme.colors.border,
shadowColor: currentTheme.colors.black
}]}
activeOpacity={0.8}
onPress={() => handleContentPress(item.id, item.type)}
onLongPress={() => handleLongPress(item)}
delayLongPress={800}
>
{/* Poster Image */}
<View style={styles.posterContainer}>
<ExpoImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.continueWatchingPoster}
contentFit="cover"
cachePolicy="memory"
transition={0}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
/>
{/* Delete Indicator Overlay */}
{deletingItemId === item.id && (
<View style={styles.deletingOverlay}>
<ActivityIndicator size="large" color="#FFFFFF" />
</View>
)}
</View>
{/* Content Details */}
<View style={styles.contentDetails}>
<View style={styles.titleRow}>
{(() => {
const isUpNext = item.progress === 0;
return (
<View style={styles.titleRow}>
<Text
style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]}
numberOfLines={1}
>
{item.name}
</Text>
{isUpNext && (
<View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.progressText}>Up Next</Text>
</View>
)}
</View>
);
})()}
</View>
{/* Episode Info or Year */}
{(() => {
if (item.type === 'series' && item.season && item.episode) {
return (
<View style={styles.episodeRow}>
<Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}>
Season {item.season}
</Text>
{item.episodeTitle && (
<Text
style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]}
numberOfLines={1}
>
{item.episodeTitle}
</Text>
)}
</View>
);
} else {
return (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumEmphasis }]}>
{item.year} {item.type === 'movie' ? 'Movie' : 'Series'}
</Text>
);
}
})()}
{/* Progress Bar */}
{item.progress > 0 && (
<View style={styles.wideProgressContainer}>
<View style={styles.wideProgressTrack}>
<View
style={[
styles.wideProgressBar,
{
width: `${item.progress}%`,
backgroundColor: currentTheme.colors.primary
}
]}
/>
</View>
<Text style={[styles.progressLabel, { color: currentTheme.colors.textMuted }]}>
{Math.round(item.progress)}% watched
</Text>
</View>
)}
</View>
</TouchableOpacity>
)}
keyExtractor={(item) => `continue-${item.id}-${item.type}`}
renderItem={renderContinueWatchingItem}
keyExtractor={keyExtractor}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.wideList}
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
ItemSeparatorComponent={ItemSeparator}
onEndReachedThreshold={0.7}
onEndReached={() => {}}
estimatedItemSize={280 + 16}
removeClippedSubviews={true}
/>
</Animated.View>
);
@ -826,4 +835,7 @@ const styles = StyleSheet.create({
},
});
export default React.memo(ContinueWatchingSection);
export default React.memo(ContinueWatchingSection, (prevProps, nextProps) => {
// This component has no props that would cause re-renders
return true;
});

View file

@ -39,6 +39,7 @@ interface FeaturedContentProps {
isSaved: boolean;
handleSaveToLibrary: () => void;
loading?: boolean;
onRetry?: () => void;
}
// Cache to store preloaded images
@ -53,7 +54,7 @@ const isTablet = width >= 768;
const nowMs = () => Date.now();
const since = (start: number) => `${(nowMs() - start).toFixed(0)}ms`;
const NoFeaturedContent = () => {
const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
@ -105,29 +106,42 @@ const NoFeaturedContent = () => {
return (
<View style={styles.noContentContainer}>
<MaterialIcons name="theaters" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={styles.noContentTitle}>No Featured Content</Text>
<Text style={styles.noContentTitle}>{onRetry ? 'Couldn\'t load featured content' : 'No Featured Content'}</Text>
<Text style={styles.noContentText}>
Install addons with catalogs or change the content source in your settings.
{onRetry
? 'There was a problem fetching featured content. Please check your connection and try again.'
: 'Install addons with catalogs or change the content source in your settings.'}
</Text>
<View style={styles.noContentButtons}>
<TouchableOpacity
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Addons')}
>
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>Install Addons</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.noContentButton}
onPress={() => navigation.navigate('HomeScreenSettings')}
>
<Text style={styles.noContentButtonText}>Settings</Text>
</TouchableOpacity>
{onRetry ? (
<TouchableOpacity
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={onRetry}
>
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>Retry</Text>
</TouchableOpacity>
) : (
<>
<TouchableOpacity
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Addons')}
>
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>Install Addons</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.noContentButton}
onPress={() => navigation.navigate('HomeScreenSettings')}
>
<Text style={styles.noContentButtonText}>Settings</Text>
</TouchableOpacity>
</>
)}
</View>
</View>
);
};
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loading }: FeaturedContentProps) => {
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loading, onRetry }: FeaturedContentProps) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
@ -520,7 +534,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
if (!featuredContent) {
// Suppress empty state while loading to avoid flash on startup/hydration
logger.debug('[FeaturedContent] render:no-featured-content', { sinceMount: since(firstRenderTsRef.current) });
return <NoFeaturedContent />;
return <NoFeaturedContent onRetry={onRetry} />;
}
if (isTablet) {

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { AppState } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { StreamingContent, catalogService } from '../services/catalogService';
import { tmdbService } from '../services/tmdbService';
@ -547,20 +548,45 @@ export function useFeaturedContent() {
useEffect(() => {
if (allFeaturedContent.length <= 1) return;
let intervalId: NodeJS.Timeout | null = null;
let appState = AppState.currentState;
const rotateContent = () => {
currentIndexRef.current = (currentIndexRef.current + 1) % allFeaturedContent.length;
if (allFeaturedContent[currentIndexRef.current]) {
const newContent = allFeaturedContent[currentIndexRef.current];
setFeaturedContent(newContent);
// Also update the persistent store
persistentStore.featuredContent = newContent;
}
};
// Further increased rotation interval to 90s to reduce CPU cycles
const intervalId = setInterval(rotateContent, 90000);
const start = () => {
if (!intervalId) intervalId = setInterval(rotateContent, 90000);
};
const stop = () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
};
return () => clearInterval(intervalId);
const handleAppStateChange = (nextState: any) => {
if (appState.match(/inactive|background/) && nextState === 'active') {
start();
} else if (nextState.match(/inactive|background/)) {
stop();
}
appState = nextState;
};
// Start when mounted and app is active
if (!appState.match(/inactive|background/)) start();
const sub = AppState.addEventListener('change', handleAppStateChange);
return () => {
stop();
sub.remove();
};
}, [allFeaturedContent]);
useEffect(() => {

View file

@ -1,8 +1,8 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useMemo } from 'react';
import { NavigationContainer, DefaultTheme as NavigationDefaultTheme, DarkTheme as NavigationDarkTheme, Theme, NavigationProp } from '@react-navigation/native';
import { createNativeStackNavigator, NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack';
import { createBottomTabNavigator, BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import { useColorScheme, Platform, Animated, StatusBar, TouchableOpacity, View, Text, AppState, Easing, Dimensions, useWindowDimensions } from 'react-native';
import { useColorScheme, Platform, Animated, StatusBar, TouchableOpacity, View, Text, AppState, Easing, Dimensions } from 'react-native';
import { PaperProvider, MD3DarkTheme, MD3LightTheme, adaptNavigationTheme } from 'react-native-paper';
import type { MD3Theme } from 'react-native-paper';
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
@ -360,9 +360,11 @@ const TabIcon = React.memo(({ focused, color, iconName }: {
// Update the TabScreenWrapper component with fixed layout dimensions
const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) => {
const { width, height } = useWindowDimensions();
const smallestDimension = Math.min(width, height);
const isTablet = (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
const isTablet = useMemo(() => {
const { width, height } = Dimensions.get('window');
const smallestDimension = Math.min(width, height);
return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
}, []);
const insets = useSafeAreaInsets();
// Force consistent status bar settings
useEffect(() => {
@ -425,9 +427,11 @@ const WrappedScreen: React.FC<{Screen: React.ComponentType<any>}> = ({ Screen })
const MainTabs = () => {
const { currentTheme } = useTheme();
const { isHomeLoading } = useLoading();
const { width, height } = useWindowDimensions();
const smallestDimension = Math.min(width, height);
const isTablet = (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
const isTablet = useMemo(() => {
const { width, height } = Dimensions.get('window');
const smallestDimension = Math.min(width, height);
return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
}, []);
const insets = useSafeAreaInsets();
const isIosTablet = Platform.OS === 'ios' && isTablet;
const [hidden, setHidden] = React.useState(HeaderVisibility.isHidden());

View file

@ -133,34 +133,31 @@ const HomeScreen = () => {
refreshFeatured
} = useFeaturedContent();
// Progressive catalog loading function with performance optimizations
// Progressive catalog loading function with performance optimizations
const loadCatalogsProgressively = useCallback(async () => {
setCatalogsLoading(true);
setCatalogs([]);
setLoadedCatalogCount(0);
try {
const addons = await catalogService.getAllAddons();
const [addons, catalogSettingsJson, addonManifests] = await Promise.all([
catalogService.getAllAddons(),
AsyncStorage.getItem(CATALOG_SETTINGS_KEY),
stremioService.getInstalledAddonsAsync()
]);
// Set hasAddons state based on whether we have any addons
setHasAddons(addons.length > 0);
// Load catalog settings to check which catalogs are enabled
const catalogSettingsJson = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY);
const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
// Hoist addon manifest loading out of the loop
const addonManifests = await stremioService.getInstalledAddonsAsync();
// Create placeholder array with proper order and track indices
const catalogPlaceholders: (CatalogContent | null)[] = [];
const catalogPromises: Promise<void>[] = [];
let catalogIndex = 0;
const catalogQueue: (() => Promise<void>)[] = [];
// Limit concurrent catalog loading to prevent overwhelming the system
const MAX_CONCURRENT_CATALOGS = 1; // Single catalog at a time to minimize heating/memory
let activeCatalogLoads = 0;
const catalogQueue: (() => Promise<void>)[] = [];
const processCatalogQueue = async () => {
while (catalogQueue.length > 0 && activeCatalogLoads < MAX_CONCURRENT_CATALOGS) {
@ -187,7 +184,6 @@ const HomeScreen = () => {
// Only load enabled catalogs
if (isEnabled) {
const currentIndex = catalogIndex;
catalogPlaceholders.push(null); // Reserve position
const catalogLoader = async () => {
try {
@ -218,7 +214,6 @@ const HomeScreen = () => {
certification: meta.certification
}));
// Skip prefetching to reduce memory pressure (keep disabled)
// Resolve custom display name; if custom exists, use as-is
const originalName = catalog.name || catalog.id;
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName);
@ -305,11 +300,13 @@ const HomeScreen = () => {
setHomeLoading(isLoading);
}, [isLoading, setHomeLoading]);
// React to settings changes
// React to settings changes (memoized to prevent unnecessary effects)
const settingsShowHero = settings.showHeroSection;
const settingsFeaturedSource = settings.featuredContentSource;
useEffect(() => {
setShowHeroSection(settings.showHeroSection);
setFeaturedContentSource(settings.featuredContentSource);
}, [settings]);
setShowHeroSection(settingsShowHero);
setFeaturedContentSource(settingsFeaturedSource);
}, [settingsShowHero, settingsFeaturedSource]);
// Load catalogs progressively on mount and when settings change
useEffect(() => {
@ -362,7 +359,7 @@ const HomeScreen = () => {
const unsubscribe = settingsEmitter.addListener(handleSettingsChange);
return unsubscribe;
}, [settings]);
}, [settings.showHeroSection, settings.featuredContentSource]);
useFocusEffect(
useCallback(() => {
@ -374,10 +371,8 @@ const HomeScreen = () => {
// Allow free rotation on tablets; lock portrait on phones
try {
const { width: dw, height: dh } = Dimensions.get('window');
const smallestDimension = Math.min(dw, dh);
const isTablet = (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
if (isTablet) {
const isTabletDevice = Platform.OS === 'ios' ? (Platform as any).isPad === true : isTablet;
if (isTabletDevice) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
@ -567,7 +562,8 @@ const HomeScreen = () => {
return null;
}, [isLoading, currentTheme.colors]);
const listData: HomeScreenListItem[] = useMemo(() => {
// Stabilize listData to prevent FlashList re-renders
const listData = useMemo(() => {
const data: HomeScreenListItem[] = [];
// If no addons are installed, just show the welcome component
@ -577,7 +573,6 @@ const HomeScreen = () => {
}
// Normal flow when addons are present (featured moved to ListHeaderComponent)
data.push({ type: 'thisWeek', key: 'thisWeek' });
// Only show a limited number of catalogs initially for performance
@ -594,25 +589,27 @@ const HomeScreen = () => {
// Add a "Load More" button if there are more catalogs to show
if (catalogs.length > visibleCatalogCount && catalogs.filter(c => c).length > visibleCatalogCount) {
data.push({ type: 'loadMore', key: 'load-more' } as any);
data.push({ type: 'loadMore', key: 'load-more' });
}
return data;
}, [hasAddons, showHeroSection, catalogs, visibleCatalogCount]);
}, [hasAddons, catalogs, visibleCatalogCount]);
const handleLoadMoreCatalogs = useCallback(() => {
setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length));
}, [catalogs.length]);
// Add memory cleanup on scroll end
const handleScrollEnd = useCallback(() => {
// No-op; avoid clearing image memory cache here to prevent decode thrash/heating
// Stable keyExtractor for FlashList
const keyExtractor = useCallback((item: HomeScreenListItem) => item.key, []);
// Memoize device check to avoid repeated Dimensions.get calls
const isTablet = useMemo(() => {
const deviceWidth = Dimensions.get('window').width;
return deviceWidth >= 768;
}, []);
// Memoize individual section components to prevent re-renders
const memoizedFeaturedContent = useMemo(() => {
const deviceWidth = Dimensions.get('window').width;
const isTablet = deviceWidth >= 768;
const heroStyleToUse = isTablet ? 'legacy' : settings.heroStyle;
return heroStyleToUse === 'carousel' ? (
<HeroCarousel
@ -629,7 +626,7 @@ const HomeScreen = () => {
loading={featuredLoading}
/>
);
}, [settings.heroStyle, showHeroSection, featuredContentSource, featuredContent, allFeaturedContent, isSaved, handleSaveToLibrary]);
}, [isTablet, settings.heroStyle, showHeroSection, featuredContentSource, featuredContent, allFeaturedContent, isSaved, handleSaveToLibrary, featuredLoading]);
const memoizedThisWeekSection = useMemo(() => <ThisWeekSection />, []);
const memoizedContinueWatchingSection = useMemo(() => <ContinueWatchingSection ref={continueWatchingRef} />, []);
@ -649,39 +646,40 @@ const HomeScreen = () => {
HeaderVisibility.setHidden(hide);
}, []);
const renderListItem = useCallback(({ item, index }: { item: HomeScreenListItem, index: number }) => {
const wrapper = (child: React.ReactNode) => (
<Animated.View>
{child}
</Animated.View>
);
// Stabilize renderItem to prevent FlashList re-renders
const renderListItem = useCallback(({ item }: { item: HomeScreenListItem; index: number }) => {
switch (item.type) {
// featured is rendered via ListHeaderComponent to avoid remounts
case 'thisWeek':
return wrapper(memoizedThisWeekSection);
return <Animated.View>{memoizedThisWeekSection}</Animated.View>;
case 'continueWatching':
return null; // Moved to ListHeaderComponent to avoid remounts on scroll
case 'catalog':
return wrapper(<CatalogSection catalog={item.catalog} />);
return (
<Animated.View>
<CatalogSection catalog={item.catalog} />
</Animated.View>
);
case 'placeholder':
return wrapper(
<View style={styles.catalogPlaceholder}>
<View style={styles.placeholderHeader}>
<View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
<LoadingSpinner size="small" text="" />
return (
<Animated.View>
<View style={styles.catalogPlaceholder}>
<View style={styles.placeholderHeader}>
<View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
<LoadingSpinner size="small" text="" />
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.placeholderPosters}>
{[...Array(3)].map((_, posterIndex) => (
<View
key={posterIndex}
style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]}
/>
))}
</ScrollView>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.placeholderPosters}>
{[...Array(3)].map((_, posterIndex) => (
<View
key={posterIndex}
style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]}
/>
))}
</ScrollView>
</View>
</Animated.View>
);
case 'loadMore':
return (
@ -700,19 +698,11 @@ const HomeScreen = () => {
</Animated.View>
);
case 'welcome':
return wrapper(<FirstTimeWelcome />);
return <Animated.View><FirstTimeWelcome /></Animated.View>;
default:
return null;
}
}, [
showHeroSection,
featuredContentSource,
featuredContent,
isSaved,
handleSaveToLibrary,
currentTheme.colors,
handleLoadMoreCatalogs
]);
}, [memoizedThisWeekSection, currentTheme.colors.elevation1, currentTheme.colors.primary, currentTheme.colors.white, handleLoadMoreCatalogs]);
// FlashList: using minimal props per installed version
@ -737,6 +727,29 @@ const HomeScreen = () => {
</>
), [catalogsLoading, catalogs, loadedCatalogCount, totalCatalogsRef.current, navigation, currentTheme.colors]);
// Memoize scroll handler to prevent recreating on every render
const handleScroll = useCallback((event: any) => {
const y = event.nativeEvent.contentOffset.y;
const dy = y - lastScrollYRef.current;
lastScrollYRef.current = y;
if (y <= 10) {
toggleHeader(false);
return;
}
// Threshold to avoid jitter
if (dy > 6) {
toggleHeader(true); // scrolling down
} else if (dy < -6) {
toggleHeader(false); // scrolling up
}
}, [toggleHeader]);
// Memoize content container style
const contentContainerStyle = useMemo(() =>
StyleSheet.flatten([styles.scrollContent, { paddingTop: insets.top }]),
[insets.top]
);
// Memoize the main content section
const renderMainContent = useMemo(() => {
if (isLoading) return null;
@ -751,42 +764,31 @@ const HomeScreen = () => {
<FlashList
data={listData}
renderItem={renderListItem}
keyExtractor={item => item.key}
contentContainerStyle={StyleSheet.flatten([
styles.scrollContent,
{ paddingTop: insets.top }
])}
keyExtractor={keyExtractor}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={false}
ListHeaderComponent={memoizedHeader}
ListFooterComponent={ListFooterComponent}
onEndReached={handleLoadMoreCatalogs}
onEndReachedThreshold={0.6}
scrollEventThrottle={32}
onScroll={event => {
const y = event.nativeEvent.contentOffset.y;
const dy = y - lastScrollYRef.current;
lastScrollYRef.current = y;
if (y <= 10) {
toggleHeader(false);
return;
}
// Threshold to avoid jitter
if (dy > 6) {
toggleHeader(true); // scrolling down
} else if (dy < -6) {
toggleHeader(false); // scrolling up
}
}}
removeClippedSubviews={true}
scrollEventThrottle={64}
onScroll={handleScroll}
/>
{/* Toasts are rendered globally at root */}
</View>
);
}, [
isLoading,
currentTheme.colors,
currentTheme.colors.darkBackground,
listData,
renderListItem,
ListFooterComponent
keyExtractor,
contentContainerStyle,
memoizedHeader,
ListFooterComponent,
handleLoadMoreCatalogs,
handleScroll
]);
return isLoading ? renderLoadingScreen : renderMainContent;

View file

@ -9,8 +9,7 @@ import {
StatusBar,
Alert,
Platform,
Dimensions,
Clipboard
Dimensions
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
@ -70,8 +69,7 @@ const UpdateScreen: React.FC = () => {
const [isChecking, setIsChecking] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [lastChecked, setLastChecked] = useState<Date | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [showLogs, setShowLogs] = useState(false);
// Logs removed
const [lastOperation, setLastOperation] = useState<string>('');
const [updateProgress, setUpdateProgress] = useState<number>(0);
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'downloading' | 'installing' | 'success' | 'error'>('idle');
@ -87,9 +85,7 @@ const UpdateScreen: React.FC = () => {
setUpdateInfo(info);
setLastChecked(new Date());
// Refresh logs after operation
const logs = UpdateService.getLogs();
setLogs(logs);
// Logs disabled
if (info.isAvailable) {
setUpdateStatus('available');
@ -130,9 +126,7 @@ const UpdateScreen: React.FC = () => {
setUpdateStatus('installing');
setLastOperation('Installing update...');
// Refresh logs after operation
const logs = UpdateService.getLogs();
setLogs(logs);
// Logs disabled
if (success) {
setUpdateStatus('success');
@ -156,8 +150,7 @@ const UpdateScreen: React.FC = () => {
const getCurrentUpdateInfo = async () => {
const info = await UpdateService.getCurrentUpdateInfo();
setCurrentInfo(info);
const logs = UpdateService.getLogs();
setLogs(logs);
// Logs disabled
};
// Extract release notes from various possible manifest fields
@ -184,41 +177,12 @@ const UpdateScreen: React.FC = () => {
);
};
const refreshLogs = () => {
const logs = UpdateService.getLogs();
setLogs(logs);
};
const clearLogs = () => {
UpdateService.clearLogs();
setLogs([]);
setLastOperation('Logs cleared');
};
const copyLog = (logText: string) => {
Clipboard.setString(logText);
Alert.alert('Copied', 'Log entry copied to clipboard');
};
const copyAllLogs = () => {
const allLogsText = logs.join('\n');
Clipboard.setString(allLogsText);
Alert.alert('Copied', 'All logs copied to clipboard');
};
const addTestLog = () => {
UpdateService.addTestLog(`Test log entry at ${new Date().toISOString()}`);
const logs = UpdateService.getLogs();
setLogs(logs);
setLastOperation('Test log added');
};
// Logs disabled: remove actions
const testConnectivity = async () => {
try {
setLastOperation('Testing connectivity...');
const isReachable = await UpdateService.testUpdateConnectivity();
const logs = UpdateService.getLogs();
setLogs(logs);
if (isReachable) {
setLastOperation('Update server is reachable');
@ -228,8 +192,7 @@ const UpdateScreen: React.FC = () => {
} catch (error) {
if (__DEV__) console.error('Error testing connectivity:', error);
setLastOperation(`Connectivity test error: ${error instanceof Error ? error.message : 'Unknown error'}`);
const logs = UpdateService.getLogs();
setLogs(logs);
// Logs disabled
}
};
@ -237,14 +200,11 @@ const UpdateScreen: React.FC = () => {
try {
setLastOperation('Testing asset URLs...');
await UpdateService.testAllAssetUrls();
const logs = UpdateService.getLogs();
setLogs(logs);
setLastOperation('Asset URL testing completed');
} catch (error) {
if (__DEV__) console.error('Error testing asset URLs:', error);
setLastOperation(`Asset URL test error: ${error instanceof Error ? error.message : 'Unknown error'}`);
const logs = UpdateService.getLogs();
setLogs(logs);
// Logs disabled
}
};
@ -252,8 +212,6 @@ const UpdateScreen: React.FC = () => {
useEffect(() => {
const loadInitialData = async () => {
await getCurrentUpdateInfo();
// Also refresh logs to ensure we have the latest
refreshLogs();
};
loadInitialData();
}, []);
@ -505,30 +463,10 @@ const UpdateScreen: React.FC = () => {
)}
</View>
{/* Advanced Toggle */}
<TouchableOpacity
style={[styles.modernAdvancedToggle, { backgroundColor: `${currentTheme.colors.primary}08` }]}
onPress={() => setShowLogs(!showLogs)}
activeOpacity={0.7}
>
<View style={styles.advancedToggleLeft}>
<MaterialIcons name="code" size={18} color={currentTheme.colors.primary} />
<Text style={[styles.advancedToggleLabel, { color: currentTheme.colors.primary }]}>
Developer Logs
</Text>
<View style={[styles.logsBadge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.logsBadgeText}>{logs.length}</Text>
</View>
</View>
<MaterialIcons
name={showLogs ? "keyboard-arrow-up" : "keyboard-arrow-down"}
size={20}
color={currentTheme.colors.primary}
/>
</TouchableOpacity>
{/* Developer Logs removed */}
</SettingsCard>
{showLogs && (
{false && (
<SettingsCard title="UPDATE LOGS" isTablet={isTablet}>
<View style={styles.logsContainer}>
<View style={styles.logsHeader}>
@ -550,34 +488,10 @@ const UpdateScreen: React.FC = () => {
>
<MaterialIcons name="link" size={16} color={currentTheme.colors.primary} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.logActionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={addTestLog}
activeOpacity={0.7}
>
<MaterialIcons name="add" size={16} color={currentTheme.colors.primary} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.logActionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={copyAllLogs}
activeOpacity={0.7}
>
<MaterialIcons name="content-copy" size={16} color={currentTheme.colors.primary} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.logActionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={refreshLogs}
activeOpacity={0.7}
>
<MaterialIcons name="refresh" size={16} color={currentTheme.colors.primary} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.logActionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={clearLogs}
activeOpacity={0.7}
>
<MaterialIcons name="clear" size={16} color={currentTheme.colors.error || '#ff4444'} />
</TouchableOpacity>
{/* Test log removed */}
{/* Copy all logs removed */}
{/* Refresh logs removed */}
{/* Clear logs removed */}
</View>
</View>
@ -586,14 +500,12 @@ const UpdateScreen: React.FC = () => {
showsVerticalScrollIndicator={true}
nestedScrollEnabled={true}
>
{logs.length === 0 ? (
<Text style={[styles.noLogsText, { color: currentTheme.colors.mediumEmphasis }]}>
No logs available
</Text>
{false ? (
<Text style={[styles.noLogsText, { color: currentTheme.colors.mediumEmphasis }]}>No logs available</Text>
) : (
logs.map((log, index) => {
const isError = log.includes('[ERROR]');
const isWarning = log.includes('[WARN]');
([] as string[]).map((log, index) => {
const isError = log.indexOf('[ERROR]') !== -1;
const isWarning = log.indexOf('[WARN]') !== -1;
return (
<TouchableOpacity
@ -602,7 +514,7 @@ const UpdateScreen: React.FC = () => {
styles.logEntry,
{ backgroundColor: 'rgba(255,255,255,0.05)' }
]}
onPress={() => copyLog(log)}
onPress={() => {}}
activeOpacity={0.7}
>
<View style={styles.logEntryContent}>

View file

@ -28,61 +28,32 @@ export class UpdateService {
* Add a log entry with timestamp - always log to console for adb logcat visibility
*/
private addLog(message: string, level: 'INFO' | 'WARN' | 'ERROR' = 'INFO'): void {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] [${level}] ${message}`;
this.logs.unshift(logEntry);
// Keep only the last MAX_LOGS entries
if (this.logs.length > this.MAX_LOGS) {
this.logs = this.logs.slice(0, this.MAX_LOGS);
}
// Console logging policy:
// - Development: log INFO/WARN/ERROR for visibility
// - Production: only log ERROR to reduce JS<->native bridge traffic and CPU usage
if (!__DEV__) {
if (level === 'ERROR') {
console.error(`[UpdateService] ${logEntry}`);
}
return;
}
// Development detailed logging
if (level === 'ERROR') {
console.error(`[UpdateService] ${logEntry}`);
} else if (level === 'WARN') {
console.warn(`[UpdateService] ${logEntry}`);
} else {
console.log(`[UpdateService] ${logEntry}`);
}
// Additional prefixed line for easier filtering during development only
if (__DEV__) {
console.log(`UpdateService: ${logEntry}`);
}
// Logging disabled intentionally
return;
}
/**
* Get all logs
*/
public getLogs(): string[] {
return [...this.logs];
// Logging disabled - return empty list
return [];
}
/**
* Clear all logs
*/
public clearLogs(): void {
// Logging disabled - no-op
this.logs = [];
this.addLog('Logs cleared', 'INFO');
}
/**
* Add a test log entry (useful for debugging)
*/
public addTestLog(message: string): void {
this.addLog(`TEST: ${message}`, 'INFO');
// Logging disabled - no-op
return;
}
/**