diff --git a/configuration/homeScreen-configuration.js b/configuration/homeScreen-configuration.js index 4dd4c5e..f1a4045 100644 --- a/configuration/homeScreen-configuration.js +++ b/configuration/homeScreen-configuration.js @@ -70,6 +70,11 @@ const SORT_ORDERS = ['None', 'Random', 'Name', 'DateCreated', 'PremiereDate', 'CommunityRating', 'CriticRating', 'DatePlayed', 'SortName', 'PlayCount', 'PlayedPercentage', 'StartDate', 'Runtime', 'ProductionYear', 'IsPlayed', 'IsUnplayed', 'ParentIndexNumber', 'IndexNumber', 'IsFolder', 'SimilarityScore', 'SearchScore', 'DateLastContentAdded', 'SeriesDatePlayed', 'ChildCount']; const SORT_ORDER_DIRECTIONS = ['Ascending', 'Descending']; const RENDER_MODE_OPTIONS = [{ value: 'Normal', label: 'Normal' }, { value: 'Spotlight', label: 'Spotlight' }, { value: 'Random', label: 'Random' }]; + const SLIDE_STATE_OPTIONS = [ + { value: 'none', label: 'None' }, + { value: 'dots', label: 'Dots' }, + { value: 'numeric', label: 'Numeric' } + ]; // Current config state let currentConfig = null; @@ -1888,7 +1893,7 @@
Default behavior for spotlight sections on the home screen. Per-section overrides can be set when editing a section.
${buildToggleSlider('spotlight-autoPlay', spotlight.autoPlay !== false, 'Auto-play', { includeHiddenCheckbox: true })} - ${buildToggleSlider('spotlight-showDots', spotlight.showDots !== false, 'Show dot indicators', { includeHiddenCheckbox: true })} + ${buildSelect('spotlight-slideState', SLIDE_STATE_OPTIONS, (spotlight.showSlideState === false ? 'none' : (spotlight.showDots === false ? 'numeric' : 'dots')), 'Slide state')} ${buildToggleSlider('spotlight-showNavButtons', spotlight.showNavButtons !== false, 'Show prev/next buttons', { includeHiddenCheckbox: true })} ${buildToggleSlider('spotlight-showClearArt', spotlight.showClearArt === true, 'Show clear art', { includeHiddenCheckbox: true })} ${buildToggleSlider('spotlight-panAnimation', spotlight.panAnimation !== false, 'Pan animation', { includeHiddenCheckbox: true })} @@ -1918,7 +1923,7 @@
Spotlight Options
${buildToggleSlider(idPrefix + 'spotlight-autoPlay', spotlightOpts.autoPlay !== false, 'Auto-play', { includeHiddenCheckbox: true })} - ${buildToggleSlider(idPrefix + 'spotlight-showDots', spotlightOpts.showDots !== false, 'Show dot indicators', { includeHiddenCheckbox: true })} + ${buildSelect(idPrefix + 'spotlight-slideState', SLIDE_STATE_OPTIONS, (spotlightOpts.showSlideState === false ? 'none' : (spotlightOpts.showDots === false ? 'numeric' : 'dots')), 'Slide state')} ${buildToggleSlider(idPrefix + 'spotlight-showNavButtons', spotlightOpts.showNavButtons !== false, 'Show prev/next buttons', { includeHiddenCheckbox: true })} ${buildToggleSlider(idPrefix + 'spotlight-showClearArt', spotlightOpts.showClearArt === true, 'Show clear art', { includeHiddenCheckbox: true })} ${buildToggleSlider(idPrefix + 'spotlight-panAnimation', spotlightOpts.panAnimation !== false, 'Pan animation', { includeHiddenCheckbox: true })} @@ -2763,10 +2768,12 @@ // Spotlight options (saved when Render Mode = Spotlight) if (renderMode === 'Spotlight') { + const slideStateVal = dialog.querySelector('#discovery-spotlight-slideState')?.value || 'dots'; discovery.spotlightConfig = { autoPlay: dialog.querySelector('#discovery-spotlight-autoPlay')?.checked !== false, interval: parseInt(dialog.querySelector('#discovery-spotlight-interval')?.value || '10000', 10), - showDots: dialog.querySelector('#discovery-spotlight-showDots')?.checked !== false, + showSlideState: slideStateVal !== 'none', + showDots: slideStateVal === 'dots', showNavButtons: dialog.querySelector('#discovery-spotlight-showNavButtons')?.checked !== false, showClearArt: dialog.querySelector('#discovery-spotlight-showClearArt')?.checked === true, panAnimation: dialog.querySelector('#discovery-spotlight-panAnimation')?.checked !== false, @@ -2869,10 +2876,12 @@ section.renderMode = renderMode; if (renderMode === 'Spotlight') { + const slideStateVal = dialog.querySelector('#section-spotlight-slideState')?.value || 'dots'; section.spotlightConfig = { autoPlay: dialog.querySelector('#section-spotlight-autoPlay')?.checked !== false, interval: parseInt(dialog.querySelector('#section-spotlight-interval')?.value || '10000', 10), - showDots: dialog.querySelector('#section-spotlight-showDots')?.checked !== false, + showSlideState: slideStateVal !== 'none', + showDots: slideStateVal === 'dots', showNavButtons: dialog.querySelector('#section-spotlight-showNavButtons')?.checked !== false, showClearArt: dialog.querySelector('#section-spotlight-showClearArt')?.checked === true, panAnimation: dialog.querySelector('#section-spotlight-panAnimation')?.checked !== false, @@ -3123,10 +3132,12 @@ // Spotlight options (saved to spotlightConfig when Render Mode = Spotlight) if (renderMode === 'Spotlight') { + const slideStateVal = dialog.querySelector('#section-spotlight-slideState')?.value || 'dots'; section.spotlightConfig = { autoPlay: dialog.querySelector('#section-spotlight-autoPlay')?.checked !== false, interval: parseInt(dialog.querySelector('#section-spotlight-interval')?.value || '10000', 10), - showDots: dialog.querySelector('#section-spotlight-showDots')?.checked !== false, + showSlideState: slideStateVal !== 'none', + showDots: slideStateVal === 'dots', showNavButtons: dialog.querySelector('#section-spotlight-showNavButtons')?.checked !== false, showClearArt: dialog.querySelector('#section-spotlight-showClearArt')?.checked === true, panAnimation: dialog.querySelector('#section-spotlight-panAnimation')?.checked !== false, @@ -3193,10 +3204,12 @@ FORCE_REFRESH_TTL: 0 }; + const slideStateVal = dialog.querySelector('#spotlight-slideState')?.value || 'dots'; const spotlight = { autoPlay: dialog.querySelector('#spotlight-autoPlay')?.checked !== false, interval: parseInt(dialog.querySelector('#spotlight-interval')?.value || '10000', 10), - showDots: dialog.querySelector('#spotlight-showDots')?.checked !== false, + showSlideState: slideStateVal !== 'none', + showDots: slideStateVal === 'dots', showNavButtons: dialog.querySelector('#spotlight-showNavButtons')?.checked !== false, showClearArt: dialog.querySelector('#spotlight-showClearArt')?.checked === true, panAnimation: dialog.querySelector('#spotlight-panAnimation')?.checked !== false, diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css index ce5ca24..3369b35 100644 --- a/scripts/cardBuilder.css +++ b/scripts/cardBuilder.css @@ -104,6 +104,25 @@ transform: scale(1.02) translate3d(0, 0, 0); } } + +/* Full-screen: start images pre-scaled so they extend past edges from the start (no hard edge before zoom) */ +@keyframes kenBurnsZoomInFullscreen { + from { + transform: scale(1.08) translate3d(0, 0, 0); + } + to { + transform: scale(1.12) translate3d(-1%, -1%, 0); + } +} + +@keyframes kenBurnsZoomOutFullscreen { + from { + transform: scale(1.15) translate3d(-1%, -1%, 0); + } + to { + transform: scale(1.08) translate3d(0, 0, 0); + } +} @@ -208,6 +227,8 @@ padding: 8px 16px; border-radius: 4px; backdrop-filter: blur(4px); + letter-spacing: 2px; + word-spacing: 2px; } .spotlight-item-title { @@ -502,6 +523,26 @@ background: white; } +/* Numeric slide state: "N / X" via data attributes */ +.spotlight-dots.spotlight-dots-numeric { + background: rgb(0 0 0 / 40%); + border-radius: 5px; + padding: 0.25em 0.5em; + gap: 0; +} + +.spotlight-dots.spotlight-dots-numeric .spotlight-dot { + display: none; +} + +.spotlight-dots.spotlight-dots-numeric::after { + content: attr(data-index) " / " attr(data-total-items); + font-size: 14px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + letter-spacing: 0.5px; +} + .spotlight-genre-link, .spotlight-person-link { color: inherit; @@ -705,7 +746,7 @@ /* Full-screen spotlight: negative margins for overlap, content inset, top/bottom fade. Banner has NO padding so the background image overflows edge-to-edge; only content is inset. */ .spotlight-section[data-full-screen="true"] { - --spotlight-fullscreen-vertical: 200px; + --spotlight-fullscreen-vertical: 4%; --spotlight-fullscreen-horizontal: max(env(safe-area-inset-left), env(safe-area-inset-right), 4%); padding: 0; width: 100%; @@ -722,15 +763,15 @@ -webkit-mask-image: linear-gradient( to bottom, transparent 0%, - black 12%, - black 88%, + black calc(var(--spotlight-fullscreen-vertical) * 2), + black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 2)), transparent 100% ); mask-image: linear-gradient( to bottom, transparent 0%, - black 12%, - black 88%, + black calc(var(--spotlight-fullscreen-vertical) * 2), + black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 2)), transparent 100% ); -webkit-mask-size: 100% 100%; @@ -780,30 +821,56 @@ width: 100%; } +/* Full-screen: base scale so image extends past edges even before animation starts */ +.spotlight-section[data-full-screen="true"] .spotlight-background-single img, +.spotlight-section[data-full-screen="true"] .spotlight-background-layer img { + transform: scale(1.08); +} + +/* Full-screen: use pre-scaled animations so image never shows hard edge at top */ +.spotlight-section[data-full-screen="true"] .spotlight-background-single img.animate { + animation: kenBurnsZoomInFullscreen 20s ease-out forwards; +} +.spotlight-section[data-full-screen="true"] .spotlight-background-layer-left img.animate { + animation: kenBurnsZoomOutFullscreen 20s ease-out forwards; +} +.spotlight-section[data-full-screen="true"] .spotlight-background-layer-right img.animate { + animation: kenBurnsZoomInFullscreen 20s ease-out forwards; +} + /* Overlay stays left: 0 so the dark gradient still covers the image; only content is inset via padding. z-index above top/bottom gradient pseudo-elements so title and buttons stay readable. */ .spotlight-section[data-full-screen="true"] .spotlight-overlay { left: 0; z-index: 3; - padding: calc(var(--spotlight-fullscreen-vertical) + 50px) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) + 50px) var(--spotlight-fullscreen-horizontal); + padding: calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal); } .spotlight-section[data-full-screen="true"] .spotlight-overlay.has-title { - padding: calc(var(--spotlight-fullscreen-vertical) + 50px) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) + 50px) var(--spotlight-fullscreen-horizontal); + padding: calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal); } .spotlight-section[data-full-screen="true"] .spotlight-section-title { left: var(--spotlight-fullscreen-horizontal); - top: var(--spotlight-fullscreen-vertical); + top: calc(var(--spotlight-fullscreen-vertical) * 2.5); } .spotlight-section[data-full-screen="true"] .spotlight-item-logo { width: auto; max-width: none; - height: 40%; + min-height: 35% !important; + height: 35% !important; margin-bottom: 40px; } +.spotlight-section[data-full-screen="true"] .spotlight-item-title { + width: auto; + max-width: none; + height: 35% !important; + margin-bottom: 40px; + font-size: 4rem; +} + .spotlight-section[data-full-screen="true"] .spotlight-clearart { right: var(--spotlight-fullscreen-horizontal); bottom: var(--spotlight-fullscreen-vertical); @@ -811,15 +878,15 @@ .spotlight-section[data-full-screen="true"] .spotlight-nav-container { right: var(--spotlight-fullscreen-horizontal); - top: var(--spotlight-fullscreen-vertical); + top: calc(var(--spotlight-fullscreen-vertical) * 2.5); } .spotlight-section[data-full-screen="true"] .spotlight-dots { - bottom: var(--spotlight-fullscreen-vertical); + bottom: calc(var(--spotlight-fullscreen-vertical) * 2.5); } .spotlight-section[data-full-screen="true"] img { - object-position: center -35%; + object-position: center 0%; } .spotlight-section[data-full-screen="true"] .spotlight-clearart { display: none; @@ -834,6 +901,7 @@ -webkit-line-clamp: 6; font-style: normal; font-size: 1.05em; + margin-bottom: 0; } /* Full-screen: modern stream-site metadata styles */ @@ -906,4 +974,18 @@ .spotlight-section[data-full-screen="true"] span.spotlight-end-time { padding-left: 30px !important; position: relative; +} + +.spotlight-section[data-full-screen="true"] .spotlight-buttons-container { + margin-top: 40px; +} + +.spotlight-section[data-full-screen="true"] .spotlight-item-logo { + width: 95%; +} + + +.layout-mobile .spotlight-section[data-full-screen="true"] .spotlight-section-title { + left: var(--spotlight-fullscreen-horizontal); + top: var(--spotlight-fullscreen-vertical); } \ No newline at end of file diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index 1cdb3e6..2027711 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -528,7 +528,6 @@ postProcessItems: postProcessItems, postProcessItemsByQuery: postProcessItemsByQuery, /** ->>>>>>> Stashed changes * Main entry point function to build a Jellyfin card * @param {Object} item - The Jellyfin item object * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes @@ -1688,7 +1687,8 @@ * @param {Object} options - Options for the spotlight carousel * @param {boolean} options.autoPlay - Auto-cycle through items (default: true) * @param {number} options.interval - Auto-play interval in ms (default: 10000) - * @param {boolean} options.showDots - Show dot indicators (default: true) + * @param {boolean} options.showSlideState - Show slide state container (dots or numeric) (default: true) + * @param {boolean} options.showDots - When showSlideState is true: true = dots (max 5), false = numeric "N / X" (default: true) * @param {boolean} options.showNavButtons - Show prev/next buttons (default: true) * @param {boolean} options.pauseOnHover - Pause auto-cycle when cursor is over spotlight (default: true, false when fullScreen) * @returns {HTMLElement} - The constructed spotlight container @@ -1701,6 +1701,7 @@ const { autoPlay = true, interval = 10000, + showSlideState = true, showDots = true, showNavButtons = true, showClearArt = false, @@ -2450,22 +2451,29 @@ navContainer.insertBefore(pauseButton, navContainer.firstChild); } - // Dot indicators - if (showDots && items.length > 1) { + // Slide state container (dots or numeric "N / X") + const MAX_DOTS = 5; + if (showSlideState && items.length > 1) { const dotsContainer = document.createElement('div'); - dotsContainer.className = 'spotlight-dots'; + dotsContainer.className = 'spotlight-dots' + (showDots ? '' : ' spotlight-dots-numeric'); + dotsContainer.setAttribute('data-index', '1'); /* 1-based for "N / X" display */ + dotsContainer.setAttribute('data-total-items', String(items.length)); - items.forEach((_, index) => { - const dot = document.createElement('button'); - dot.className = 'spotlight-dot' + (index === 0 ? ' active' : ''); - dot.setAttribute('data-index', index); - dot.addEventListener('click', (e) => { - e.stopPropagation(); - // Reset timer when manually navigating - goToItem(index, true); - }); - dotsContainer.appendChild(dot); - }); + if (showDots) { + const numDots = Math.min(items.length, MAX_DOTS); + for (let i = 0; i < numDots; i++) { + const dot = document.createElement('button'); + dot.className = 'spotlight-dot' + (i === 0 ? ' active' : ''); + dot.setAttribute('data-dot-index', i); + dot.addEventListener('click', (e) => { + e.stopPropagation(); + const windowStart = Math.floor(currentIndex / numDots) * numDots; + const targetIndex = Math.min(windowStart + i, items.length - 1); + goToItem(targetIndex, true); + }); + dotsContainer.appendChild(dot); + } + } bannerContainer.appendChild(dotsContainer); } @@ -2504,7 +2512,7 @@ void img.offsetWidth; // force reflow to restart animation img.classList.add('animate'); const handler = (e) => { - if (e.animationName === 'kenBurnsZoomOut' || e.animationName === 'kenBurnsZoomIn') { + if (['kenBurnsZoomOut', 'kenBurnsZoomIn', 'kenBurnsZoomOutFullscreen', 'kenBurnsZoomInFullscreen'].includes(e.animationName)) { img.removeEventListener('animationend', handler); checkAllDone(); } @@ -2530,16 +2538,24 @@ currentIndex = index; - // Update dots - if (showDots) { - const dots = bannerContainer.querySelectorAll('.spotlight-dot'); - dots.forEach((dot, i) => { - if (i === index) { - dot.classList.add('active'); - } else { - dot.classList.remove('active'); - } - }); + // Update slide state (dots or data attrs for numeric) + if (showSlideState) { + const dotsContainer = bannerContainer.querySelector('.spotlight-dots'); + if (dotsContainer) { + dotsContainer.setAttribute('data-index', String(index + 1)); // 1-based for display + } + if (showDots) { + const dots = bannerContainer.querySelectorAll('.spotlight-dot'); + const numDots = dots.length; + const activeDotIndex = numDots > 0 ? (index % numDots) : 0; + dots.forEach((dot, i) => { + if (i === activeDotIndex) { + dot.classList.add('active'); + } else { + dot.classList.remove('active'); + } + }); + } } // Reset auto-play timer when manually navigating (always reset delay) @@ -2623,7 +2639,7 @@ }; imgs.forEach(img => { const handler = (e) => { - if (e.animationName === 'kenBurnsZoomOut' || e.animationName === 'kenBurnsZoomIn') { + if (['kenBurnsZoomOut', 'kenBurnsZoomIn', 'kenBurnsZoomOutFullscreen', 'kenBurnsZoomInFullscreen'].includes(e.animationName)) { img.removeEventListener('animationend', handler); checkAllDone(); } @@ -2758,7 +2774,7 @@ if (viewMoreUrl) { // Create clickable title with chevron icon const titleLink = document.createElement('a'); - titleLink.className = 'sectionTitle-link sectionTitleTextButton'; + titleLink.className = 'sectionTitle-link button-flat button-flat-mini sectionTitleTextButton emby-button'; titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;'; // Handle both URL and function diff --git a/scripts/homeScreen.css b/scripts/homeScreen.css index 606b089..bcf1257 100644 --- a/scripts/homeScreen.css +++ b/scripts/homeScreen.css @@ -24,40 +24,6 @@ padding-bottom: 100px; } -.homeSectionsContainer > .section0 { - order: 1; -} -.homeSectionsContainer > .section1 { - order: 2; -} -.homeSectionsContainer > .section2 { - order: 3; -} -.homeSectionsContainer > .section3 { - order: 4; -} -.homeSectionsContainer > .section4 { - order: 5; -} -.homeSectionsContainer > .section5 { - order: 6; -} -.homeSectionsContainer > .section6 { - order: 7; -} -.homeSectionsContainer > .section7 { - order: 8; -} -.homeSectionsContainer > .section8 { - order: 9; -} -.homeSectionsContainer > .section9 { - order: 10; -} -.homeSectionsContainer > .section10 { - order: 11; -} - body[data-seasonal-background="true"]:has(.homePage:not(.hide)) #reactRoot:has(.libraryPage:not(.hide) .pageTabContent.is-active[data-index="0"])>.backgroundContainer::after { content: ''; position: absolute; diff --git a/scripts/homeScreen3.js b/scripts/homeScreen3.js index ec4e817..f9332ae 100644 --- a/scripts/homeScreen3.js +++ b/scripts/homeScreen3.js @@ -994,15 +994,6 @@ if (!homeSection) { continue; } - if (homeSection.toLowerCase() === 'nextup' && state.kefinNextUp) { - continue; - } - if (homeSection.toLowerCase() === 'resume' && state.kefinContinueWatching) { - continue; - } - if (homeSection.toLowerCase() === 'latestmedia' && state.kefinLatestMedia) { - continue; - } // Match to homeSection in kefinHomeScreen with .toLowerCase() id let kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase() === homeSection.toLowerCase() && s.enabled === true); @@ -1017,7 +1008,7 @@ if (kefinHomeScreenSection) { jellyfinOrders[`homesection${i}`] = kefinHomeScreenSection.order; } else { - jellyfinOrders[`homesection${i}`] = 0; + jellyfinOrders[`homesection${i}`] = 999; } } @@ -1040,7 +1031,7 @@ // Add display: none to all .homeectionN not in jellyfinOrders for (let i = 0; i <= 8; i++) { const homesectionKey = `homesection${i}`; - if (isNaN(jellyfinOrders[homesectionKey])) { + if (jellyfinOrders[homesectionKey] && isNaN(jellyfinOrders[homesectionKey])) { const classSelector = `.${homesectionKey.replace('home','')}`; css += `${classSelector} { display: none !important; }\n`; } @@ -1367,14 +1358,22 @@ const loadSectionTimerEnd = performance.now(); const loadSectionDuration = loadSectionTimerEnd - loadSectionTimerStart; LOG(`Section ${sectionConfig.id} loaded for rendering in time: ${loadSectionDuration.toFixed(2)}ms`); + + const postProcess = (sectionConfig, items) => { + let postProcessedItems = items; + if (window.cardBuilder.postProcessItems) { + postProcessedItems = window.cardBuilder.postProcessItems(sectionConfig, items); + } + return postProcessedItems; + } // Single query result return { config: sectionConfig, queryUrl: null, result: { - data: window.cardBuilder.postProcessItems(sectionConfig, results[0].data), - dataPromise: results[0].dataPromise.then(data => window.cardBuilder.postProcessItems(sectionConfig, data)), + data: postProcess(sectionConfig, results[0].data), + dataPromise: results[0].dataPromise.then(data => postProcess(sectionConfig, data)), isStalePromise: results[0].isStalePromise } };