mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
landscape poster support
This commit is contained in:
parent
59cb902658
commit
601a4a0f1d
8 changed files with 236 additions and 172 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
|
|
|
|||
|
|
@ -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)'
|
||||
}]}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue