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
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 (
<View
@ -194,7 +181,6 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
}
])}
ItemSeparatorComponent={ItemSeparator}
getItemLayout={getItemLayout}
removeClippedSubviews={true}
initialNumToRender={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3}
maxToRenderPerBatch={isTV ? 4 : isLargeTablet ? 4 : 3}

View file

@ -139,6 +139,30 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
}
}, [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
const itemRef = useRef<View>(null);
@ -169,7 +193,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
setIsWatched(targetWatched);
try {
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');
setTimeout(() => {
DeviceEventEmitter.emit('watchedStatusChanged');
@ -185,7 +209,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
undefined,
{ forceNotify: true, forceWrite: true }
);
} catch {}
} catch { }
if (item.type === 'movie') {
try {
@ -194,9 +218,9 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
await trakt.addToWatchedMovies(item.id);
try {
await storageService.updateTraktSyncStatus(item.id, item.type, true, 100);
} catch {}
} catch { }
}
} catch {}
} catch { }
}
}
setMenuVisible(false);
@ -242,44 +266,34 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
setMenuVisible(false);
}, []);
// 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';
}
// For TMDB images, use smaller sizes
if (item.poster.includes('image.tmdb.org')) {
// Replace any size with w154 (fits 100-130px tiles perfectly)
return item.poster.replace(/\/w\d+\//, '/w154/');
}
// For metahub images, use smaller sizes
if (item.poster.includes('placeholder')) {
return item.poster.replace('/medium/', '/small/');
}
// Return original URL for other sources to avoid breaking them
return item.poster;
}, [item.poster, item.id]);
// While settings load, render a placeholder with reserved space (poster aspect + title)
if (!isLoaded) {
const placeholderRadius = 12;
return (
<View style={[styles.itemContainer, { width: posterWidth }]}>
<View style={[styles.itemContainer, { width: finalWidth }]}>
<View
style={[
styles.contentItem,
{
width: posterWidth,
borderRadius: placeholderRadius,
width: finalWidth,
aspectRatio: finalAspectRatio,
borderRadius,
backgroundColor: currentTheme.colors.elevation1,
},
]}
/>
{/* Reserve space for title to keep section spacing stable */}
<View style={{ height: 18, marginTop: 4 }} />
</View>
);
@ -287,15 +301,15 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
return (
<>
<Animated.View style={[styles.itemContainer, { width: posterWidth }]} entering={FadeIn.duration(300)}>
<Animated.View style={[styles.itemContainer, { width: finalWidth }]} entering={FadeIn.duration(300)}>
<TouchableOpacity
style={[styles.contentItem, { width: posterWidth, borderRadius: posterRadius }]}
style={[styles.contentItem, { width: finalWidth, aspectRatio: finalAspectRatio, borderRadius }]}
activeOpacity={0.7}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
>
<View ref={itemRef} style={[styles.contentItemContainer, { borderRadius: posterRadius }] }>
<View ref={itemRef} style={[styles.contentItemContainer, { borderRadius }]}>
{/* Image with FastImage for aggressive caching */}
{item.poster ? (
<FastImage
@ -304,7 +318,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
priority: FastImage.priority.normal,
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}
onLoad={() => {
setImageError(false);
@ -316,7 +330,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
/>
) : (
// 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' }}>
{item.name.substring(0, 20)}...
</Text>

View file

@ -289,6 +289,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const [catalogExtras, setCatalogExtras] = useState<CatalogExtra[]>([]);
const [selectedFilters, setSelectedFilters] = useState<Record<string, string>>({});
const [activeGenreFilter, setActiveGenreFilter] = useState<string | undefined>(genreFilter);
const [showTitles, setShowTitles] = useState(true); // Default to showing titles
const { currentTheme } = useTheme();
const colors = currentTheme.colors;
const styles = createStyles(colors);
@ -302,6 +303,10 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
if (pref === '2') setMobileColumnsPref(2);
else if (pref === '3') setMobileColumnsPref(3);
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 { }
})();
}, []);
@ -556,11 +561,14 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
let nextHasMore = false;
try {
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
// This handles addons with different page sizes (not just 50 items per page)
nextHasMore = typeof svcHasMore === 'boolean' ? svcHasMore : (catalogItems.length > 0);
// If service explicitly provides hasMore, use it
// Otherwise, only assume there's more if we got a reasonable number of items (>= 5)
// 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 {
nextHasMore = catalogItems.length > 0;
// Fallback: only assume more if we got at least 5 items
nextHasMore = catalogItems.length >= 5;
}
setHasMore(nextHasMore);
logger.log('[CatalogScreen] Updated items and hasMore', {
@ -749,6 +757,10 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
// For proper spacing
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 (
<TouchableOpacity
style={[
@ -763,7 +775,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
>
<FastImage
source={{ uri: optimizePosterUrl(item.poster) }}
style={styles.poster}
style={[styles.poster, { aspectRatio }]}
resizeMode={FastImage.resizeMode.cover}
/>
@ -808,9 +820,26 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
</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>
);
}, [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 = () => (
<View style={styles.centered}>

View file

@ -268,6 +268,7 @@ const CatalogSettingsScreen = () => {
const [settings, setSettings] = useState<CatalogSetting[]>([]);
const [groupedSettings, setGroupedSettings] = useState<GroupedCatalogs>({});
const [mobileColumns, setMobileColumns] = useState<'auto' | 2 | 3>('auto');
const [showTitles, setShowTitles] = useState(true); // Default to showing titles
const navigation = useNavigation();
const { refreshCatalogs } = useCatalogContext();
const { currentTheme } = useTheme();
@ -375,6 +376,10 @@ const CatalogSettingsScreen = () => {
if (pref === '2') setMobileColumns(2);
else if (pref === '3') setMobileColumns(3);
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) {
// ignore
}
@ -474,19 +479,19 @@ const CatalogSettingsScreen = () => {
await mmkvStorage.setItem(CATALOG_CUSTOM_NAMES_KEY, JSON.stringify(customNames));
// Clear in-memory cache so new name is used immediately
try { clearCustomNameCache(); } catch {}
try { clearCustomNameCache(); } catch { }
// --- Reload settings to reflect the change ---
await loadSettings();
// Also trigger home/catalog consumers to refresh
try { refreshCatalogs(); } catch {}
try { refreshCatalogs(); } catch { }
// --- No need to manually update local state anymore ---
} catch (error) {
logger.error('Failed to save custom catalog name:', error);
setAlertTitle('Error');
setAlertMessage('Could not save the custom name.');
setAlertActions([{ label: 'OK', onPress: () => {} }]);
setAlertActions([{ label: 'OK', onPress: () => { } }]);
setAlertVisible(true);
} finally {
setIsRenameModalVisible(false);
@ -552,7 +557,7 @@ const CatalogSettingsScreen = () => {
try {
await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, 'auto');
setMobileColumns('auto');
} catch {}
} catch { }
}}
activeOpacity={0.7}
>
@ -564,7 +569,7 @@ const CatalogSettingsScreen = () => {
try {
await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, '2');
setMobileColumns(2);
} catch {}
} catch { }
}}
activeOpacity={0.7}
>
@ -576,7 +581,7 @@ const CatalogSettingsScreen = () => {
try {
await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, '3');
setMobileColumns(3);
} catch {}
} catch { }
}}
activeOpacity={0.7}
>

