This commit is contained in:
tapframe 2025-06-20 02:47:50 +05:30
parent 5545ed40fb
commit 9cb2049d6b
3 changed files with 334 additions and 95 deletions

View file

@ -15,26 +15,40 @@ interface CatalogSectionProps {
const { width } = Dimensions.get('window');
// Dynamic poster calculation based on screen width
// Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability
const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters
const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
const LEFT_PADDING = 16; // Left padding
const SPACING = 8; // Space between posters
// Calculate how many posters can fit
const availableWidth = screenWidth - HORIZONTAL_PADDING;
const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
// Calculate available width for posters (reserve space for left padding)
const availableWidth = screenWidth - LEFT_PADDING;
// Limit to reasonable number of columns (3-6)
const numColumns = Math.min(Math.max(maxColumns, 3), 6);
// Try different numbers of full posters to find the best fit
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
// Calculate actual poster width
const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
for (let n = 3; n <= 6; n++) {
// Calculate poster width needed for N full posters + 0.25 partial poster
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
// Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding
// We'll use minimal right padding (8px) to maximize space
const usableWidth = availableWidth - 8;
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
console.log(`[CatalogSection] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`);
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
bestLayout = { numFullPosters: n, posterWidth };
console.log(`[CatalogSection] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`);
}
}
return {
numColumns,
posterWidth,
spacing: 12 // Space between posters
numFullPosters: bestLayout.numFullPosters,
posterWidth: bestLayout.posterWidth,
spacing: SPACING,
partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster
};
};
@ -98,18 +112,18 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
keyExtractor={(item) => `${item.id}-${item.type}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.catalogList}
snapToInterval={POSTER_WIDTH + 12}
contentContainerStyle={[styles.catalogList, { paddingRight: 16 - posterLayout.partialPosterWidth }]}
snapToInterval={POSTER_WIDTH + 8}
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 12 }} />}
ItemSeparatorComponent={() => <View style={{ width: 8 }} />}
initialNumToRender={4}
maxToRenderPerBatch={4}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
getItemLayout={(data, index) => ({
length: POSTER_WIDTH + 12,
offset: (POSTER_WIDTH + 12) * index,
length: POSTER_WIDTH + 8,
offset: (POSTER_WIDTH + 8) * index,
index,
})}
/>

View file

@ -13,26 +13,40 @@ interface ContentItemProps {
const { width } = Dimensions.get('window');
// Dynamic poster calculation based on screen width
// Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability
const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters
const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
const LEFT_PADDING = 16; // Left padding
const SPACING = 8; // Space between posters
// Calculate how many posters can fit
const availableWidth = screenWidth - HORIZONTAL_PADDING;
const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
// Calculate available width for posters (reserve space for left padding)
const availableWidth = screenWidth - LEFT_PADDING;
// Limit to reasonable number of columns (3-6)
const numColumns = Math.min(Math.max(maxColumns, 3), 6);
// Try different numbers of full posters to find the best fit
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
// Calculate actual poster width
const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
for (let n = 3; n <= 6; n++) {
// Calculate poster width needed for N full posters + 0.25 partial poster
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
// Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding
// We'll use minimal right padding (8px) to maximize space
const usableWidth = availableWidth - 8;
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
console.log(`[ContentItem] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`);
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
bestLayout = { numFullPosters: n, posterWidth };
console.log(`[ContentItem] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`);
}
}
return {
numColumns,
posterWidth,
spacing: 12 // Space between posters
numFullPosters: bestLayout.numFullPosters,
posterWidth: bestLayout.posterWidth,
spacing: SPACING,
partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster
};
};
@ -157,28 +171,28 @@ const styles = StyleSheet.create({
width: POSTER_WIDTH,
aspectRatio: 2/3,
margin: 0,
borderRadius: 8,
borderRadius: 4,
overflow: 'hidden',
position: 'relative',
elevation: 8,
elevation: 6,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.25,
shadowRadius: 6,
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.12)',
},
contentItemContainer: {
width: '100%',
height: '100%',
borderRadius: 8,
borderRadius: 4,
overflow: 'hidden',
position: 'relative',
},
poster: {
width: '100%',
height: '100%',
borderRadius: 8,
borderRadius: 4,
},
loadingOverlay: {
position: 'absolute',

View file

@ -22,6 +22,7 @@ import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService';
import { stremioService } from '../services/stremioService';
import { Stream } from '../types/metadata';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
@ -386,16 +387,16 @@ const HomeScreen = () => {
const { currentTheme } = useTheme();
const continueWatchingRef = useRef<ContinueWatchingRef>(null);
const { settings } = useSettings();
const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [hasContinueWatching, setHasContinueWatching] = useState(false);
const {
catalogs,
loading: catalogsLoading,
refreshCatalogs
} = useHomeCatalogs();
const [catalogs, setCatalogs] = useState<CatalogContent[]>([]);
const [catalogsLoading, setCatalogsLoading] = useState(true);
const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
const totalCatalogsRef = useRef(0);
const {
featuredContent,
@ -405,10 +406,116 @@ const HomeScreen = () => {
refreshFeatured
} = useFeaturedContent();
// Progressive catalog loading function
const loadCatalogsProgressively = useCallback(async () => {
setCatalogsLoading(true);
setCatalogs([]);
setLoadedCatalogCount(0);
try {
const addons = await catalogService.getAllAddons();
// Create placeholder array with proper order and track indices
const catalogPlaceholders: (CatalogContent | null)[] = [];
const catalogPromises: Promise<void>[] = [];
let catalogIndex = 0;
for (const addon of addons) {
if (addon.catalogs) {
for (const catalog of addon.catalogs) {
const currentIndex = catalogIndex;
catalogPlaceholders.push(null); // Reserve position
const catalogPromise = (async () => {
try {
const addonManifest = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifest.find((a: any) => a.id === addon.id);
if (!manifest) return;
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
if (metas && metas.length > 0) {
const items = metas.map((meta: any) => ({
id: meta.id,
type: meta.type,
name: meta.name,
poster: meta.poster,
posterShape: meta.posterShape,
banner: meta.background,
logo: meta.logo,
imdbRating: meta.imdbRating,
year: meta.year,
genres: meta.genres,
description: meta.description,
runtime: meta.runtime,
released: meta.released,
trailerStreams: meta.trailerStreams,
videos: meta.videos,
directors: meta.director,
creators: meta.creator,
certification: meta.certification
}));
let displayName = catalog.name;
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
displayName = `${displayName} ${contentType}`;
}
const catalogContent = {
addon: addon.id,
type: catalog.type,
id: catalog.id,
name: displayName,
items
};
console.log(`[HomeScreen] Loaded catalog: ${displayName} at position ${currentIndex} (${items.length} items)`);
// Update the catalog at its specific position
setCatalogs(prevCatalogs => {
const newCatalogs = [...prevCatalogs];
newCatalogs[currentIndex] = catalogContent;
return newCatalogs;
});
}
} catch (error) {
console.error(`[HomeScreen] Failed to load ${catalog.name} from ${addon.name}:`, error);
} finally {
setLoadedCatalogCount(prev => prev + 1);
}
})();
catalogPromises.push(catalogPromise);
catalogIndex++;
}
}
}
totalCatalogsRef.current = catalogIndex;
console.log(`[HomeScreen] Starting to load ${catalogIndex} catalogs progressively...`);
// Initialize catalogs array with proper length
setCatalogs(new Array(catalogIndex).fill(null));
// Wait for all catalogs to finish loading (success or failure)
await Promise.allSettled(catalogPromises);
console.log('[HomeScreen] All catalogs processed');
// Filter out null values to get only successfully loaded catalogs
setCatalogs(prevCatalogs => prevCatalogs.filter(catalog => catalog !== null));
} catch (error) {
console.error('[HomeScreen] Error in progressive catalog loading:', error);
} finally {
setCatalogsLoading(false);
}
}, []);
// Only count feature section as loading if it's enabled in settings
// For catalogs, we show them progressively, so only show loading if no catalogs are loaded yet
const isLoading = useMemo(() =>
(showHeroSection ? featuredLoading : false) || catalogsLoading,
[showHeroSection, featuredLoading, catalogsLoading]
(showHeroSection ? featuredLoading : false) || (catalogsLoading && catalogs.length === 0),
[showHeroSection, featuredLoading, catalogsLoading, catalogs.length]
);
// React to settings changes
@ -417,6 +524,21 @@ const HomeScreen = () => {
setFeaturedContentSource(settings.featuredContentSource);
}, [settings]);
// Load catalogs progressively on mount and when settings change
useEffect(() => {
loadCatalogsProgressively();
}, [loadCatalogsProgressively]);
// Listen for catalog changes (addon additions/removals) and reload catalogs
useEffect(() => {
loadCatalogsProgressively();
}, [lastUpdate, loadCatalogsProgressively]);
// Create a refresh function for catalogs
const refreshCatalogs = useCallback(() => {
return loadCatalogsProgressively();
}, [loadCatalogsProgressively]);
// Subscribe directly to settings emitter for immediate updates
useEffect(() => {
const handleSettingsChange = () => {
@ -567,10 +689,13 @@ const HomeScreen = () => {
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
refreshContinueWatching();
// Also refresh catalogs when returning to home screen
// This ensures new addons are shown even if the context event was missed
loadCatalogsProgressively();
});
return unsubscribe;
}, [navigation, refreshContinueWatching]);
}, [navigation, refreshContinueWatching, loadCatalogsProgressively]);
// Memoize the loading screen to prevent unnecessary re-renders
const renderLoadingScreen = useMemo(() => {
@ -626,28 +751,63 @@ const HomeScreen = () => {
<ContinueWatchingSection ref={continueWatchingRef} />
{catalogs.length > 0 ? (
catalogs.map((catalog, index) => (
<View key={`${catalog.addon}-${catalog.id}-${index}`}>
{/* Show catalogs as they load */}
{catalogs.map((catalog, index) => {
if (!catalog) {
// Show placeholder for loading catalog
return (
<View key={`placeholder-${index}`} style={styles.catalogPlaceholder}>
<View style={styles.placeholderHeader}>
<View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
<View style={styles.placeholderPosters}>
{[...Array(4)].map((_, posterIndex) => (
<View
key={posterIndex}
style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]}
/>
))}
</View>
</View>
);
}
return (
<Animated.View
key={`${catalog.addon}-${catalog.id}-${index}`}
entering={FadeIn.duration(300)}
>
<CatalogSection catalog={catalog} />
</View>
))
) : (
!catalogsLoading && (
<View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} />
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
No content available
</Text>
<TouchableOpacity
style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Settings')}
>
<MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} />
<Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text>
</TouchableOpacity>
</View>
)
</Animated.View>
);
})}
{/* Show loading indicator for remaining catalogs */}
{catalogsLoading && catalogs.length < totalCatalogsRef.current && (
<View style={styles.loadingMoreCatalogs}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
<Text style={[styles.loadingMoreText, { color: currentTheme.colors.textMuted }]}>
Loading more content... ({loadedCatalogCount}/{totalCatalogsRef.current})
</Text>
</View>
)}
{/* Show empty state only if all catalogs are loaded and none are available */}
{!catalogsLoading && catalogs.length === 0 && (
<View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} />
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
No content available
</Text>
<TouchableOpacity
style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Settings')}
>
<MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} />
<Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
</View>
@ -671,26 +831,40 @@ const HomeScreen = () => {
const { width, height } = Dimensions.get('window');
// Dynamic poster calculation based on screen width
// Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability
const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters
const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
const LEFT_PADDING = 16; // Left padding
const SPACING = 8; // Space between posters
// Calculate how many posters can fit
const availableWidth = screenWidth - HORIZONTAL_PADDING;
const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
// Calculate available width for posters (reserve space for left padding)
const availableWidth = screenWidth - LEFT_PADDING;
// Limit to reasonable number of columns (3-6)
const numColumns = Math.min(Math.max(maxColumns, 3), 6);
// Try different numbers of full posters to find the best fit
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
// Calculate actual poster width
const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
for (let n = 3; n <= 6; n++) {
// Calculate poster width needed for N full posters + 0.25 partial poster
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
// Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding
// We'll use minimal right padding (8px) to maximize space
const usableWidth = availableWidth - 8;
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
console.log(`[HomeScreen] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`);
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
bestLayout = { numFullPosters: n, posterWidth };
console.log(`[HomeScreen] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`);
}
}
return {
numColumns,
posterWidth,
spacing: 12 // Space between posters
numFullPosters: bestLayout.numFullPosters,
posterWidth: bestLayout.posterWidth,
spacing: SPACING,
partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster
};
};
@ -714,6 +888,42 @@ const styles = StyleSheet.create<any>({
marginTop: 12,
fontSize: 14,
},
loadingMoreCatalogs: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
marginHorizontal: 16,
marginBottom: 16,
},
loadingMoreText: {
marginLeft: 12,
fontSize: 14,
},
catalogPlaceholder: {
marginBottom: 24,
paddingHorizontal: 16,
},
placeholderHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
placeholderTitle: {
width: 150,
height: 20,
borderRadius: 4,
},
placeholderPosters: {
flexDirection: 'row',
gap: 8,
},
placeholderPoster: {
width: POSTER_WIDTH,
aspectRatio: 2/3,
borderRadius: 4,
},
emptyCatalog: {
padding: 32,
alignItems: 'center',
@ -903,7 +1113,8 @@ const styles = StyleSheet.create<any>({
marginRight: 4,
},
catalogList: {
paddingHorizontal: 16,
paddingLeft: 16,
paddingRight: 16 - posterLayout.partialPosterWidth,
paddingBottom: 12,
paddingTop: 6,
},
@ -911,21 +1122,21 @@ const styles = StyleSheet.create<any>({
width: POSTER_WIDTH,
aspectRatio: 2/3,
margin: 0,
borderRadius: 8,
borderRadius: 4,
overflow: 'hidden',
position: 'relative',
elevation: 8,
elevation: 6,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.25,
shadowRadius: 6,
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.12)',
},
poster: {
width: '100%',
height: '100%',
borderRadius: 8,
borderRadius: 4,
},
imdbLogo: {
width: 35,
@ -964,7 +1175,7 @@ const styles = StyleSheet.create<any>({
contentItemContainer: {
width: '100%',
height: '100%',
borderRadius: 8,
borderRadius: 4,
overflow: 'hidden',
position: 'relative',
},