diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index af602c2..d55fd35 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -2746,6 +2746,8 @@ getActiveViewMoreUrl: getActiveViewMoreUrl, refreshSectionQueries: refreshSectionQueries, loadActiveSectionQueryItems: loadActiveSectionQueryItems, + createSectionScrollableContainer: createSectionScrollableContainer, + applySectionPresentationAttrs: applySectionPresentationAttrs, attachSectionControlButtons: attachSectionControlButtons, ensureCardBorders: ensureCardBorders, applyItemsLayoutState: applyItemsLayoutState, @@ -4464,11 +4466,26 @@ // Overlays: desktop = hover FAB + br actions; mobile = native bottom-right play only const NON_PLAYABLE_TYPES = ['CollectionFolder', 'Folder', 'UserView', 'Virtual', 'Studio', 'Genre', 'Person']; + const NO_RATE_TYPES = ['UserView', 'CollectionFolder', 'Folder', 'Program', 'Timer', 'SeriesTimer', 'Channel']; const canShowPlayOverlay = !isCustomCard && !NON_PLAYABLE_TYPES.includes(item.Type) && item.LocationType !== 'Virtual' && item.Type !== 'Person'; + // Match jellyfin-web itemHelper.canMarkPlayed (cards: no Folder/CollectionFolder/UserView) + const canMarkPlayed = + item.Type !== 'Program' + && ( + (item.MediaType === 'Video' && item.Type !== 'TvChannel') + || (item.MediaType === 'Audio' && item.Type === 'AudioBook') + || item.Type === 'Series' + || item.Type === 'Season' + || item.Type === 'BoxSet' + || item.MediaType === 'Book' + ) + && !NON_PLAYABLE_TYPES.includes(item.Type); + // Match jellyfin-web itemHelper.canRate type denylist (do not gate on missing UserData) + const canRate = !NO_RATE_TYPES.includes(item.Type); let cardOverlayContainer = null; let mobileOverlayPlayButton = null; @@ -4535,40 +4552,44 @@ buttonContainer.appendChild(watchlistButton); } - const watchedButton = document.createElement('button'); - watchedButton.setAttribute('is', 'emby-playstatebutton'); - watchedButton.type = 'button'; - watchedButton.setAttribute('data-action', 'none'); - watchedButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button'; - watchedButton.setAttribute('data-id', itemId); - watchedButton.setAttribute('data-serverid', serverId); - watchedButton.setAttribute('data-itemtype', itemType); - watchedButton.setAttribute('data-played', item.UserData?.Played || 'false'); - watchedButton.title = 'Mark played'; + if (canMarkPlayed) { + const watchedButton = document.createElement('button'); + watchedButton.setAttribute('is', 'emby-playstatebutton'); + watchedButton.type = 'button'; + watchedButton.setAttribute('data-action', 'none'); + watchedButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button'; + watchedButton.setAttribute('data-id', itemId); + watchedButton.setAttribute('data-serverid', serverId); + watchedButton.setAttribute('data-itemtype', itemType); + watchedButton.setAttribute('data-played', item.UserData?.Played || 'false'); + watchedButton.title = 'Mark played'; - const watchedIcon = document.createElement('span'); - watchedIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check playstatebutton-icon-unplayed'; - watchedIcon.setAttribute('aria-hidden', 'true'); - watchedButton.appendChild(watchedIcon); - buttonContainer.appendChild(watchedButton); + const watchedIcon = document.createElement('span'); + watchedIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check playstatebutton-icon-unplayed'; + watchedIcon.setAttribute('aria-hidden', 'true'); + watchedButton.appendChild(watchedIcon); + buttonContainer.appendChild(watchedButton); + } - const favoriteButton = document.createElement('button'); - favoriteButton.setAttribute('is', 'emby-ratingbutton'); - favoriteButton.type = 'button'; - favoriteButton.setAttribute('data-action', 'none'); - favoriteButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button'; - favoriteButton.setAttribute('data-id', itemId); - favoriteButton.setAttribute('data-serverid', serverId); - favoriteButton.setAttribute('data-itemtype', itemType); - favoriteButton.setAttribute('data-likes', ''); - favoriteButton.setAttribute('data-isfavorite', item.UserData?.IsFavorite || 'false'); - favoriteButton.title = 'Add to favorites'; + if (canRate) { + const favoriteButton = document.createElement('button'); + favoriteButton.setAttribute('is', 'emby-ratingbutton'); + favoriteButton.type = 'button'; + favoriteButton.setAttribute('data-action', 'none'); + favoriteButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button'; + favoriteButton.setAttribute('data-id', itemId); + favoriteButton.setAttribute('data-serverid', serverId); + favoriteButton.setAttribute('data-itemtype', itemType); + favoriteButton.setAttribute('data-likes', ''); + favoriteButton.setAttribute('data-isfavorite', item.UserData?.IsFavorite || 'false'); + favoriteButton.title = 'Add to favorites'; - const favoriteIcon = document.createElement('span'); - favoriteIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite'; - favoriteIcon.setAttribute('aria-hidden', 'true'); - favoriteButton.appendChild(favoriteIcon); - buttonContainer.appendChild(favoriteButton); + const favoriteIcon = document.createElement('span'); + favoriteIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite'; + favoriteIcon.setAttribute('aria-hidden', 'true'); + favoriteButton.appendChild(favoriteIcon); + buttonContainer.appendChild(favoriteButton); + } const moreButton = document.createElement('button'); moreButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light'; diff --git a/scripts/homeScreen3.js b/scripts/homeScreen3.js index dfe45fa..7c7e35b 100644 --- a/scripts/homeScreen3.js +++ b/scripts/homeScreen3.js @@ -1957,6 +1957,166 @@ return enqueueHomeViewJob({ type: 'home', hash: window.location.hash || '' }); } + /** + * Full re-render + replace a single mounted home section (admin editor save). + * If the section is not mounted, no-ops (does not refresh the whole home). + * @param {string} sectionId + * @param {Object} [sectionConfig] saved section config from the editor + * @returns {Promise} true if replaced + */ + async function refreshHomeSectionById(sectionId, sectionConfig) { + if (!sectionId) return false; + + invalidateHomeScreenConfig('refreshHomeSectionById'); + + const escaped = typeof CSS !== 'undefined' && CSS.escape + ? CSS.escape(String(sectionId)) + : String(sectionId).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const sectionElement = + document.querySelector(`.homePage:not(.hide) #homeTab .sections [data-section-id="${escaped}"]`) + || document.querySelector(`[data-section-id="${escaped}"]`); + if (!sectionElement?.isConnected) { + LOG('refreshHomeSectionById: section not mounted', sectionId); + return false; + } + + const runtime = window.KefinHomeScreenSectionRuntime?.get?.(sectionId); + const cfg = sectionConfig + ? { ...(runtime?.effectiveConfig || {}), ...sectionConfig } + : (runtime?.effectiveConfig || null); + if (!cfg) { + WARN('refreshHomeSectionById: no section config for', sectionId); + return false; + } + + const cardBuilder = window.cardBuilder; + if (!cardBuilder?.createSectionScrollableContainer || !cardBuilder?.renderSpotlightSection) { + WARN('refreshHomeSectionById: cardBuilder rebuild APIs unavailable'); + return false; + } + + let items = []; + try { + if (Array.isArray(cfg.items) && cfg.items.length > 0) { + items = cardBuilder.postProcessItems + ? cardBuilder.postProcessItems(cfg, cfg.items) + : cfg.items.slice(); + } else if (window.sectionHelper?.loadSectionForRendering) { + const loaded = await window.sectionHelper.loadSectionForRendering(cfg); + const result = loaded?.result; + const raw = typeof result?.ensureData === 'function' + ? await result.ensureData() + : await (result?.dataPromise ?? result?.data); + if (Array.isArray(raw)) items = raw; + else if (Array.isArray(raw?.Items)) items = raw.Items; + else items = []; + } else if (cardBuilder.refreshSectionQueries) { + items = await cardBuilder.refreshSectionQueries(cfg); + } + } catch (e) { + ERR('refreshHomeSectionById: failed to load items', sectionId, e); + return false; + } + + if (!items.length) { + WARN('refreshHomeSectionById: no items after load', sectionId); + return false; + } + + const title = cardBuilder.getSectionTitleForMultiQuery?.(cfg) || cfg.name || ''; + const viewMoreUrl = cardBuilder.getActiveViewMoreUrl?.(cfg) || cfg.viewMoreUrl || null; + const isSpotlight = cfg.renderMode === 'Spotlight' || cfg.spotlight === true; + + let newContent = null; + if (isSpotlight) { + newContent = cardBuilder.renderSpotlightSection(items, title, { + viewMoreUrl, + ...(cfg.spotlightConfig || {}), + }); + } else { + let cardFormat = cfg.cardFormat; + if (cardFormat === 'random' || cardFormat === 'Random') { + cardFormat = cardBuilder.getRandomCardFormat?.() || 'Poster'; + } + newContent = cardBuilder.createSectionScrollableContainer( + items, + title, + viewMoreUrl, + cfg.overflowCard !== false, + cardFormat, + cfg, + ); + } + + if (!newContent) { + WARN('refreshHomeSectionById: failed to build section DOM', sectionId); + return false; + } + + newContent.setAttribute('data-section-id', cfg.id); + if (cfg.order != null) { + newContent.dataset.order = String(cfg.order); + newContent.style.order = cfg.order; + } + if (cfg.cardFormat) { + newContent.setAttribute('data-card-format', cfg.cardFormat); + } + + // Preserve non-mode layout classes from the previous root (e.g. category filters) + const SPOTLIGHT_MODE_CLASSES = ['spotlight-section', 'padded-left']; + const NORMAL_MODE_CLASSES = ['verticalSection', 'emby-scroller-container', 'custom-scroller-container']; + const wasSpotlight = sectionElement.classList.contains('spotlight-section'); + const modeChanged = wasSpotlight !== isSpotlight; + if (modeChanged) { + const stale = wasSpotlight ? SPOTLIGHT_MODE_CLASSES : NORMAL_MODE_CLASSES; + sectionElement.classList.forEach((cls) => { + if (!stale.includes(cls)) newContent.classList.add(cls); + }); + } + + if (typeof cardBuilder.applySectionPresentationAttrs === 'function') { + cardBuilder.applySectionPresentationAttrs(newContent, cfg); + } + + const layout = cardBuilder.resolveItemsLayout?.(cfg) || 'row'; + const gapless = cardBuilder.resolveUseGaplessCards?.(cfg) === true; + + sectionElement.replaceWith(newContent); + + if (!isSpotlight && cardBuilder.applyItemsLayoutState) { + cardBuilder.applyItemsLayoutState(newContent, layout, gapless); + } + if (cardBuilder.ensureCardBorders) { + cardBuilder.ensureCardBorders(newContent); + } + if (cardBuilder.attachSectionControlButtons) { + cardBuilder.attachSectionControlButtons(cfg, newContent, items); + } + if (cardBuilder.registerLazyImagesInSection) { + cardBuilder.registerLazyImagesInSection(newContent); + } else if (cardBuilder.registerLazyImagesIn) { + cardBuilder.registerLazyImagesIn(newContent); + } + + if (runtime) { + runtime.element = newContent; + runtime.effectiveConfig = cfg; + runtime.cachedItems = items; + window.KefinHomeScreenSectionRuntime?.register?.(runtime); + } else if (window.KefinHomeScreenSectionRuntime?.register) { + window.KefinHomeScreenSectionRuntime.register({ + sectionId: cfg.id, + element: newContent, + effectiveConfig: cfg, + cachedItems: items, + serverDefaults: {}, + }); + } + + LOG('refreshHomeSectionById: replaced section', sectionId); + return true; + } + const HOME_CHROME_CREATE_ID = 'kefin-home-create-section-btn'; const HOME_CHROME_CATEGORY_RAIL_ID = 'kefin-home-category-rail'; const HOME_CHROME_CATEGORY_STYLE_ID = 'kefin-home-category-filter-styles'; @@ -2731,6 +2891,7 @@ window.homeScreen3 = { init: () => enqueueHomeViewJob({ type: 'home', hash: window.location.hash || '' }), refreshHomeSections, + refreshHomeSectionById, resolveDiscoverySection: buildDiscoverySectionInstance, sectionHelper: () => window.sectionHelper, getActiveHomeCategory: () => homeChromeActiveCategory || 'none',