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

@ -41,7 +41,7 @@ const getDeviceType = (screenWidth: number) => {
// Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => {
const deviceType = getDeviceType(screenWidth);
// Responsive sizing based on device type
const MIN_POSTER_WIDTH = deviceType === 'tv' ? 180 : deviceType === 'largeTablet' ? 160 : deviceType === 'tablet' ? 140 : 100;
const MAX_POSTER_WIDTH = deviceType === 'tv' ? 220 : deviceType === 'largeTablet' ? 200 : deviceType === 'tablet' ? 180 : 130;
@ -52,9 +52,9 @@ const calculatePosterLayout = (screenWidth: number) => {
const availableWidth = screenWidth - LEFT_PADDING;
// Try different numbers of full posters to find the best fit
let bestLayout = {
numFullPosters: 3,
posterWidth: deviceType === 'tv' ? 200 : deviceType === 'largeTablet' ? 180 : deviceType === 'tablet' ? 160 : 120
let bestLayout = {
numFullPosters: 3,
posterWidth: deviceType === 'tv' ? 200 : deviceType === 'largeTablet' ? 180 : deviceType === 'tablet' ? 160 : 120
};
for (let n = 3; n <= 6; n++) {
@ -96,7 +96,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
return () => unsubscribe();
}, [item.id, item.type]);
// Load watched state from AsyncStorage when item changes
// Load watched state from AsyncStorage when item changes
useEffect(() => {
const updateWatched = () => {
mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then((val: string | null) => setIsWatched(val === 'true'));
@ -126,7 +126,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
const posterWidth = React.useMemo(() => {
const deviceType = getDeviceType(width);
const sizeMultiplier = deviceType === 'tv' ? 1.2 : deviceType === 'largeTablet' ? 1.1 : deviceType === 'tablet' ? 1.0 : 0.9;
switch (settings.posterSize) {
case 'small':
return Math.max(90, POSTER_WIDTH - 15) * sizeMultiplier;
@ -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,24 +301,24 @@ 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
source={{
source={{
uri: optimizedPosterUrl,
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,14 +330,14 @@ 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>
)}
{imageError && (
<View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="broken-image" size={24} color={currentTheme.colors.textMuted} />
</View>
)}
@ -350,14 +364,14 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
</View>
</TouchableOpacity>
{settings.showPosterTitles && (
<Text
<Text
style={[
styles.title,
{
styles.title,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: getDeviceType(width) === 'tv' ? 16 : getDeviceType(width) === 'largeTablet' ? 15 : getDeviceType(width) === 'tablet' ? 14 : 13
}
]}
]}
numberOfLines={2}
>
{item.name}

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

@ -177,17 +177,17 @@ const createStyles = (colors: any) => StyleSheet.create({
optionChipTextSelected: {
color: colors.white,
},
hintRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 16,
paddingVertical: 8,
},
hintText: {
fontSize: 12,
color: colors.mediumGray,
},
hintRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 16,
paddingVertical: 8,
},
hintText: {
fontSize: 12,
color: colors.mediumGray,
},
enabledCount: {
fontSize: 15,
color: colors.mediumGray,
@ -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();
@ -288,11 +289,11 @@ const CatalogSettingsScreen = () => {
const loadSettings = useCallback(async () => {
try {
setLoading(true);
// Get installed addons and their catalogs
const addons = await stremioService.getInstalledAddonsAsync();
const availableCatalogs: CatalogSetting[] = [];
// Get saved enable/disable settings
const savedSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY);
const savedEnabledSettings: { [key: string]: boolean } = savedSettingsJson ? JSON.parse(savedSettingsJson) : {};
@ -300,12 +301,12 @@ const CatalogSettingsScreen = () => {
// Get saved custom names
const savedCustomNamesJson = await mmkvStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
const savedCustomNames: { [key: string]: string } = savedCustomNamesJson ? JSON.parse(savedCustomNamesJson) : {};
// Process each addon's catalogs
addons.forEach(addon => {
if (addon.catalogs && addon.catalogs.length > 0) {
const uniqueCatalogs = new Map<string, CatalogSetting>();
addon.catalogs.forEach(catalog => {
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
let displayName = catalog.name || catalog.id;
@ -330,7 +331,7 @@ const CatalogSettingsScreen = () => {
if (!displayName.toLowerCase().includes(catalogType.toLowerCase())) {
displayName = `${displayName} ${catalogType}`.trim();
}
uniqueCatalogs.set(settingKey, {
addonId: addon.id,
catalogId: catalog.id,
@ -340,32 +341,32 @@ const CatalogSettingsScreen = () => {
customName: savedCustomNames[settingKey]
});
});
availableCatalogs.push(...uniqueCatalogs.values());
}
});
// Group settings by addon name
const grouped: GroupedCatalogs = {};
availableCatalogs.forEach(setting => {
const addon = addons.find(a => a.id === setting.addonId);
if (!addon) return;
if (!grouped[setting.addonId]) {
grouped[setting.addonId] = {
name: addon.name,
catalogs: [],
expanded: true,
expanded: true,
enabledCount: 0
};
}
grouped[setting.addonId].catalogs.push(setting);
if (setting.enabled) {
grouped[setting.addonId].enabledCount++;
}
});
setSettings(availableCatalogs);
setGroupedSettings(grouped);
@ -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
}
@ -396,7 +401,7 @@ const CatalogSettingsScreen = () => {
settingsObj[key] = setting.enabled;
});
await mmkvStorage.setItem(CATALOG_SETTINGS_KEY, JSON.stringify(settingsObj));
// Small delay to ensure AsyncStorage has fully persisted before triggering refresh
setTimeout(() => {
refreshCatalogs(); // Trigger catalog refresh after saving settings
@ -411,26 +416,26 @@ const CatalogSettingsScreen = () => {
const newSettings = [...settings];
const catalogsForAddon = groupedSettings[addonId].catalogs;
const setting = catalogsForAddon[index];
const updatedSetting = {
...setting,
enabled: !setting.enabled
};
const flatIndex = newSettings.findIndex(s =>
s.addonId === setting.addonId &&
s.type === setting.type &&
const flatIndex = newSettings.findIndex(s =>
s.addonId === setting.addonId &&
s.type === setting.type &&
s.catalogId === setting.catalogId
);
if (flatIndex !== -1) {
newSettings[flatIndex] = updatedSetting;
}
const newGroupedSettings = { ...groupedSettings };
newGroupedSettings[addonId].catalogs[index] = updatedSetting;
newGroupedSettings[addonId].enabledCount += updatedSetting.enabled ? 1 : -1;
setSettings(newSettings);
setGroupedSettings(newGroupedSettings);
saveEnabledSettings(newSettings); // Use specific save function
@ -459,11 +464,11 @@ const CatalogSettingsScreen = () => {
if (!catalogToRename || !currentRenameValue) return;
const settingKey = `${catalogToRename.addonId}:${catalogToRename.type}:${catalogToRename.catalogId}`;
try {
const savedCustomNamesJson = await mmkvStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
const customNames: { [key: string]: string } = savedCustomNamesJson ? JSON.parse(savedCustomNamesJson) : {};
const trimmedNewName = currentRenameValue.trim();
if (trimmedNewName === catalogToRename.name || trimmedNewName === '') {
@ -471,22 +476,22 @@ const CatalogSettingsScreen = () => {
} else {
customNames[settingKey] = trimmedNewName;
}
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();
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);
@ -533,7 +538,7 @@ const CatalogSettingsScreen = () => {
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>Catalogs</Text>
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
{/* Layout (Mobile only) */}
{Platform.OS && (
@ -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}
>
@ -596,9 +601,9 @@ const CatalogSettingsScreen = () => {
<Text style={styles.addonTitle}>
{group.name.toUpperCase()}
</Text>
<View style={styles.card}>
<TouchableOpacity
<TouchableOpacity
style={styles.groupHeader}
onPress={() => toggleExpansion(addonId)}
activeOpacity={0.7}
@ -608,14 +613,14 @@ const CatalogSettingsScreen = () => {
<Text style={styles.enabledCount}>
{group.enabledCount} of {group.catalogs.length} enabled
</Text>
<MaterialIcons
name={group.expanded ? "keyboard-arrow-down" : "keyboard-arrow-right"}
size={24}
color={colors.mediumGray}
<MaterialIcons
name={group.expanded ? "keyboard-arrow-down" : "keyboard-arrow-right"}
size={24}
color={colors.mediumGray}
/>
</View>
</TouchableOpacity>
{group.expanded && (
<>
<View style={styles.hintRow}>
@ -623,30 +628,30 @@ const CatalogSettingsScreen = () => {
<Text style={styles.hintText}>Long-press a catalog to rename</Text>
</View>
{group.catalogs.map((setting, index) => (
<Pressable
key={`${setting.addonId}:${setting.type}:${setting.catalogId}`}
onLongPress={() => handleLongPress(setting)} // Added long press handler
style={({ pressed }) => [
styles.catalogItem,
pressed && styles.catalogItemPressed, // Optional pressed style
]}
>
<View style={styles.catalogInfo}>
<Text style={styles.catalogName}>
{setting.customName || setting.name} {/* Display custom or default name */}
</Text>
<Text style={styles.catalogType}>
{setting.type.charAt(0).toUpperCase() + setting.type.slice(1)}
</Text>
</View>
<Switch
value={setting.enabled}
onValueChange={() => toggleCatalog(addonId, index)}
trackColor={{ false: '#505050', true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.white : undefined}
ios_backgroundColor="#505050"
/>
</Pressable>
<Pressable
key={`${setting.addonId}:${setting.type}:${setting.catalogId}`}
onLongPress={() => handleLongPress(setting)} // Added long press handler
style={({ pressed }) => [
styles.catalogItem,
pressed && styles.catalogItemPressed, // Optional pressed style
]}
>
<View style={styles.catalogInfo}>
<Text style={styles.catalogName}>
{setting.customName || setting.name} {/* Display custom or default name */}
</Text>
<Text style={styles.catalogType}>
{setting.type.charAt(0).toUpperCase() + setting.type.slice(1)}
</Text>
</View>
<Switch
value={setting.enabled}
onValueChange={() => toggleCatalog(addonId, index)}
trackColor={{ false: '#505050', true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.white : undefined}
ios_backgroundColor="#505050"
/>
</Pressable>
))}
</>
)}
@ -706,8 +711,8 @@ const CatalogSettingsScreen = () => {
)}
</Pressable>
) : (
<Pressable style={styles.modalOverlay} onPress={() => setIsRenameModalVisible(false)}>
<Pressable style={styles.modalContent} onPress={(e) => e.stopPropagation()}>
<Pressable style={styles.modalOverlay} onPress={() => setIsRenameModalVisible(false)}>
<Pressable style={styles.modalContent} onPress={(e) => e.stopPropagation()}>
<Text style={styles.modalTitle}>Rename Catalog</Text>
<TextInput
style={styles.modalInput}

View file

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