landscape poster support

This commit is contained in:
tapframe 2025-12-16 15:38:29 +05:30
parent 59cb902658
commit 601a4a0f1d
8 changed files with 236 additions and 172 deletions

View file

@ -96,20 +96,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
// Memoize the keyExtractor to prevent re-creation // Memoize the keyExtractor to prevent re-creation
const keyExtractor = useCallback((item: StreamingContent) => `${item.id}-${item.type}`, []); const keyExtractor = useCallback((item: StreamingContent) => `${item.id}-${item.type}`, []);
// Calculate item width for getItemLayout - use base POSTER_WIDTH for consistent spacing
// Note: ContentItem may apply size multipliers based on settings, but base width ensures consistent layout
const itemWidth = useMemo(() => POSTER_WIDTH, []);
// getItemLayout for consistent spacing and better performance
const getItemLayout = useCallback((data: any, index: number) => {
const length = itemWidth + separatorWidth;
const paddingHorizontal = isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 16;
return {
length,
offset: paddingHorizontal + (length * index),
index,
};
}, [itemWidth, separatorWidth, isTV, isLargeTablet, isTablet]);
return ( return (
<View <View
@ -194,7 +181,6 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
} }
])} ])}
ItemSeparatorComponent={ItemSeparator} ItemSeparatorComponent={ItemSeparator}
getItemLayout={getItemLayout}
removeClippedSubviews={true} removeClippedSubviews={true}
initialNumToRender={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3} initialNumToRender={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3}
maxToRenderPerBatch={isTV ? 4 : isLargeTablet ? 4 : 3} maxToRenderPerBatch={isTV ? 4 : isLargeTablet ? 4 : 3}

View file

@ -96,7 +96,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
return () => unsubscribe(); return () => unsubscribe();
}, [item.id, item.type]); }, [item.id, item.type]);
// Load watched state from AsyncStorage when item changes // Load watched state from AsyncStorage when item changes
useEffect(() => { useEffect(() => {
const updateWatched = () => { const updateWatched = () => {
mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then((val: string | null) => setIsWatched(val === 'true')); mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then((val: string | null) => setIsWatched(val === 'true'));
@ -139,6 +139,30 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
} }
}, [settings.posterSize, width]); }, [settings.posterSize, width]);
// Determine dimensions based on poster shape
const { finalWidth, finalAspectRatio, borderRadius } = React.useMemo(() => {
const shape = item.posterShape || 'poster';
const baseHeight = posterWidth / (2 / 3); // Standard height derived from portrait width
let w = posterWidth;
let ratio = 2 / 3;
if (shape === 'landscape') {
ratio = 16 / 9;
// Maintain same height as portrait posters
w = baseHeight * ratio;
} else if (shape === 'square') {
ratio = 1;
w = baseHeight;
}
return {
finalWidth: w,
finalAspectRatio: ratio,
borderRadius: typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12
};
}, [posterWidth, item.posterShape, settings.posterBorderRadius]);
// Intersection observer simulation for lazy loading // Intersection observer simulation for lazy loading
const itemRef = useRef<View>(null); const itemRef = useRef<View>(null);
@ -169,7 +193,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
setIsWatched(targetWatched); setIsWatched(targetWatched);
try { try {
await mmkvStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false'); await mmkvStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
} catch {} } catch { }
showInfo(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched', targetWatched ? 'Item marked as watched' : 'Item marked as unwatched'); showInfo(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched', targetWatched ? 'Item marked as watched' : 'Item marked as unwatched');
setTimeout(() => { setTimeout(() => {
DeviceEventEmitter.emit('watchedStatusChanged'); DeviceEventEmitter.emit('watchedStatusChanged');
@ -185,7 +209,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
undefined, undefined,
{ forceNotify: true, forceWrite: true } { forceNotify: true, forceWrite: true }
); );
} catch {} } catch { }
if (item.type === 'movie') { if (item.type === 'movie') {
try { try {
@ -194,9 +218,9 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
await trakt.addToWatchedMovies(item.id); await trakt.addToWatchedMovies(item.id);
try { try {
await storageService.updateTraktSyncStatus(item.id, item.type, true, 100); await storageService.updateTraktSyncStatus(item.id, item.type, true, 100);
} catch {} } catch { }
} }
} catch {} } catch { }
} }
} }
setMenuVisible(false); setMenuVisible(false);
@ -242,44 +266,34 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
setMenuVisible(false); setMenuVisible(false);
}, []); }, []);
// Memoize optimized poster URL to prevent recalculating // Memoize optimized poster URL to prevent recalculating
const optimizedPosterUrl = React.useMemo(() => { const optimizedPosterUrl = React.useMemo(() => {
if (!item.poster || item.poster.includes('placeholder')) { if (!item.poster || item.poster.includes('placeholder')) {
return 'https://via.placeholder.com/154x231/333/666?text=No+Image'; return 'https://via.placeholder.com/154x231/333/666?text=No+Image';
} }
// For TMDB images, use smaller sizes
if (item.poster.includes('image.tmdb.org')) { if (item.poster.includes('image.tmdb.org')) {
// Replace any size with w154 (fits 100-130px tiles perfectly)
return item.poster.replace(/\/w\d+\//, '/w154/'); return item.poster.replace(/\/w\d+\//, '/w154/');
} }
// For metahub images, use smaller sizes
if (item.poster.includes('placeholder')) { if (item.poster.includes('placeholder')) {
return item.poster.replace('/medium/', '/small/'); return item.poster.replace('/medium/', '/small/');
} }
// Return original URL for other sources to avoid breaking them
return item.poster; return item.poster;
}, [item.poster, item.id]); }, [item.poster, item.id]);
// While settings load, render a placeholder with reserved space (poster aspect + title)
if (!isLoaded) { if (!isLoaded) {
const placeholderRadius = 12;
return ( return (
<View style={[styles.itemContainer, { width: posterWidth }]}> <View style={[styles.itemContainer, { width: finalWidth }]}>
<View <View
style={[ style={[
styles.contentItem, styles.contentItem,
{ {
width: posterWidth, width: finalWidth,
borderRadius: placeholderRadius, aspectRatio: finalAspectRatio,
borderRadius,
backgroundColor: currentTheme.colors.elevation1, backgroundColor: currentTheme.colors.elevation1,
}, },
]} ]}
/> />
{/* Reserve space for title to keep section spacing stable */}
<View style={{ height: 18, marginTop: 4 }} /> <View style={{ height: 18, marginTop: 4 }} />
</View> </View>
); );
@ -287,15 +301,15 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
return ( return (
<> <>
<Animated.View style={[styles.itemContainer, { width: posterWidth }]} entering={FadeIn.duration(300)}> <Animated.View style={[styles.itemContainer, { width: finalWidth }]} entering={FadeIn.duration(300)}>
<TouchableOpacity <TouchableOpacity
style={[styles.contentItem, { width: posterWidth, borderRadius: posterRadius }]} style={[styles.contentItem, { width: finalWidth, aspectRatio: finalAspectRatio, borderRadius }]}
activeOpacity={0.7} activeOpacity={0.7}
onPress={handlePress} onPress={handlePress}
onLongPress={handleLongPress} onLongPress={handleLongPress}
delayLongPress={300} delayLongPress={300}
> >
<View ref={itemRef} style={[styles.contentItemContainer, { borderRadius: posterRadius }] }> <View ref={itemRef} style={[styles.contentItemContainer, { borderRadius }]}>
{/* Image with FastImage for aggressive caching */} {/* Image with FastImage for aggressive caching */}
{item.poster ? ( {item.poster ? (
<FastImage <FastImage
@ -304,7 +318,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
priority: FastImage.priority.normal, priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable cache: FastImage.cacheControl.immutable
}} }}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius: posterRadius }]} style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius }]}
resizeMode={FastImage.resizeMode.cover} resizeMode={FastImage.resizeMode.cover}
onLoad={() => { onLoad={() => {
setImageError(false); setImageError(false);
@ -316,7 +330,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
/> />
) : ( ) : (
// Show placeholder for items without posters // 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)}...
</Text> </Text>

View file

@ -289,6 +289,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const [catalogExtras, setCatalogExtras] = useState<CatalogExtra[]>([]); const [catalogExtras, setCatalogExtras] = useState<CatalogExtra[]>([]);
const [selectedFilters, setSelectedFilters] = useState<Record<string, string>>({}); const [selectedFilters, setSelectedFilters] = useState<Record<string, string>>({});
const [activeGenreFilter, setActiveGenreFilter] = useState<string | undefined>(genreFilter); const [activeGenreFilter, setActiveGenreFilter] = useState<string | undefined>(genreFilter);
const [showTitles, setShowTitles] = useState(true); // Default to showing titles
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const colors = currentTheme.colors; const colors = currentTheme.colors;
const styles = createStyles(colors); const styles = createStyles(colors);
@ -302,6 +303,10 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
if (pref === '2') setMobileColumnsPref(2); if (pref === '2') setMobileColumnsPref(2);
else if (pref === '3') setMobileColumnsPref(3); else if (pref === '3') setMobileColumnsPref(3);
else setMobileColumnsPref('auto'); else setMobileColumnsPref('auto');
// Load show titles preference (default: true)
const titlesPref = await mmkvStorage.getItem('catalog_show_titles');
setShowTitles(titlesPref !== 'false'); // Default to true if not set
} catch { } } catch { }
})(); })();
}, []); }, []);
@ -556,11 +561,14 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
let nextHasMore = false; let nextHasMore = false;
try { try {
const svcHasMore = addonId ? stremioService.getCatalogHasMore(addonId, type, id) : undefined; const svcHasMore = addonId ? stremioService.getCatalogHasMore(addonId, type, id) : undefined;
// If service explicitly provides hasMore, use it; otherwise assume there's more if we got any items // If service explicitly provides hasMore, use it
// This handles addons with different page sizes (not just 50 items per page) // Otherwise, only assume there's more if we got a reasonable number of items (>= 5)
nextHasMore = typeof svcHasMore === 'boolean' ? svcHasMore : (catalogItems.length > 0); // This prevents infinite loops when addons return just 1-2 items per page
const MIN_ITEMS_FOR_MORE = 5;
nextHasMore = typeof svcHasMore === 'boolean' ? svcHasMore : (catalogItems.length >= MIN_ITEMS_FOR_MORE);
} catch { } catch {
nextHasMore = catalogItems.length > 0; // Fallback: only assume more if we got at least 5 items
nextHasMore = catalogItems.length >= 5;
} }
setHasMore(nextHasMore); setHasMore(nextHasMore);
logger.log('[CatalogScreen] Updated items and hasMore', { logger.log('[CatalogScreen] Updated items and hasMore', {
@ -749,6 +757,10 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
// For proper spacing // For proper spacing
const rightMargin = isLastInRow ? 0 : ((screenData as any).itemSpacing ?? SPACING.sm); const rightMargin = isLastInRow ? 0 : ((screenData as any).itemSpacing ?? SPACING.sm);
// Calculate aspect ratio based on posterShape
const shape = item.posterShape || 'poster';
const aspectRatio = shape === 'landscape' ? 16 / 9 : (shape === 'square' ? 1 : 2 / 3);
return ( return (
<TouchableOpacity <TouchableOpacity
style={[ style={[
@ -763,7 +775,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
> >
<FastImage <FastImage
source={{ uri: optimizePosterUrl(item.poster) }} source={{ uri: optimizePosterUrl(item.poster) }}
style={styles.poster} style={[styles.poster, { aspectRatio }]}
resizeMode={FastImage.resizeMode.cover} resizeMode={FastImage.resizeMode.cover}
/> />
@ -808,9 +820,26 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
</View> </View>
) )
)} )}
{/* Poster Title */}
{showTitles && (
<Text
style={{
fontSize: 12,
fontWeight: '500',
color: colors.mediumGray,
marginTop: 6,
textAlign: 'center',
paddingHorizontal: 4,
}}
numberOfLines={2}
>
{item.name}
</Text>
)}
</TouchableOpacity> </TouchableOpacity>
); );
}, [navigation, styles, effectiveNumColumns, effectiveItemWidth, type, nowPlayingMovies, colors.white, optimizePosterUrl]); }, [navigation, styles, effectiveNumColumns, effectiveItemWidth, screenData, type, nowPlayingMovies, colors.white, colors.mediumGray, optimizePosterUrl, addonId, isDarkMode, showTitles]);
const renderEmptyState = () => ( const renderEmptyState = () => (
<View style={styles.centered}> <View style={styles.centered}>

View file

@ -177,17 +177,17 @@ const createStyles = (colors: any) => StyleSheet.create({
optionChipTextSelected: { optionChipTextSelected: {
color: colors.white, color: colors.white,
}, },
hintRow: { hintRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 6,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 8, paddingVertical: 8,
}, },
hintText: { hintText: {
fontSize: 12, fontSize: 12,
color: colors.mediumGray, color: colors.mediumGray,
}, },
enabledCount: { enabledCount: {
fontSize: 15, fontSize: 15,
color: colors.mediumGray, color: colors.mediumGray,
@ -268,6 +268,7 @@ const CatalogSettingsScreen = () => {
const [settings, setSettings] = useState<CatalogSetting[]>([]); const [settings, setSettings] = useState<CatalogSetting[]>([]);
const [groupedSettings, setGroupedSettings] = useState<GroupedCatalogs>({}); const [groupedSettings, setGroupedSettings] = useState<GroupedCatalogs>({});
const [mobileColumns, setMobileColumns] = useState<'auto' | 2 | 3>('auto'); const [mobileColumns, setMobileColumns] = useState<'auto' | 2 | 3>('auto');
const [showTitles, setShowTitles] = useState(true); // Default to showing titles
const navigation = useNavigation(); const navigation = useNavigation();
const { refreshCatalogs } = useCatalogContext(); const { refreshCatalogs } = useCatalogContext();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
@ -375,6 +376,10 @@ const CatalogSettingsScreen = () => {
if (pref === '2') setMobileColumns(2); if (pref === '2') setMobileColumns(2);
else if (pref === '3') setMobileColumns(3); else if (pref === '3') setMobileColumns(3);
else setMobileColumns('auto'); else setMobileColumns('auto');
// Load show titles preference (default: true)
const titlesPref = await mmkvStorage.getItem('catalog_show_titles');
setShowTitles(titlesPref !== 'false'); // Default to true if not set
} catch (e) { } catch (e) {
// ignore // ignore
} }
@ -474,19 +479,19 @@ const CatalogSettingsScreen = () => {
await mmkvStorage.setItem(CATALOG_CUSTOM_NAMES_KEY, JSON.stringify(customNames)); await mmkvStorage.setItem(CATALOG_CUSTOM_NAMES_KEY, JSON.stringify(customNames));
// Clear in-memory cache so new name is used immediately // Clear in-memory cache so new name is used immediately
try { clearCustomNameCache(); } catch {} try { clearCustomNameCache(); } catch { }
// --- Reload settings to reflect the change --- // --- Reload settings to reflect the change ---
await loadSettings(); await loadSettings();
// Also trigger home/catalog consumers to refresh // Also trigger home/catalog consumers to refresh
try { refreshCatalogs(); } catch {} try { refreshCatalogs(); } catch { }
// --- No need to manually update local state anymore --- // --- No need to manually update local state anymore ---
} catch (error) { } catch (error) {
logger.error('Failed to save custom catalog name:', error); logger.error('Failed to save custom catalog name:', error);
setAlertTitle('Error'); setAlertTitle('Error');
setAlertMessage('Could not save the custom name.'); setAlertMessage('Could not save the custom name.');
setAlertActions([{ label: 'OK', onPress: () => {} }]); setAlertActions([{ label: 'OK', onPress: () => { } }]);
setAlertVisible(true); setAlertVisible(true);
} finally { } finally {
setIsRenameModalVisible(false); setIsRenameModalVisible(false);
@ -552,7 +557,7 @@ const CatalogSettingsScreen = () => {
try { try {
await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, 'auto'); await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, 'auto');
setMobileColumns('auto'); setMobileColumns('auto');
} catch {} } catch { }
}} }}
activeOpacity={0.7} activeOpacity={0.7}
> >
@ -564,7 +569,7 @@ const CatalogSettingsScreen = () => {
try { try {
await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, '2'); await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, '2');
setMobileColumns(2); setMobileColumns(2);
} catch {} } catch { }
}} }}
activeOpacity={0.7} activeOpacity={0.7}
> >
@ -576,7 +581,7 @@ const CatalogSettingsScreen = () => {
try { try {
await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, '3'); await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, '3');
setMobileColumns(3); setMobileColumns(3);
} catch {} } catch { }
}} }}
activeOpacity={0.7} activeOpacity={0.7}
> >
@ -623,30 +628,30 @@ const CatalogSettingsScreen = () => {
<Text style={styles.hintText}>Long-press a catalog to rename</Text> <Text style={styles.hintText}>Long-press a catalog to rename</Text>
</View> </View>
{group.catalogs.map((setting, index) => ( {group.catalogs.map((setting, index) => (
<Pressable <Pressable
key={`${setting.addonId}:${setting.type}:${setting.catalogId}`} key={`${setting.addonId}:${setting.type}:${setting.catalogId}`}
onLongPress={() => handleLongPress(setting)} // Added long press handler onLongPress={() => handleLongPress(setting)} // Added long press handler
style={({ pressed }) => [ style={({ pressed }) => [
styles.catalogItem, styles.catalogItem,
pressed && styles.catalogItemPressed, // Optional pressed style pressed && styles.catalogItemPressed, // Optional pressed style
]} ]}
> >
<View style={styles.catalogInfo}> <View style={styles.catalogInfo}>
<Text style={styles.catalogName}> <Text style={styles.catalogName}>
{setting.customName || setting.name} {/* Display custom or default name */} {setting.customName || setting.name} {/* Display custom or default name */}
</Text> </Text>
<Text style={styles.catalogType}> <Text style={styles.catalogType}>
{setting.type.charAt(0).toUpperCase() + setting.type.slice(1)} {setting.type.charAt(0).toUpperCase() + setting.type.slice(1)}
</Text> </Text>
</View> </View>
<Switch <Switch
value={setting.enabled} value={setting.enabled}
onValueChange={() => toggleCatalog(addonId, index)} onValueChange={() => toggleCatalog(addonId, index)}
trackColor={{ false: '#505050', true: colors.primary }} trackColor={{ false: '#505050', true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.white : undefined} thumbColor={Platform.OS === 'android' ? colors.white : undefined}
ios_backgroundColor="#505050" ios_backgroundColor="#505050"
/> />
</Pressable> </Pressable>
))} ))}
</> </>
)} )}

