From dd7d906ef66b8b716a85d446c29dbb86208190eb Mon Sep 17 00:00:00 2001 From: Matthew Sigal Date: Wed, 15 Jul 2026 10:44:13 -0700 Subject: [PATCH 1/2] feat: implement artwork selector with d-pad navigation - Ported Artwork Selector from core to Smart-TV client. - Added ChangeArtworkModal component using Enact/Sandstone with full D-pad support. - Integrated ChangeArtworkModal in Details view and ModernDetailContent. - Added API endpoints in jellyfinApi.js for remote images and write-access checks. - Handled write permission warnings and local metadata checks in the modal. - Add open prop guard to prevent premature rendering before load. --- packages/app/resources/strings.json | 9 +- .../ChangeArtworkModal/ChangeArtworkModal.js | 1033 +++++++++++++++++ .../ChangeArtworkModal.module.less | 599 ++++++++++ .../components/ChangeArtworkModal/index.js | 1 + packages/app/src/services/jellyfinApi.js | 47 +- packages/app/src/views/Details/Details.js | 63 +- .../src/views/Details/ModernDetailContent.js | 4 +- packages/app/src/views/Details/detailIcons.js | 1 + 8 files changed, 1749 insertions(+), 8 deletions(-) create mode 100644 packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.js create mode 100644 packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.module.less create mode 100644 packages/app/src/components/ChangeArtworkModal/index.js diff --git a/packages/app/resources/strings.json b/packages/app/resources/strings.json index cb97763d..319cf51e 100644 --- a/packages/app/resources/strings.json +++ b/packages/app/resources/strings.json @@ -761,6 +761,13 @@ "requested": "requested", "failed": "failed", "Request in 4K": "Request in 4K", - "Requests": "Requests" + "Requests": "Requests", + "Change Artwork": "Change Artwork", + "Sources": "Sources", + "Only show interface language": "Only show interface language", + "Clear All Artwork": "Clear All Artwork", + "High (1080p+)": "High (1080p+)", + "Medium (720p)": "Medium (720p)", + "Low (<720p)": "Low (<720p)" } diff --git a/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.js b/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.js new file mode 100644 index 00000000..061451e7 --- /dev/null +++ b/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.js @@ -0,0 +1,1033 @@ +import {useState, useEffect, useCallback, useRef, useMemo} from 'react'; +import Spottable from '@enact/spotlight/Spottable'; +import Spotlight from '@enact/spotlight'; +import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator'; +import $L from '@enact/i18n/$L'; +import {Scroller} from '@enact/sandstone/Scroller'; +import {getImageUrl} from '../../utils/helpers'; +import {isBackKey} from '../../utils/keys'; + +import css from './ChangeArtworkModal.module.less'; + +const ModalContainer = SpotlightContainerDecorator({ + enterTo: 'default-element', + restrict: 'self-only', + leaveFor: {left: '', right: '', up: '', down: ''} +}, 'div'); + +const RestrictedContainer = SpotlightContainerDecorator({ + enterTo: 'default-element', + restrict: 'self-only', + leaveFor: {left: '', right: '', up: '', down: ''} +}, 'div'); + +const SpottableDiv = Spottable('div'); +const SpottableButton = Spottable('button'); + +const RESOLUTIONS = ['All', 'High (1080p+)', 'Medium (720p)', 'Low (<720p)']; + +const getSupportedImageTypes = (itemType) => { + const type = itemType?.toLowerCase(); + switch (type) { + case 'movie': + return ['Primary', 'Backdrop', 'Banner', 'Logo', 'Thumb', 'Art', 'Disc']; + case 'series': + return ['Primary', 'Backdrop', 'Banner', 'Logo', 'Thumb', 'Art']; + case 'season': + return ['Primary', 'Backdrop', 'Banner']; + case 'episode': + return ['Primary']; + case 'musicvideo': + return ['Primary', 'Backdrop', 'Banner', 'Logo', 'Thumb']; + case 'trailer': + return ['Primary', 'Backdrop', 'Thumb']; + case 'boxset': + return ['Primary', 'Backdrop', 'Banner', 'Logo', 'Thumb']; + case 'playlist': + return ['Primary', 'Backdrop']; + case 'musicartist': + return ['Primary', 'Backdrop', 'Banner', 'Logo']; + case 'musicalbum': + return ['Primary', 'Backdrop', 'Disc']; + case 'audio': + return ['Primary']; + case 'book': + case 'audiobook': + return ['Primary']; + case 'folder': + case 'collectionfolder': + case 'userview': + case 'genre': + case 'musicgenre': + return ['Primary', 'Backdrop', 'Thumb']; + default: + return ['Primary', 'Backdrop']; + } +}; + +const getCategoryDisplayName = (category, itemType) => { + switch (category) { + case 'Primary': + return itemType?.toLowerCase() === 'episode' ? $L('Thumbnail') : $L('Poster'); + case 'Backdrop': + return $L('Backdrops'); + case 'Banner': + return $L('Banner'); + case 'Logo': + return $L('Logo'); + case 'Thumb': + return $L('Thumbnail'); + case 'Art': + return $L('Art'); + case 'Disc': + return $L('Disc Art'); + default: + return category; + } +}; + +const getImageDimensions = (category, itemType) => { + if (category === 'Primary' && itemType?.toLowerCase() === 'episode') { + return {width: 280, height: 158}; // 16:9 for episode primary images + } + switch (category) { + case 'Primary': + return {width: 160, height: 240}; // 2:3 + case 'Backdrop': + case 'Thumb': + case 'Screenshot': + return {width: 280, height: 158}; // 16:9 + case 'Banner': + return {width: 350, height: 70}; // 5:1 + case 'Logo': + case 'Art': + return {width: 200, height: 80}; // ~2.5:1 + case 'Disc': + return {width: 160, height: 160}; // 1:1 + default: + return {width: 160, height: 240}; + } +}; + +const getCurrentTags = (item, category) => { + if (category === 'Backdrop') { + return item.BackdropImageTags || []; + } + const tag = item.ImageTags?.[category]; + return tag ? [tag] : []; +}; + +const getOptimizedRemoteImageUrl = (url, category, targetWidth) => { + if (!url) return ''; + + // 1. TMDB Optimization + if (url.includes('image.tmdb.org/t/p/original/')) { + if (targetWidth) { + if (category === 'Backdrop' || category === 'Thumb' || category === 'Screenshot') { + return targetWidth <= 780 ? url.replace('/original/', '/w780/') : url.replace('/original/', '/w1280/'); + } else if (category === 'Primary') { + if (targetWidth <= 342) return url.replace('/original/', '/w342/'); + if (targetWidth <= 500) return url.replace('/original/', '/w500/'); + return url.replace('/original/', '/w780/'); + } else if (category === 'Logo' || category === 'Art') { + return targetWidth <= 300 ? url.replace('/original/', '/w300/') : url.replace('/original/', '/w500/'); + } + } else { + if (category === 'Backdrop' || category === 'Thumb' || category === 'Screenshot') { + return url.replace('/original/', '/w780/'); + } else if (category === 'Primary') { + return url.replace('/original/', '/w342/'); + } else if (category === 'Logo' || category === 'Art') { + return url.replace('/original/', '/w300/'); + } + } + } + return url; +}; + +const ChangeArtworkModal = ({open, item: initialItem, api, serverUrl, onClose, onSuccess}) => { + const [activeItem, setActiveItem] = useState(initialItem); + const [history, setHistory] = useState([initialItem]); + const [historyIndex, setHistoryIndex] = useState(0); + + const [supportedCategories, setSupportedCategories] = useState([]); + const [remoteImages, setRemoteImages] = useState({}); + const [loadingCategories, setLoadingCategories] = useState({}); + const [actionInProgress, setActionInProgress] = useState(new Set()); + const [hasChanged, setHasChanged] = useState(false); + + // Filters + const [onlyShowInterfaceLanguage, setOnlyShowInterfaceLanguage] = useState(true); + const [deselectedSources, setDeselectedSources] = useState(new Set()); + const [selectedResolution, setSelectedResolution] = useState('All'); + const [focusedCategory, setFocusedCategory] = useState(null); // Expanded category + + // Overlays / Modals inside ChangeArtwork + const [showSourcesPopup, setShowSourcesPopup] = useState(false); + const [previewImage, setPreviewImage] = useState(null); // { category, image } + const [deleteConfirm, setDeleteConfirm] = useState(null); // { category, index } + const [clearAllConfirm, setClearAllConfirm] = useState(false); + const [writeAccessWarning, setWriteAccessWarning] = useState(null); // error string + + // Flag to avoid checking write access multiple times per session + const hasCheckedWriteAccess = useRef(false); + + // Initialize / load item details + const loadItem = useCallback(async (itemToLoad) => { + setActiveItem(itemToLoad); + const categories = getSupportedImageTypes(itemToLoad.Type); + setSupportedCategories(categories); + setRemoteImages({}); + setLoadingCategories({}); + setFocusedCategory(null); + + // Fetch remote images for each category + categories.forEach(async (category) => { + if (itemToLoad.Type?.toLowerCase() === 'genre' || itemToLoad.Type?.toLowerCase() === 'musicgenre') { + setRemoteImages(prev => ({...prev, [category]: []})); + return; + } + setLoadingCategories(prev => ({...prev, [category]: true})); + try { + const result = await api.getRemoteImages(itemToLoad.Id, category); + const list = result?.Images || []; + setRemoteImages(prev => ({...prev, [category]: list})); + } catch (e) { + console.warn(`Failed to fetch remote images for ${category}:`, e); + } finally { + setLoadingCategories(prev => ({...prev, [category]: false})); + } + }); + + // Proactively check write access once + if (!hasCheckedWriteAccess.current && api.checkWriteAccess) { + try { + const reports = await api.checkWriteAccess(); + hasCheckedWriteAccess.current = true; + const itemPath = itemToLoad.Path; + if (itemPath && Array.isArray(reports)) { + const matchingReport = reports.find(report => + report.FailedPaths?.some(path => itemPath.startsWith(path)) + ); + if (matchingReport) { + const libraryName = matchingReport.LibraryName || $L('Library'); + setWriteAccessWarning( + $L('The server does not have write permissions for "{libraryName}" library path. Local artwork changes may fail to save.').replace('{libraryName}', libraryName) + ); + } + } + } catch (e) { + console.warn('[Moonfin] Failed to check libraries write access:', e); + } + } + }, [api]); + + // Initialize on open + useEffect(() => { + if (open && initialItem) { + setHistory([initialItem]); + setHistoryIndex(0); + setHasChanged(false); + setFocusedCategory(null); + setDeselectedSources(new Set()); + setOnlyShowInterfaceLanguage(true); + setSelectedResolution('All'); + loadItem(initialItem); + } + }, [open, initialItem, loadItem]); + + // Spotlight focus on mount + useEffect(() => { + if (open) { + const t = setTimeout(() => Spotlight.focus('change-artwork-modal'), 150); + return () => clearTimeout(t); + } + }, [open]); + + // BACK Key Handling + useEffect(() => { + if (!open) return; + const handleKey = (e) => { + if (isBackKey(e)) { + e.preventDefault(); + e.stopPropagation(); + if (previewImage) { + setPreviewImage(null); + } else if (deleteConfirm) { + setDeleteConfirm(null); + } else if (clearAllConfirm) { + setClearAllConfirm(false); + } else if (showSourcesPopup) { + setShowSourcesPopup(false); + } else if (writeAccessWarning) { + setWriteAccessWarning(null); + } else if (focusedCategory) { + setFocusedCategory(null); + } else { + onClose?.(hasChanged); + } + } + }; + window.addEventListener('keydown', handleKey, true); + return () => window.removeEventListener('keydown', handleKey, true); + }, [open, focusedCategory, previewImage, deleteConfirm, clearAllConfirm, showSourcesPopup, writeAccessWarning, hasChanged, onClose]); + + // History Navigation + const navigateToItem = useCallback(async (itemId) => { + if (!itemId) return; + try { + const updated = await api.getItem(itemId); + if (updated) { + const nextHistory = history.slice(0, historyIndex + 1); + nextHistory.push(updated); + setHistory(nextHistory); + setHistoryIndex(nextHistory.length - 1); + loadItem(updated); + } + } catch (e) { + onSuccess?.($L('Failed to load item')); + } + }, [api, history, historyIndex, loadItem, onSuccess]); + + const goBack = useCallback(() => { + if (historyIndex > 0) { + const prevIndex = historyIndex - 1; + setHistoryIndex(prevIndex); + loadItem(history[prevIndex]); + } + }, [history, historyIndex, loadItem]); + + const goForward = useCallback(() => { + if (historyIndex < history.length - 1) { + const nextIndex = historyIndex + 1; + setHistoryIndex(nextIndex); + loadItem(history[nextIndex]); + } + }, [history, historyIndex, loadItem]); + + const refreshItemMetadata = useCallback(async () => { + try { + const updated = await api.getItem(activeItem.Id); + if (updated) { + setActiveItem(updated); + const updatedHistory = [...history]; + updatedHistory[historyIndex] = updated; + setHistory(updatedHistory); + } + } catch (e) { + console.warn('Failed to refresh item metadata:', e); + } + }, [api, activeItem, history, historyIndex]); + + // Error handler checking local metadata config + const handleActionError = useCallback(async (error, actionName) => { + let isLocalMetadataEnabled = false; + if (api.getVirtualFolders) { + try { + const folders = await api.getVirtualFolders(); + const itemPath = activeItem.Path; + if (itemPath && Array.isArray(folders)) { + const matchingFolder = folders.find(folder => + folder.Locations?.some(loc => itemPath.startsWith(loc)) + ); + if (matchingFolder) { + isLocalMetadataEnabled = matchingFolder.LibraryOptions?.SaveLocalMetadata === true; + } + } + } catch (e) { + console.warn('Failed to check virtual folders:', e); + } + } + + if (isLocalMetadataEnabled) { + setWriteAccessWarning( + $L('Saving metadata locally is enabled for this library, but the server lacks write permissions to write files to the library folder.') + ); + } else { + const msgMap = { + download: $L('Image download failed: {err}'), + delete: $L('Image delete failed: {err}'), + clear: $L('Clear artwork failed: {err}') + }; + const template = msgMap[actionName] || $L('Action failed: {err}'); + onSuccess?.(template.replace('{err}', error.message || error.toString())); + } + }, [api, activeItem, onSuccess]); + + // Download Remote Image + const downloadImage = useCallback(async (category, imageUrl) => { + if (actionInProgress.has(category)) return; + setActionInProgress(prev => new Set(prev).add(category)); + try { + await api.downloadRemoteImage(activeItem.Id, category, imageUrl); + setHasChanged(true); + await refreshItemMetadata(); + onSuccess?.($L('Artwork updated successfully')); + } catch (e) { + handleActionError(e, 'download'); + } finally { + setActionInProgress(prev => { + const next = new Set(prev); + next.delete(category); + return next; + }); + } + }, [api, activeItem, actionInProgress, refreshItemMetadata, handleActionError, onSuccess]); + + // Delete Item Image + const deleteImage = useCallback(async (category, imageIndex) => { + if (actionInProgress.has(category)) return; + setActionInProgress(prev => new Set(prev).add(category)); + try { + await api.deleteItemImage(activeItem.Id, category, imageIndex); + setHasChanged(true); + await refreshItemMetadata(); + onSuccess?.($L('Image deleted successfully')); + } catch (e) { + handleActionError(e, 'delete'); + } finally { + setActionInProgress(prev => { + const next = new Set(prev); + next.delete(category); + return next; + }); + } + }, [api, activeItem, actionInProgress, refreshItemMetadata, handleActionError, onSuccess]); + + // Clear All Artwork + const clearAllArtwork = useCallback(async () => { + const allTypes = getSupportedImageTypes(activeItem.Type); + setActionInProgress(new Set(allTypes)); + try { + for (const category of allTypes) { + const tags = getCurrentTags(activeItem, category); + if (tags.length > 0) { + if (category === 'Backdrop') { + for (let i = tags.length - 1; i >= 0; i--) { + await api.deleteItemImage(activeItem.Id, category, i); + } + } else { + await api.deleteItemImage(activeItem.Id, category); + } + } + } + setHasChanged(true); + await refreshItemMetadata(); + onSuccess?.($L('All custom artwork cleared')); + } catch (e) { + handleActionError(e, 'clear'); + } finally { + setActionInProgress(new Set()); + } + }, [api, activeItem, refreshItemMetadata, handleActionError, onSuccess]); + + // Available providers dynamic list + const availableSources = useMemo(() => { + const sources = new Set(); + Object.values(remoteImages).forEach(list => { + list.forEach(img => { + if (img.ProviderName) sources.add(img.ProviderName); + }); + }); + return Array.from(sources); + }, [remoteImages]); + + // Filter logic + const shouldShowImage = useCallback((img) => { + if (deselectedSources.has(img.ProviderName)) return false; + + if (onlyShowInterfaceLanguage) { + const lang = img.Language?.toLowerCase(); + const currentLang = (typeof navigator !== 'undefined' ? (navigator.language || 'en').split('-')[0] : 'en').toLowerCase(); + if (lang && lang !== 'all' && lang !== 'none' && lang !== 'mul' && lang !== currentLang) { + return false; + } + } + + if (selectedResolution !== 'All') { + const w = img.Width; + const h = img.Height; + if (!w || !h) return true; + const maxDim = Math.max(w, h); + if (selectedResolution === 'High (1080p+)' && maxDim < 1080) return false; + if (selectedResolution === 'Medium (720p)' && (maxDim < 720 || maxDim >= 1080)) return false; + if (selectedResolution === 'Low (<720p)' && maxDim >= 720) return false; + } + + return true; + }, [deselectedSources, onlyShowInterfaceLanguage, selectedResolution]); + + // Toggle filters + const toggleLanguageFilter = useCallback(() => { + setOnlyShowInterfaceLanguage(prev => !prev); + }, []); + + const toggleSource = useCallback((source) => { + setDeselectedSources(prev => { + const next = new Set(prev); + if (next.has(source)) { + next.delete(source); + } else { + next.add(source); + } + return next; + }); + }, []); + + // Click handlers for links + const handleBreadcrumbClick = useCallback((ev) => { + const targetId = ev.currentTarget.dataset.id; + if (targetId) navigateToItem(targetId); + }, [navigateToItem]); + + // Performance-optimized Callback Handlers to satisfy react/jsx-no-bind lints + const handleCloseClick = useCallback(() => { + onClose?.(hasChanged); + }, [onClose, hasChanged]); + + const handleOpenSourcesClick = useCallback(() => { + setShowSourcesPopup(true); + }, []); + + const handleSourcesClose = useCallback(() => { + setShowSourcesPopup(false); + }, []); + + const handleSourceItemClick = useCallback((ev) => { + const src = ev.currentTarget.dataset.source; + if (src) toggleSource(src); + }, [toggleSource]); + + const handleOpenClearAllClick = useCallback(() => { + setClearAllConfirm(true); + }, []); + + const handleDeleteConfirmYes = useCallback(() => { + if (deleteConfirm) { + deleteImage(deleteConfirm.category, deleteConfirm.index); + setDeleteConfirm(null); + } + }, [deleteConfirm, deleteImage]); + + const handleDeleteConfirmNo = useCallback(() => { + setDeleteConfirm(null); + }, []); + + const handleClearAllYes = useCallback(() => { + clearAllArtwork(); + setClearAllConfirm(false); + }, [clearAllArtwork]); + + const handleClearAllNo = useCallback(() => { + setClearAllConfirm(false); + }, []); + + const handleWarningDismiss = useCallback(() => { + setWriteAccessWarning(null); + }, []); + + const handlePreviewUse = useCallback(() => { + if (previewImage) { + downloadImage(previewImage.category, previewImage.image.Url); + setPreviewImage(null); + } + }, [previewImage, downloadImage]); + + const handlePreviewCancel = useCallback(() => { + setPreviewImage(null); + }, []); + + const handleViewAllClick = useCallback((ev) => { + const cat = ev.currentTarget.dataset.category; + if (cat) setFocusedCategory(cat); + }, []); + + const handleResolutionChipClick = useCallback((ev) => { + const res = ev.currentTarget.dataset.resolution; + if (res) setSelectedResolution(res); + }, []); + + const handleDeleteConfirmClick = useCallback((ev) => { + const category = ev.currentTarget.dataset.category; + const indexStr = ev.currentTarget.dataset.index; + const index = indexStr ? parseInt(indexStr, 10) : null; + setDeleteConfirm({category, index}); + }, []); + + const handleRemoteCardClick = useCallback((ev) => { + const category = ev.currentTarget.dataset.category; + const index = parseInt(ev.currentTarget.dataset.index, 10); + const list = (remoteImages[category] || []).filter(shouldShowImage); + const img = list[index]; + if (img) { + setPreviewImage({category, image: img}); + } + }, [remoteImages, shouldShowImage]); + + const handleCloseGridClick = useCallback(() => { + setFocusedCategory(null); + }, []); + + // Render Breadcrumbs + const renderBreadcrumbs = () => { + const seriesName = activeItem.SeriesName; + const seriesId = activeItem.SeriesId; + const seasonName = activeItem.SeasonName; + const seasonId = activeItem.SeasonId; + + const parts = []; + if (activeItem.Type === 'Episode') { + if (seriesName) { + parts.push( + + {seriesName} + + ); + parts.push( \ ); + } + if (seasonName) { + parts.push( + + {seasonName} + + ); + parts.push( \ ); + } + parts.push({activeItem.Name}); + } else if (activeItem.Type === 'Season') { + if (seriesName) { + parts.push( + + {seriesName} + + ); + parts.push( \ ); + } + parts.push({activeItem.Name}); + } else { + parts.push({activeItem.Name}); + } + + return
{parts}
; + }; + + const isGridView = focusedCategory !== null; + + if (!open) return null; + + return ( +
+ + {/* Header */} +
+
+

{$L('Change Artwork')}

+ {renderBreadcrumbs()} +
+
+ {/* History Back */} + + + + {/* History Forward */} + + + + {/* Close Button */} + + + +
+
+ + {/* Global Filters & Actions */} + {!isGridView && ( +
+ {availableSources.length > 0 && ( + + {$L('Sources')} + + )} + + {onlyShowInterfaceLanguage ? $L('Show All Languages') : $L('Local Language Only')} + + + {$L('Clear All Artwork')} + +
+ )} + +
+ {!isGridView ? ( + + {supportedCategories.map((category) => { + const currentTags = getCurrentTags(activeItem, category); + const remoteList = (remoteImages[category] || []).filter(shouldShowImage); + const loading = loadingCategories[category]; + const dims = getImageDimensions(category, activeItem.Type); + + return ( +
+
+ + {getCategoryDisplayName(category, activeItem.Type)} + + {remoteList.length > 0 && ( + + {$L('View All ({count})').replace('{count}', remoteList.length)} + + )} +
+ + +
+ {/* Current Images */} + {currentTags.map((tag, idx) => ( + + +
{$L('Current')}
+ + + +
+ ))} + + {/* Remote Images (subset for list view) */} + {loading && ( +
+
+
+ )} + + {!loading && remoteList.slice(0, 8).map((img, idx) => { + const optimizedUrl = getOptimizedRemoteImageUrl(img.ThumbnailUrl || img.Url, category, dims.width); + return ( + + +
+ {img.ProviderName} + {img.Width && img.Height && ( + {img.Width}x{img.Height} + )} +
+
+ ); + })} + + {!loading && currentTags.length === 0 && remoteList.length === 0 && ( +
+ {$L('No artwork found')} +
+ )} +
+ +
+ ); + })} +
+ ) : ( + /* Category Grid View */ +
+
+ + + {$L('Back')} + +

+ {getCategoryDisplayName(focusedCategory, activeItem.Type)} +

+ + {/* Resolution Selection Chips */} +
+ {RESOLUTIONS.map((res) => ( + + {res} + + ))} +
+
+ + +
+ {/* Current Images */} + {getCurrentTags(activeItem, focusedCategory).map((tag, idx) => { + const dims = getImageDimensions(focusedCategory, activeItem.Type); + return ( + + +
{$L('Current')}
+ + + +
+ ); + })} + + {/* Remote Images in Grid */} + {(remoteImages[focusedCategory] || []).filter(shouldShowImage).map((img, idx) => { + const dims = getImageDimensions(focusedCategory, activeItem.Type); + const optimizedUrl = getOptimizedRemoteImageUrl(img.ThumbnailUrl || img.Url, focusedCategory, dims.width); + return ( + + +
+ {img.ProviderName} + {img.Width && img.Height && ( + {img.Width}x{img.Height} + )} +
+
+ ); + })} +
+
+
+ )} +
+ + {/* Overlays / Popups */} + + {/* Sources Filter Select Popup */} + {showSourcesPopup && ( +
+ +

{$L('Filter Providers')}

+
+ {availableSources.map(src => ( + + + {src} + + ))} +
+ + {$L('Close')} + +
+
+ )} + + {/* Delete Confirmation Dialog */} + {deleteConfirm && ( +
+ +

{$L('Confirm Delete')}

+

{$L('Are you sure you want to delete this custom artwork?')}

+
+ + {$L('Delete')} + + + {$L('Cancel')} + +
+
+
+ )} + + {/* Clear All Confirmation Dialog */} + {clearAllConfirm && ( +
+ +

{$L('Confirm Clear All')}

+

{$L('Are you sure you want to clear all custom artwork for this item?')}

+
+ + {$L('Clear')} + + + {$L('Cancel')} + +
+
+
+ )} + + {/* Proactive / Reactive Write Access Warning Dialog */} + {writeAccessWarning && ( +
+ +

{$L('Warning: No Write Access')}

+

{writeAccessWarning}

+

+ {$L('Make sure the media folders are writable by the Jellyfin system user on your host.')} +

+ + {$L('Dismiss')} + +
+
+ )} + + {/* Zoom Preview Dialog */} + {previewImage && ( +
+ +
+

{$L('Preview: {provider}').replace('{provider}', previewImage.image.ProviderName)}

+ {previewImage.image.Width && previewImage.image.Height && ( + + {previewImage.image.Width}x{previewImage.image.Height} + + )} +
+
+ +
+
+ + {$L('Use Image')} + + + {$L('Cancel')} + +
+
+
+ )} + +
+ ); +}; + +export default ChangeArtworkModal; diff --git a/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.module.less b/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.module.less new file mode 100644 index 00000000..7c1181a7 --- /dev/null +++ b/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.module.less @@ -0,0 +1,599 @@ +@accent: var(--theme-accent, #00a4dc); + +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(var(--theme-scrim-rgb, 0, 0, 0), 0.6); + animation: fadeIn 0.2s ease; +} + +.dialog { + background: rgba(var(--theme-surface-rgb, 20, 20, 26), 0.9); + backdrop-filter: blur(20px) saturate(1.5); + -webkit-backdrop-filter: blur(20px) saturate(1.5); + + :global(.perf-low) &, + :global(.perf-mid) & { + background: rgba(var(--theme-surface-rgb, 20, 20, 26), 0.98); + } + border: 1px solid rgba(var(--theme-on-surface-rgb, 255, 255, 255), 0.12); + border-radius: 24px; + padding: 24px 32px; + width: 90vw; + height: 85vh; + box-shadow: + 0 12px 48px rgba(0, 0, 0, 0.5), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + display: flex; + flex-direction: column; + animation: scaleIn 0.2s ease; + box-sizing: border-box; +} + +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + flex-shrink: 0; +} + +.headerLeft { + display: flex; + flex-direction: column; + min-width: 0; +} + +.title { + font-size: 26px; + font-weight: 700; + color: var(--theme-text-primary, #fff); + margin: 0 0 6px; +} + +.breadcrumbs { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} + +.breadcrumbLink { + padding: 4px 8px; + border-radius: 6px; + font-size: 15px; + font-weight: 600; + background: transparent; + border: none; + color: var(--theme-accent, @accent); + cursor: pointer; + text-decoration: underline; + + &:focus { + background: rgba(var(--theme-accent-rgb, 0, 164, 220), 0.2); + text-decoration: none; + } +} + +.breadcrumbSep { + font-size: 15px; + color: rgba(var(--theme-on-surface-rgb, 255, 255, 255), 0.3); +} + +.breadcrumbText { + font-size: 15px; + color: var(--theme-text-secondary, rgba(255, 255, 255, 0.7)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 300px; +} + +.headerRight { + display: flex; + align-items: center; + gap: 8px; +} + +.chevronBtn, +.closeBtn { + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + border: none; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.15s ease; + + svg { + width: 24px; + height: 24px; + } + + &:focus { + background: var(--theme-accent, @accent); + } + + &:hover { + background: rgba(255, 255, 255, 0.15); + } +} + +.disabled { + opacity: 0.3; + cursor: default; + pointer-events: none; +} + +.filterBar { + display: flex; + gap: 12px; + margin-bottom: 20px; + flex-shrink: 0; +} + +.filterBtn { + padding: 8px 16px; + border-radius: 12px; + font-size: 15px; + font-weight: 600; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #fff; + cursor: pointer; + + &:focus { + background: var(--theme-accent, @accent); + border-color: #fff; + } +} + +.activeFilter { + background: rgba(var(--theme-accent-rgb, 0, 164, 220), 0.2); + border-color: var(--theme-accent, @accent); +} + +.clearAllBtn { + margin-left: auto; + background: rgba(255, 71, 87, 0.15); + border-color: rgba(255, 71, 87, 0.3); + + &:focus { + background: #ff4757; + border-color: #fff; + } +} + +.body { + flex-grow: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.scroller { + flex-grow: 1; + min-height: 0; +} + +.categorySection { + margin-bottom: 24px; + display: flex; + flex-direction: column; +} + +.categoryHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.categoryTitle { + font-size: 18px; + font-weight: 600; + color: #fff; +} + +.viewAllBtn { + padding: 4px 12px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + background: rgba(255, 255, 255, 0.06); + border: none; + color: var(--theme-text-secondary, rgba(255, 255, 255, 0.7)); + + &:focus { + background: var(--theme-accent, @accent); + color: #fff; + } +} + +.cardRowScroller { + width: 100%; +} + +.cardRow { + display: flex; + gap: 12px; + padding: 4px 0; +} + +.cardWrapper { + position: relative; + border-radius: 12px; + overflow: hidden; + background: rgba(255, 255, 255, 0.05); + border: 2px solid transparent; + transition: all 0.15s ease; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + flex-shrink: 0; + box-sizing: border-box; + + &:focus { + border-color: #fff; + box-shadow: 0 0 0 2px var(--theme-accent, @accent); + transform: scale(1.02); + + .cardFooter { + background: rgba(0, 0, 0, 0.85); + } + } +} + +.cardImg { + width: 100%; + height: 100%; + object-fit: cover; +} + +.cardBadge { + position: absolute; + top: 8px; + left: 8px; + padding: 3px 6px; + background: var(--theme-accent, @accent); + color: #fff; + font-size: 11px; + font-weight: 700; + border-radius: 4px; + text-transform: uppercase; +} + +.cardActionBtn { + position: absolute; + top: 8px; + right: 8px; + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.6); + border: none; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:focus { + background: #ff4757; + color: #fff; + } +} + +.cardFooter { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.6); + padding: 4px 8px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.cardProvider { + font-size: 11px; + color: rgba(255, 255, 255, 0.7); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cardResolution { + font-size: 10px; + color: rgba(255, 255, 255, 0.5); +} + +.loaderCard, +.emptyCard { + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + justify-content: center; + border: 1px dashed rgba(255, 255, 255, 0.15); + flex-shrink: 0; + color: rgba(255, 255, 255, 0.4); + font-size: 14px; +} + +.spinner { + width: 24px; + height: 24px; + border: 2px solid rgba(255, 255, 255, 0.1); + border-top-color: var(--theme-accent, @accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Grid View */ +.gridView { + display: flex; + flex-direction: column; + height: 100%; +} + +.gridHeader { + display: flex; + align-items: center; + margin-bottom: 16px; + flex-shrink: 0; + gap: 16px; +} + +.backBtn { + padding: 8px 16px; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + background: rgba(255, 255, 255, 0.08); + border: none; + color: #fff; + display: flex; + align-items: center; + cursor: pointer; + + &:focus { + background: var(--theme-accent, @accent); + } +} + +.gridTitle { + font-size: 22px; + font-weight: 700; + color: #fff; + margin: 0; +} + +.resolutionChips { + margin-left: auto; + display: flex; + gap: 8px; +} + +.resolutionChip { + padding: 6px 14px; + border-radius: 18px; + font-size: 13px; + font-weight: 600; + background: rgba(255, 255, 255, 0.06); + border: none; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + + &:focus { + background: var(--theme-accent, @accent); + color: #fff; + } +} + +.activeChip { + background: rgba(var(--theme-accent-rgb, 0, 164, 220), 0.25); + color: #fff; + border: 1px solid var(--theme-accent, @accent); +} + +.gridScroller { + flex-grow: 1; + min-height: 0; +} + +.gridContent { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 16px; + padding: 8px 4px; +} + +/* Modals & Overlays inside ChangeArtwork */ +.modalOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9500; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + border-radius: 24px; + animation: fadeIn 0.15s ease; +} + +.sourcesPanel, +.confirmPanel, +.warningPanel, +.previewPanel { + background: rgba(var(--theme-surface-rgb, 30, 30, 40), 0.95); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 20px; + padding: 24px; + max-width: 500px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); + animation: scaleIn 0.15s ease; +} + +.previewPanel { + max-width: 700px; +} + +.panelTitle { + font-size: 22px; + font-weight: 600; + color: #fff; + margin: 0 0 16px; + text-align: center; +} + +.panelMessage { + font-size: 16px; + color: rgba(255, 255, 255, 0.7); + margin: 0 0 20px; + text-align: center; +} + +.panelTip { + font-size: 14px; + color: rgba(255, 71, 87, 0.8); + background: rgba(255, 71, 87, 0.08); + padding: 10px; + border-radius: 8px; + margin-bottom: 20px; + text-align: center; +} + +.sourcesList { + max-height: 250px; + overflow-y: auto; + margin-bottom: 20px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.sourceItem { + display: flex; + align-items: center; + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + + &:focus { + background: rgba(var(--theme-accent-rgb, 0, 164, 220), 0.2); + } +} + +.checkbox { + margin-right: 12px; + width: 18px; + height: 18px; +} + +.sourceName { + font-size: 16px; + color: #fff; +} + +.previewHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + + h3 { + font-size: 18px; + font-weight: 600; + color: #fff; + margin: 0; + } +} + +.previewResolution { + font-size: 14px; + color: rgba(255, 255, 255, 0.4); +} + +.previewImgWrapper { + width: 100%; + max-height: 40vh; + display: flex; + justify-content: center; + align-items: center; + background: rgba(0, 0, 0, 0.3); + border-radius: 8px; + overflow: hidden; + margin-bottom: 16px; +} + +.previewImg { + max-width: 100%; + max-height: 40vh; + object-fit: contain; +} + +.formButtons { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.btn { + padding: 10px 24px; + border-radius: 10px; + font-size: 16px; + font-weight: 600; + background: rgba(255, 255, 255, 0.08); + border: none; + color: #fff; + cursor: pointer; + + &:focus { + background: rgba(255, 255, 255, 0.2); + } +} + +.btnPrimary { + background: var(--theme-accent, @accent); + + &:focus { + background: var(--theme-accent, @accent); + box-shadow: 0 0 0 2px #fff; + } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/packages/app/src/components/ChangeArtworkModal/index.js b/packages/app/src/components/ChangeArtworkModal/index.js new file mode 100644 index 00000000..aba35d27 --- /dev/null +++ b/packages/app/src/components/ChangeArtworkModal/index.js @@ -0,0 +1 @@ +export {default} from './ChangeArtworkModal'; diff --git a/packages/app/src/services/jellyfinApi.js b/packages/app/src/services/jellyfinApi.js index 3e6be793..6941172a 100644 --- a/packages/app/src/services/jellyfinApi.js +++ b/packages/app/src/services/jellyfinApi.js @@ -275,9 +275,6 @@ export const api = { getResumeItems: (limit = 12) => request(`/Users/${currentUser}/Items/Resume?Limit=${limit}&MediaTypes=Video&Fields=${encodeURIComponent(HOME_ROW_ITEM_FIELDS)}`), - getResumeAudioItems: (limit = 20) => - request(`/Users/${currentUser}/Items/Resume?Limit=${limit}&MediaTypes=Audio&Fields=${encodeURIComponent(HOME_ROW_ITEM_FIELDS)}`), - getNextUp: (limit = 24, seriesId = null) => { let url = `/Shows/NextUp?UserId=${currentUser}&Limit=${limit}&Fields=${encodeURIComponent(HOME_ROW_ITEM_FIELDS)}`; if (seriesId) url += `&SeriesId=${seriesId}`; @@ -527,7 +524,28 @@ export const api = { removeFromPlaylist: (playlistId, entryIds) => request(`/Playlists/${playlistId}/Items?EntryIds=${entryIds.join(',')}`, { method: 'DELETE' - }) + }), + + getRemoteImages: (itemId, imageType) => + request(`/Items/${itemId}/RemoteImages?Type=${imageType}&IncludeAllLanguages=true`), + + downloadRemoteImage: (itemId, imageType, imageUrl) => + request(`/Items/${itemId}/RemoteImages/Download?Type=${imageType}&ImageUrl=${encodeURIComponent(imageUrl)}`, { + method: 'POST' + }), + + deleteItemImage: (itemId, imageType, imageIndex) => { + const indexSuffix = imageIndex !== undefined && imageIndex !== null ? `/${imageIndex}` : ''; + return request(`/Items/${itemId}/Images/${imageType}${indexSuffix}`, { + method: 'DELETE' + }); + }, + + getVirtualFolders: () => + request('/Library/VirtualFolders'), + + checkWriteAccess: () => + request('/Moonfin/Libraries/CheckWriteAccess') }; /** @@ -800,6 +818,27 @@ export const createApiForServer = (serverUrl, token, userId, serverTypeOverride getThemeSongs: (itemId, inheritFromParent = true) => serverRequest(`/Items/${itemId}/ThemeSongs?UserId=${userId}&InheritFromParent=${inheritFromParent}`), + getRemoteImages: (itemId, imageType) => + serverRequest(`/Items/${itemId}/RemoteImages?Type=${imageType}&IncludeAllLanguages=true`), + + downloadRemoteImage: (itemId, imageType, imageUrl) => + serverRequest(`/Items/${itemId}/RemoteImages/Download?Type=${imageType}&ImageUrl=${encodeURIComponent(imageUrl)}`, { + method: 'POST' + }), + + deleteItemImage: (itemId, imageType, imageIndex) => { + const indexSuffix = imageIndex !== undefined && imageIndex !== null ? `/${imageIndex}` : ''; + return serverRequest(`/Items/${itemId}/Images/${imageType}${indexSuffix}`, { + method: 'DELETE' + }); + }, + + getVirtualFolders: () => + serverRequest('/Library/VirtualFolders'), + + checkWriteAccess: () => + serverRequest('/Moonfin/Libraries/CheckWriteAccess'), + // Return server info for playback routing getServerInfo: () => ({ serverUrl: url, diff --git a/packages/app/src/views/Details/Details.js b/packages/app/src/views/Details/Details.js index ddc2b7ce..d72b654c 100644 --- a/packages/app/src/views/Details/Details.js +++ b/packages/app/src/views/Details/Details.js @@ -21,6 +21,7 @@ import {fetchVideoStreamUrl, extractYouTubeIdFromUrl} from '../../services/youtu import {formatTime} from '../Player/PlayerConstants'; import AddToPlaylistModal from '../../components/AddToPlaylistModal'; import DeleteItemDialog from '../../components/DeleteItemDialog'; +import ChangeArtworkModal from '../../components/ChangeArtworkModal'; import {toSubtitleLanguage, mapRemoteSubtitleOptions} from '../Player/remoteSubtitleUtils'; import {getTmdbId, fetchTmdbSeasonRatings} from '../../services/mdblistApi'; import {analyzeLogoBrightness} from '../../utils/imgUtils'; @@ -132,7 +133,7 @@ const getMediaBadges = (item, versionIndex = 0) => { }; const Details = ({itemId, initialItem, onPlay, onSelectItem, onSelectPerson, onSelectStudio, onItemDeleted, backHandlerRef}) => { - const {api, serverUrl} = useAuth(); + const {api, serverUrl, user} = useAuth(); const {settings} = useSettings(); // Cross-server support @@ -147,6 +148,7 @@ const Details = ({itemId, initialItem, onPlay, onSelectItem, onSelectPerson, onS return initialItem?._serverUrl || serverUrl; }, [initialItem?._serverUrl, serverUrl]); + const tagWithServerInfo = useCallback((items) => { if (!initialItem?._serverUrl) return items; const tagSingleItem = (singleItem) => ({ @@ -187,6 +189,7 @@ const Details = ({itemId, initialItem, onPlay, onSelectItem, onSelectPerson, onS const [trailerStreamUrl, setTrailerStreamUrl] = useState(null); const [showPlaylistModal, setShowPlaylistModal] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showArtworkModal, setShowArtworkModal] = useState(false); const [toastMessage, setToastMessage] = useState(null); const [episodeRatings, setEpisodeRatings] = useState({}); const [logoFailed, setLogoFailed] = useState(false); @@ -194,6 +197,29 @@ const Details = ({itemId, initialItem, onPlay, onSelectItem, onSelectPerson, onS const handleToastEnd = useCallback(() => setToastMessage(null), []); const [invertLogo, setInvertLogo] = useState(false); + const canChangeArtwork = useMemo(() => { + if (!item) return false; + const type = item.Type; + const isMediaType = + type === 'Movie' || + type === 'Episode' || + type === 'Series' || + type === 'Season' || + type === 'Audio' || + type === 'MusicAlbum' || + type === 'BoxSet'; + const isFolder = + type === 'Folder' || + type === 'CollectionFolder' || + type === 'UserView' || + type === 'Genre' || + type === 'MusicGenre'; + + return (isMediaType || isFolder) && + jellyfinApi.getServerType() === 'jellyfin' && + user?.Policy?.IsAdministrator; + }, [item, user]); + // Refs const pageScrollerRef = useRef(null); const pageScrollToRef = useRef(null); @@ -931,6 +957,27 @@ const Details = ({itemId, initialItem, onPlay, onSelectItem, onSelectPerson, onS window.requestAnimationFrame(() => Spotlight.focus('details-action-buttons')); }, []); + const refreshItem = useCallback(async () => { + try { + const data = await effectiveApi.getItemForDetail(itemId); + if (data) { + setItem(tagWithServerInfo(data)); + } + } catch (err) { + console.error('[Details] Error refreshing item', err); + } + }, [effectiveApi, itemId, tagWithServerInfo]); + + const handleOpenArtworkModal = useCallback(() => { + setShowArtworkModal(true); + }, []); + + const handleCloseArtworkModal = useCallback(() => { + setShowArtworkModal(false); + refreshItem(); + window.requestAnimationFrame(() => Spotlight.focus('details-action-buttons')); + }, [refreshItem]); + const handleConfirmDelete = useCallback(async () => { try { await effectiveApi.deleteItem(item.Id); @@ -946,6 +993,7 @@ const Details = ({itemId, initialItem, onPlay, onSelectItem, onSelectPerson, onS useEffect(() => { if (!backHandlerRef) return; backHandlerRef.current = () => { + if (showArtworkModal) { handleCloseArtworkModal(); return true; } if (showDeleteDialog) { handleCloseDeleteDialog(); return true; } if (showPlaylistModal) { handleClosePlaylistModal(); return true; } if (activeModal) { closeModal(); return true; } @@ -953,7 +1001,7 @@ const Details = ({itemId, initialItem, onPlay, onSelectItem, onSelectPerson, onS return false; }; return () => { if (backHandlerRef) backHandlerRef.current = null; }; - }, [backHandlerRef, activeModal, showMediaInfo, showPlaylistModal, showDeleteDialog, closeModal, handleClosePlaylistModal, handleCloseDeleteDialog]); + }, [backHandlerRef, activeModal, showMediaInfo, showPlaylistModal, showDeleteDialog, showArtworkModal, closeModal, handleClosePlaylistModal, handleCloseDeleteDialog, handleCloseArtworkModal]); const handleSectionKeyDown = useCallback((ev) => { const currentSpottable = ev.target.closest('.spottable'); @@ -1625,6 +1673,15 @@ const handleSectionKeyDown = useCallback((ev) => { onConfirm={handleConfirmDelete} /> + + {toastMessage && (
{toastMessage}
)} @@ -1640,6 +1697,8 @@ const handleSectionKeyDown = useCallback((ev) => { key={item.Id} item={item} settings={settings} + canChangeArtwork={canChangeArtwork} + handleOpenArtworkModal={handleOpenArtworkModal} effectiveApi={effectiveApi} serverToken={initialItem?._serverAccessToken || jellyfinApi.getApiKey()} effectiveServerUrl={effectiveServerUrl} diff --git a/packages/app/src/views/Details/ModernDetailContent.js b/packages/app/src/views/Details/ModernDetailContent.js index 7ad32a74..2b97e544 100644 --- a/packages/app/src/views/Details/ModernDetailContent.js +++ b/packages/app/src/views/Details/ModernDetailContent.js @@ -50,7 +50,8 @@ const ModernDetailContent = (props) => { handlePlay, handleResume, handleShuffle, handleTrailer, handleToggleWatched, handleToggleFavorite, handleGoToSeries, handleOpenVersionModal, handleOpenAudioModal, handleOpenSubtitleModal, handleOpenMediaInfo, handleOpenPlaylistModal, handleOpenDeleteDialog, handleChapterSelect, handleExtraSelect, handleTrackPlay, - onSelectItem, onSelectPerson, onSelectStudio + onSelectItem, onSelectPerson, onSelectStudio, + canChangeArtwork, handleOpenArtworkModal } = props; const hasTrailer = item.LocalTrailerCount > 0 || (item.RemoteTrailers?.length > 0) || isSeries; @@ -440,6 +441,7 @@ const ModernDetailContent = (props) => { {supportsMediaSourceSelection && } {item.CanDelete && } + {canChangeArtwork && } ); diff --git a/packages/app/src/views/Details/detailIcons.js b/packages/app/src/views/Details/detailIcons.js index dd4b1ccf..f9476132 100644 --- a/packages/app/src/views/Details/detailIcons.js +++ b/packages/app/src/views/Details/detailIcons.js @@ -14,5 +14,6 @@ export const DETAIL_ICON_PATHS = { series: 'M240-120v-80l40-40H160q-33 0-56.5-23.5T80-320v-440q0-33 23.5-56.5T160-840h640q33 0 56.5 23.5T880-760v440q0 33-23.5 56.5T800-240H680l40 40v80H240Zm-80-200h640v-440H160v440Zm0 0v-440 440Z', mediaInfo: 'M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z', playlist: 'M120-320v-80h480v80H120Zm0-160v-80h480v80H120Zm0-160v-80h480v80H120Zm520 480v-320l240 160-240 160Z', + artwork: 'M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-880h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm80-80h400L560-400 440-240l-80-100-80 100Zm-80 80v-560 560Z', delete: 'M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T700-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z' }; From ed6daab20d101ac49af26fd1285b141ad12cffc1 Mon Sep 17 00:00:00 2001 From: RadicalMuffinMan <103554043+RadicalMuffinMan@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:42:10 -0400 Subject: [PATCH 2/2] Fixed artwork selector issues and moved card sizing to CSS --- .../ChangeArtworkModal/ChangeArtworkModal.js | 177 +++++++++++------- .../ChangeArtworkModal.module.less | 33 ++++ packages/app/src/services/jellyfinApi.js | 3 + packages/app/src/views/Details/Details.js | 8 +- 4 files changed, 157 insertions(+), 64 deletions(-) diff --git a/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.js b/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.js index 061451e7..7e687742 100644 --- a/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.js +++ b/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.js @@ -5,7 +5,7 @@ import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDeco import $L from '@enact/i18n/$L'; import {Scroller} from '@enact/sandstone/Scroller'; import {getImageUrl} from '../../utils/helpers'; -import {isBackKey} from '../../utils/keys'; +import {useSettings} from '../../context/SettingsContext'; import css from './ChangeArtworkModal.module.less'; @@ -109,6 +109,30 @@ const getImageDimensions = (category, itemType) => { } }; +// The matching css dimension class, so card sizing lives in css rather than +// inline style objects. Widths still come from getImageDimensions for the +// remote image fetch url. +const getCardSizeClass = (category, itemType) => { + if (category === 'Primary') { + return itemType?.toLowerCase() === 'episode' ? 'sizeWide' : 'sizePoster'; + } + switch (category) { + case 'Backdrop': + case 'Thumb': + case 'Screenshot': + return 'sizeWide'; + case 'Banner': + return 'sizeBanner'; + case 'Logo': + case 'Art': + return 'sizeLogo'; + case 'Disc': + return 'sizeSquare'; + default: + return 'sizePoster'; + } +}; + const getCurrentTags = (item, category) => { if (category === 'Backdrop') { return item.BackdropImageTags || []; @@ -145,7 +169,8 @@ const getOptimizedRemoteImageUrl = (url, category, targetWidth) => { return url; }; -const ChangeArtworkModal = ({open, item: initialItem, api, serverUrl, onClose, onSuccess}) => { +const ChangeArtworkModal = ({open, item: initialItem, api, serverUrl, onClose, onSuccess, backHandlerRef}) => { + const {settings} = useSettings(); const [activeItem, setActiveItem] = useState(initialItem); const [history, setHistory] = useState([initialItem]); const [historyIndex, setHistoryIndex] = useState(0); @@ -169,43 +194,50 @@ const ChangeArtworkModal = ({open, item: initialItem, api, serverUrl, onClose, o const [clearAllConfirm, setClearAllConfirm] = useState(false); const [writeAccessWarning, setWriteAccessWarning] = useState(null); // error string - // Flag to avoid checking write access multiple times per session - const hasCheckedWriteAccess = useRef(false); + // Server write access reports cover every library, so fetch them once and + // match the current item's path each load. + const writeAccessReportsRef = useRef(null); + // Bumped on each load so stale async responses from a previous item are ignored. + const loadIdRef = useRef(0); // Initialize / load item details const loadItem = useCallback(async (itemToLoad) => { + const loadId = ++loadIdRef.current; setActiveItem(itemToLoad); const categories = getSupportedImageTypes(itemToLoad.Type); setSupportedCategories(categories); setRemoteImages({}); setLoadingCategories({}); setFocusedCategory(null); + setWriteAccessWarning(null); // Fetch remote images for each category categories.forEach(async (category) => { if (itemToLoad.Type?.toLowerCase() === 'genre' || itemToLoad.Type?.toLowerCase() === 'musicgenre') { - setRemoteImages(prev => ({...prev, [category]: []})); + if (loadIdRef.current === loadId) setRemoteImages(prev => ({...prev, [category]: []})); return; } setLoadingCategories(prev => ({...prev, [category]: true})); try { const result = await api.getRemoteImages(itemToLoad.Id, category); const list = result?.Images || []; - setRemoteImages(prev => ({...prev, [category]: list})); + if (loadIdRef.current === loadId) setRemoteImages(prev => ({...prev, [category]: list})); } catch (e) { console.warn(`Failed to fetch remote images for ${category}:`, e); } finally { - setLoadingCategories(prev => ({...prev, [category]: false})); + if (loadIdRef.current === loadId) setLoadingCategories(prev => ({...prev, [category]: false})); } }); - // Proactively check write access once - if (!hasCheckedWriteAccess.current && api.checkWriteAccess) { + // Warn when the server cannot write to this item's library path + if (api.checkWriteAccess) { try { - const reports = await api.checkWriteAccess(); - hasCheckedWriteAccess.current = true; + if (!writeAccessReportsRef.current) { + writeAccessReportsRef.current = await api.checkWriteAccess(); + } + const reports = writeAccessReportsRef.current; const itemPath = itemToLoad.Path; - if (itemPath && Array.isArray(reports)) { + if (loadIdRef.current === loadId && itemPath && Array.isArray(reports)) { const matchingReport = reports.find(report => report.FailedPaths?.some(path => itemPath.startsWith(path)) ); @@ -232,45 +264,48 @@ const ChangeArtworkModal = ({open, item: initialItem, api, serverUrl, onClose, o setDeselectedSources(new Set()); setOnlyShowInterfaceLanguage(true); setSelectedResolution('All'); + setDeleteConfirm(null); + setPreviewImage(null); + setClearAllConfirm(false); + setShowSourcesPopup(false); + setWriteAccessWarning(null); + setActionInProgress(new Set()); + writeAccessReportsRef.current = null; loadItem(initialItem); } }, [open, initialItem, loadItem]); - // Spotlight focus on mount + // Move d-pad focus into whichever overlay or view is active, and back to the + // modal when one closes, so focus is never stranded on the covered content. useEffect(() => { - if (open) { - const t = setTimeout(() => Spotlight.focus('change-artwork-modal'), 150); - return () => clearTimeout(t); - } - }, [open]); - - // BACK Key Handling + if (!open) return undefined; + let target = 'change-artwork-modal'; + if (previewImage) target = 'zoom-preview'; + else if (deleteConfirm) target = 'delete-confirm'; + else if (clearAllConfirm) target = 'clear-all-confirm'; + else if (showSourcesPopup) target = 'sources-popup'; + else if (writeAccessWarning) target = 'write-access-warning'; + else if (focusedCategory) target = 'grid-back-btn'; + const t = setTimeout(() => Spotlight.focus(target), 100); + return () => clearTimeout(t); + }, [open, previewImage, deleteConfirm, clearAllConfirm, showSourcesPopup, writeAccessWarning, focusedCategory]); + + // Back is driven by the parent through backHandlerRef so it composes with the + // app back stack. Returns true when a sub view was closed, false when nothing + // is left, letting the parent close the modal. useEffect(() => { - if (!open) return; - const handleKey = (e) => { - if (isBackKey(e)) { - e.preventDefault(); - e.stopPropagation(); - if (previewImage) { - setPreviewImage(null); - } else if (deleteConfirm) { - setDeleteConfirm(null); - } else if (clearAllConfirm) { - setClearAllConfirm(false); - } else if (showSourcesPopup) { - setShowSourcesPopup(false); - } else if (writeAccessWarning) { - setWriteAccessWarning(null); - } else if (focusedCategory) { - setFocusedCategory(null); - } else { - onClose?.(hasChanged); - } - } + if (!backHandlerRef) return undefined; + backHandlerRef.current = () => { + if (previewImage) { setPreviewImage(null); return true; } + if (deleteConfirm) { setDeleteConfirm(null); return true; } + if (clearAllConfirm) { setClearAllConfirm(false); return true; } + if (showSourcesPopup) { setShowSourcesPopup(false); return true; } + if (writeAccessWarning) { setWriteAccessWarning(null); return true; } + if (focusedCategory) { setFocusedCategory(null); return true; } + return false; }; - window.addEventListener('keydown', handleKey, true); - return () => window.removeEventListener('keydown', handleKey, true); - }, [open, focusedCategory, previewImage, deleteConfirm, clearAllConfirm, showSourcesPopup, writeAccessWarning, hasChanged, onClose]); + return () => { if (backHandlerRef) backHandlerRef.current = null; }; + }, [backHandlerRef, previewImage, deleteConfirm, clearAllConfirm, showSourcesPopup, writeAccessWarning, focusedCategory]); // History Navigation const navigateToItem = useCallback(async (itemId) => { @@ -438,7 +473,7 @@ const ChangeArtworkModal = ({open, item: initialItem, api, serverUrl, onClose, o if (onlyShowInterfaceLanguage) { const lang = img.Language?.toLowerCase(); - const currentLang = (typeof navigator !== 'undefined' ? (navigator.language || 'en').split('-')[0] : 'en').toLowerCase(); + const currentLang = (settings.uiLanguage || 'en').split('-')[0].toLowerCase(); if (lang && lang !== 'all' && lang !== 'none' && lang !== 'mul' && lang !== currentLang) { return false; } @@ -455,7 +490,22 @@ const ChangeArtworkModal = ({open, item: initialItem, api, serverUrl, onClose, o } return true; - }, [deselectedSources, onlyShowInterfaceLanguage, selectedResolution]); + }, [deselectedSources, onlyShowInterfaceLanguage, selectedResolution, settings.uiLanguage]); + + // Filtered remote images and the size class for the expanded grid, computed + // once rather than per card. + const gridRemoteImages = useMemo( + () => (focusedCategory ? (remoteImages[focusedCategory] || []).filter(shouldShowImage) : []), + [focusedCategory, remoteImages, shouldShowImage] + ); + const gridSizeClass = useMemo( + () => (focusedCategory ? css[getCardSizeClass(focusedCategory, activeItem?.Type)] : ''), + [focusedCategory, activeItem] + ); + const gridImageWidth = useMemo( + () => (focusedCategory ? getImageDimensions(focusedCategory, activeItem?.Type).width : 0), + [focusedCategory, activeItem] + ); // Toggle filters const toggleLanguageFilter = useCallback(() => { @@ -706,6 +756,7 @@ const ChangeArtworkModal = ({open, item: initialItem, api, serverUrl, onClose, o const remoteList = (remoteImages[category] || []).filter(shouldShowImage); const loading = loadingCategories[category]; const dims = getImageDimensions(category, activeItem.Type); + const sizeClass = css[getCardSizeClass(category, activeItem.Type)]; return (
@@ -730,8 +781,7 @@ const ChangeArtworkModal = ({open, item: initialItem, api, serverUrl, onClose, o {currentTags.map((tag, idx) => ( +
)} @@ -761,9 +811,8 @@ const ChangeArtworkModal = ({open, item: initialItem, api, serverUrl, onClose, o const optimizedUrl = getOptimizedRemoteImageUrl(img.ThumbnailUrl || img.Url, category, dims.width); return ( +
{$L('No artwork found')}
)} @@ -799,7 +848,7 @@ const ChangeArtworkModal = ({open, item: initialItem, api, serverUrl, onClose, o onClick={handleCloseGridClick} spotlightId="grid-back-btn" > - + {$L('Back')}

@@ -825,12 +874,10 @@ const ChangeArtworkModal = ({open, item: initialItem, api, serverUrl, onClose, o
{/* Current Images */} {getCurrentTags(activeItem, focusedCategory).map((tag, idx) => { - const dims = getImageDimensions(focusedCategory, activeItem.Type); return ( { - const dims = getImageDimensions(focusedCategory, activeItem.Type); - const optimizedUrl = getOptimizedRemoteImageUrl(img.ThumbnailUrl || img.Url, focusedCategory, dims.width); + {gridRemoteImages.map((img, idx) => { + const optimizedUrl = getOptimizedRemoteImageUrl(img.ThumbnailUrl || img.Url, focusedCategory, gridImageWidth); return ( ); })} + + {getCurrentTags(activeItem, focusedCategory).length === 0 && gridRemoteImages.length === 0 && ( +
+ {$L('No artwork found')} +
+ )}

diff --git a/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.module.less b/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.module.less index 7c1181a7..fe1d6a56 100644 --- a/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.module.less +++ b/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.module.less @@ -332,6 +332,33 @@ font-size: 14px; } +// Card dimensions per image category, kept in CSS so the cards do not build +// inline style objects on every render. +.sizePoster { + width: 160px; + height: 240px; +} + +.sizeWide { + width: 280px; + height: 158px; +} + +.sizeBanner { + width: 350px; + height: 70px; +} + +.sizeLogo { + width: 200px; + height: 80px; +} + +.sizeSquare { + width: 160px; + height: 160px; +} + .spinner { width: 24px; height: 24px; @@ -377,6 +404,12 @@ } } +.backBtnIcon { + width: 24px; + height: 24px; + margin-right: 8px; +} + .gridTitle { font-size: 22px; font-weight: 700; diff --git a/packages/app/src/services/jellyfinApi.js b/packages/app/src/services/jellyfinApi.js index 6941172a..886d9f16 100644 --- a/packages/app/src/services/jellyfinApi.js +++ b/packages/app/src/services/jellyfinApi.js @@ -275,6 +275,9 @@ export const api = { getResumeItems: (limit = 12) => request(`/Users/${currentUser}/Items/Resume?Limit=${limit}&MediaTypes=Video&Fields=${encodeURIComponent(HOME_ROW_ITEM_FIELDS)}`), + getResumeAudioItems: (limit = 20) => + request(`/Users/${currentUser}/Items/Resume?Limit=${limit}&MediaTypes=Audio&Fields=${encodeURIComponent(HOME_ROW_ITEM_FIELDS)}`), + getNextUp: (limit = 24, seriesId = null) => { let url = `/Shows/NextUp?UserId=${currentUser}&Limit=${limit}&Fields=${encodeURIComponent(HOME_ROW_ITEM_FIELDS)}`; if (seriesId) url += `&SeriesId=${seriesId}`; diff --git a/packages/app/src/views/Details/Details.js b/packages/app/src/views/Details/Details.js index d72b654c..be298108 100644 --- a/packages/app/src/views/Details/Details.js +++ b/packages/app/src/views/Details/Details.js @@ -222,6 +222,7 @@ const Details = ({itemId, initialItem, onPlay, onSelectItem, onSelectPerson, onS // Refs const pageScrollerRef = useRef(null); + const artworkModalBackRef = useRef(null); const pageScrollToRef = useRef(null); const lastFocusedElementRef = useRef(null); const trailerVideoRef = useRef(null); @@ -993,7 +994,11 @@ const Details = ({itemId, initialItem, onPlay, onSelectItem, onSelectPerson, onS useEffect(() => { if (!backHandlerRef) return; backHandlerRef.current = () => { - if (showArtworkModal) { handleCloseArtworkModal(); return true; } + if (showArtworkModal) { + if (artworkModalBackRef.current?.()) return true; + handleCloseArtworkModal(); + return true; + } if (showDeleteDialog) { handleCloseDeleteDialog(); return true; } if (showPlaylistModal) { handleClosePlaylistModal(); return true; } if (activeModal) { closeModal(); return true; } @@ -1680,6 +1685,7 @@ const handleSectionKeyDown = useCallback((ev) => { serverUrl={effectiveServerUrl} onClose={handleCloseArtworkModal} onSuccess={showToast} + backHandlerRef={artworkModalBackRef} /> {toastMessage && (