View file

@ -393,7 +393,10 @@ const LibraryScreen = () => {
return folders.filter(folder => folder.itemCount > 0);
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
const renderItem = ({ item }: { item: LibraryItem }) => (
const renderItem = ({ item }: { item: LibraryItem }) => {
const aspectRatio = item.posterShape === 'landscape' ? 16 / 9 : (item.posterShape === 'square' ? 1 : 2 / 3);
return (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
@ -404,7 +407,7 @@ const LibraryScreen = () => {
activeOpacity={0.7}
>
<View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black, aspectRatio }]}>
<FastImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
@ -432,6 +435,7 @@ const LibraryScreen = () => {
</View>
</TouchableOpacity>
);
};
// Render individual Trakt collection folder
const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => (

View file

@ -615,6 +615,25 @@ const SearchScreen = () => {
}) => {
const [inLibrary, setInLibrary] = React.useState(!!item.inLibrary);
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(() => {
const updateWatched = () => {
mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
@ -630,9 +649,10 @@ const SearchScreen = () => {
});
return () => unsubscribe();
}, [item.id, item.type]);
return (
<TouchableOpacity
style={styles.horizontalItem}
style={[styles.horizontalItem, { width: itemWidth }]}
onPress={() => {
navigation.navigate('Metadata', { id: item.id, type: item.type });
}}
@ -645,6 +665,11 @@ const SearchScreen = () => {
activeOpacity={0.7}
>
<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,
borderColor: 'rgba(255,255,255,0.05)'
}]}>

View file

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

View file

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