View file

@ -393,45 +393,49 @@ const LibraryScreen = () => {
return folders.filter(folder => folder.itemCount > 0); return folders.filter(folder => folder.itemCount > 0);
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); }, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
const renderItem = ({ item }: { item: LibraryItem }) => ( const renderItem = ({ item }: { item: LibraryItem }) => {
<TouchableOpacity const aspectRatio = item.posterShape === 'landscape' ? 16 / 9 : (item.posterShape === 'square' ? 1 : 2 / 3);
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })} return (
onLongPress={() => { <TouchableOpacity
setSelectedItem(item); style={[styles.itemContainer, { width: itemWidth }]}
setMenuVisible(true); onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
}} onLongPress={() => {
activeOpacity={0.7} setSelectedItem(item);
> setMenuVisible(true);
<View> }}
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}> activeOpacity={0.7}
<FastImage >
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }} <View>
style={styles.poster} <View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black, aspectRatio }]}>
resizeMode={FastImage.resizeMode.cover} <FastImage
/> source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
{item.watched && ( style={styles.poster}
<View style={styles.watchedIndicator}> resizeMode={FastImage.resizeMode.cover}
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success || '#4CAF50'} /> />
</View> {item.watched && (
)} <View style={styles.watchedIndicator}>
{item.progress !== undefined && item.progress < 1 && ( <MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success || '#4CAF50'} />
<View style={styles.progressBarContainer}> </View>
<View )}
style={[ {item.progress !== undefined && item.progress < 1 && (
styles.progressBar, <View style={styles.progressBarContainer}>
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary } <View
]} style={[
/> styles.progressBar,
</View> { width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary }
)} ]}
/>
</View>
)}
</View>
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
{item.name}
</Text>
</View> </View>
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}> </TouchableOpacity>
{item.name} );
</Text> };
</View>
</TouchableOpacity>
);
// Render individual Trakt collection folder // Render individual Trakt collection folder
const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => ( const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => (

View file

@ -615,6 +615,25 @@ const SearchScreen = () => {
}) => { }) => {
const [inLibrary, setInLibrary] = React.useState(!!item.inLibrary); const [inLibrary, setInLibrary] = React.useState(!!item.inLibrary);
const [watched, setWatched] = React.useState(false); const [watched, setWatched] = React.useState(false);
// Calculate dimensions based on poster shape
const { itemWidth, aspectRatio } = useMemo(() => {
const shape = item.posterShape || 'poster';
const baseHeight = HORIZONTAL_POSTER_HEIGHT;
let w = HORIZONTAL_ITEM_WIDTH;
let r = 2 / 3;
if (shape === 'landscape') {
r = 16 / 9;
w = baseHeight * r;
} else if (shape === 'square') {
r = 1;
w = baseHeight;
}
return { itemWidth: w, aspectRatio: r };
}, [item.posterShape]);
React.useEffect(() => { React.useEffect(() => {
const updateWatched = () => { const updateWatched = () => {
mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true')); mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
@ -630,9 +649,10 @@ const SearchScreen = () => {
}); });
return () => unsubscribe(); return () => unsubscribe();
}, [item.id, item.type]); }, [item.id, item.type]);
return ( return (
<TouchableOpacity <TouchableOpacity
style={styles.horizontalItem} style={[styles.horizontalItem, { width: itemWidth }]}
onPress={() => { onPress={() => {
navigation.navigate('Metadata', { id: item.id, type: item.type }); navigation.navigate('Metadata', { id: item.id, type: item.type });
}} }}
@ -645,6 +665,11 @@ const SearchScreen = () => {
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={[styles.horizontalItemPosterContainer, { <View style={[styles.horizontalItemPosterContainer, {
width: itemWidth,
height: undefined, // Let aspect ratio control height or keep fixed height with width?
// Actually, since we derived width from fixed height, we can keep height fixed or use aspect.
// Using aspect ratio is safer if baseHeight changes.
aspectRatio: aspectRatio,
backgroundColor: currentTheme.colors.darkBackground, backgroundColor: currentTheme.colors.darkBackground,
borderColor: 'rgba(255,255,255,0.05)' borderColor: 'rgba(255,255,255,0.05)'
}]}> }]}>

View file

@ -56,7 +56,7 @@ export interface StreamingContent {
name: string; name: string;
tmdbId?: number; tmdbId?: number;
poster: string; poster: string;
posterShape?: string; posterShape?: 'poster' | 'square' | 'landscape';
banner?: string; banner?: string;
logo?: string; logo?: string;
imdbRating?: string; imdbRating?: string;
@ -835,7 +835,7 @@ class CatalogService {
type: meta.type, type: meta.type,
name: meta.name, name: meta.name,
poster: posterUrl, poster: posterUrl,
posterShape: 'poster', posterShape: meta.posterShape || 'poster', // Use addon's shape or default to poster type
banner: meta.background, banner: meta.background,
logo: logoUrl, logo: logoUrl,
imdbRating: meta.imdbRating, imdbRating: meta.imdbRating,
@ -857,7 +857,7 @@ class CatalogService {
type: meta.type, type: meta.type,
name: meta.name, name: meta.name,
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image', poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
posterShape: 'poster', posterShape: meta.posterShape || 'poster',
banner: meta.background, banner: meta.background,
// Use addon's logo if available, otherwise undefined // Use addon's logo if available, otherwise undefined
logo: (meta as any).logo || undefined, logo: (meta as any).logo || undefined,

View file

@ -20,6 +20,7 @@ export interface Meta {
type: string; type: string;
name: string; name: string;
poster?: string; poster?: string;
posterShape?: 'poster' | 'square' | 'landscape'; // For variable aspect ratios
background?: string; background?: string;
logo?: string; logo?: string;
description?: string; description?: string;