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..7e687742
--- /dev/null
+++ b/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.js
@@ -0,0 +1,1084 @@
+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 {useSettings} from '../../context/SettingsContext';
+
+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};
+ }
+};
+
+// 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 || [];
+ }
+ 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, backHandlerRef}) => {
+ const {settings} = useSettings();
+ 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
+
+ // 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') {
+ 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 || [];
+ if (loadIdRef.current === loadId) setRemoteImages(prev => ({...prev, [category]: list}));
+ } catch (e) {
+ console.warn(`Failed to fetch remote images for ${category}:`, e);
+ } finally {
+ if (loadIdRef.current === loadId) setLoadingCategories(prev => ({...prev, [category]: false}));
+ }
+ });
+
+ // Warn when the server cannot write to this item's library path
+ if (api.checkWriteAccess) {
+ try {
+ if (!writeAccessReportsRef.current) {
+ writeAccessReportsRef.current = await api.checkWriteAccess();
+ }
+ const reports = writeAccessReportsRef.current;
+ const itemPath = itemToLoad.Path;
+ if (loadIdRef.current === loadId && 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');
+ setDeleteConfirm(null);
+ setPreviewImage(null);
+ setClearAllConfirm(false);
+ setShowSourcesPopup(false);
+ setWriteAccessWarning(null);
+ setActionInProgress(new Set());
+ writeAccessReportsRef.current = null;
+ loadItem(initialItem);
+ }
+ }, [open, initialItem, loadItem]);
+
+ // 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) 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 (!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;
+ };
+ return () => { if (backHandlerRef) backHandlerRef.current = null; };
+ }, [backHandlerRef, previewImage, deleteConfirm, clearAllConfirm, showSourcesPopup, writeAccessWarning, focusedCategory]);
+
+ // 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 = (settings.uiLanguage || 'en').split('-')[0].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, 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(() => {
+ 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);
+ const sizeClass = css[getCardSizeClass(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) => {
+ return (
+
+
+ {$L('Current')}
+
+
+
+
+ );
+ })}
+
+ {/* Remote Images in Grid */}
+ {gridRemoteImages.map((img, idx) => {
+ const optimizedUrl = getOptimizedRemoteImageUrl(img.ThumbnailUrl || img.Url, focusedCategory, gridImageWidth);
+ return (
+
+
+
+ {img.ProviderName}
+ {img.Width && img.Height && (
+ {img.Width}x{img.Height}
+ )}
+
+
+ );
+ })}
+
+ {getCurrentTags(activeItem, focusedCategory).length === 0 && gridRemoteImages.length === 0 && (
+
+ {$L('No artwork found')}
+
+ )}
+
+
+
+ )}
+
+
+ {/* 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..fe1d6a56
--- /dev/null
+++ b/packages/app/src/components/ChangeArtworkModal/ChangeArtworkModal.module.less
@@ -0,0 +1,632 @@
+@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;
+}
+
+// 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;
+ 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);
+ }
+}
+
+.backBtnIcon {
+ width: 24px;
+ height: 24px;
+ margin-right: 8px;
+}
+
+.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..886d9f16 100644
--- a/packages/app/src/services/jellyfinApi.js
+++ b/packages/app/src/services/jellyfinApi.js
@@ -527,7 +527,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 +821,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..be298108 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,8 +197,32 @@ 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 artworkModalBackRef = useRef(null);
const pageScrollToRef = useRef(null);
const lastFocusedElementRef = useRef(null);
const trailerVideoRef = useRef(null);
@@ -931,6 +958,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 +994,11 @@ const Details = ({itemId, initialItem, onPlay, onSelectItem, onSelectPerson, onS
useEffect(() => {
if (!backHandlerRef) return;
backHandlerRef.current = () => {
+ 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; }
@@ -953,7 +1006,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 +1678,16 @@ const handleSectionKeyDown = useCallback((ev) => {
onConfirm={handleConfirmDelete}
/>
+
+
{toastMessage && (
{toastMessage}
)}
@@ -1640,6 +1703,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'
};