From ef46c9372c8f6941c4a432d36e0434d4592a0a74 Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Sat, 21 Feb 2026 23:54:19 -0500 Subject: [PATCH] Improved rendering of progressive sections, also Spotlight Improvements: Layout and Size options, Animation performance improved, DOM footprint reduced --- scripts/cardBuilder.css | 870 ++++++++++++++++++++++++++++++---------- scripts/cardBuilder.js | 834 ++++++++++++++++++++++++++------------ 2 files changed, 1246 insertions(+), 458 deletions(-) diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css index 3369b35..b338568 100644 --- a/scripts/cardBuilder.css +++ b/scripts/cardBuilder.css @@ -8,12 +8,16 @@ .spotlight-banner-container { position: relative; - width: 100%; height: 450px; overflow: hidden; border-radius: 8px; } +/* Size variants */ +.spotlight-section[data-size="full"] .spotlight-banner-container { + height: 94vh !important; +} + .spotlight-items-container { position: relative; width: 100%; @@ -39,6 +43,10 @@ overflow: hidden; } +.spotlight-section[data-layout="Border"] .spotlight-background-dual { + border-radius: 8px; +} + .spotlight-background-layer { position: absolute; top: 0; @@ -58,13 +66,17 @@ filter: blur(0); transform: scale(1); transition: filter 0.3s ease-in-out, transform 0.3s ease-in-out; + will-change: transform; + backface-visibility: hidden; } .spotlight-background-layer-left img.animate { animation: kenBurnsZoomOut 20s ease-out forwards; + transform-origin: center right; } .spotlight-background-layer-right img.animate { animation: kenBurnsZoomIn 20s ease-out forwards; + transform-origin: center left; } /* Single backdrop (when dual backdrops disabled or only one image) - same pan animation */ @@ -84,67 +96,125 @@ } .spotlight-background-single img.animate { - animation: kenBurnsZoomIn 20s ease-out forwards; + animation: kenBurnsZoomIn 15s ease-out forwards; + will-change: transform; + backface-visibility: hidden; + transform-origin: center center; } @keyframes kenBurnsZoomIn { from { - transform: scale(1) translate3d(0, 0, 0); + transform: scale3d(1, 1, 0); } to { - transform: scale(1.12) translate3d(-1%, -1%, 0); + transform: scale3d(1.12, 1.12, 0.1); } } @keyframes kenBurnsZoomOut { from { - transform: scale(1.15) translate3d(-1%, -1%, 0); + transform: scale3d(1.15, 1.15, 0.1); } to { - transform: scale(1.02) translate3d(0, 0, 0); + transform: scale3d(1.02, 1.02, 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); + transform: scale3d(1.08, 1.08, 0); } to { - transform: scale(1.12) translate3d(-1%, -1%, 0); + transform: scale3d(1.12, 1.12, 0.1); } } @keyframes kenBurnsZoomOutFullscreen { from { - transform: scale(1.15) translate3d(-1%, -1%, 0); + transform: scale3d(1.15, 1.15, 0.1); } to { - transform: scale(1.08) translate3d(0, 0, 0); + transform: scale3d(1.08, 1.08, 0); } } - - -@keyframes slowPanRight { - from { - transform: scale(1.1) translate3d(-3%, 0, 0); - } - to { - transform: scale(1.1) translate3d(3%, 0, 0); - } - } +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} - @keyframes slowPanLeft { - from { - transform: scale(1.1) translate3d(3%, 0, 0); - } - to { - transform: scale(1.1) translate3d(-3%, 0, 0); - } +@keyframes fadeInUp { + from { opacity: 0; transform: scale3d(1, 1, 1) translateY(30px); } + to { opacity: 1; transform: scale3d(1, 1, 1) translateY(0); } +} + +@keyframes fadeInDown { + from { opacity: 0; transform: scale3d(1, 1, 1) translateY(-30px); } + to { opacity: 1; transform: scale3d(1, 1, 1) translateY(0); } +} + +@keyframes kenBurnsPanRight { + from { transform: scale3d(1.1, 1.1, 1) translateX(-2%); } + to { transform: scale3d(1.1, 1.1, 1) translateX(2%); } } + @keyframes kenBurnsPanLeft { + from { transform: scale3d(1.1, 1.1, 1) translateX(2%); } + to { transform: scale3d(1.1, 1.1, 1) translateX(-2%); } + } + @keyframes kenBurnsPanUp { + from { transform: scale3d(1.1, 1.1, 1) translateY(2%); } + to { transform: scale3d(1.1, 1.1, 1) translateY(-2%); } + } + + @keyframes kenBurnsDiagonal { + from { transform: scale3d(1.08, 1.08, 1) translate(-2%, 2%); } + to { transform: scale3d(1.13, 1.13, 1) translate(2%, -2%); } + } + + @keyframes fadeInScale { + from { opacity: 0; transform: scale3d(0.95, 0.95, 1); } + to { opacity: 1; transform: scale3d(1, 1, 1); } + } + + @keyframes parallaxFloat { + 0%, 100% { transform: scale3d(1, 1, 1) translateY(0); } + 50% { transform: scale3d(1, 1, 1) translateY(-12px); } + } + + @keyframes depthPulse { + 0%, 100% { transform: scale3d(1, 1, 1); } + 50% { transform: scale3d(1.03, 1.03, 1); } + } + + @keyframes slowRotate { + from { transform: scale3d(1.15, 1.15, 1) rotate(0deg); } + to { transform: scale3d(1.15, 1.15, 1) rotate(3deg); } + } + + @keyframes breathe { + 0%, 100% { transform: scale3d(1, 1, 1); opacity: 0.9; } + 50% { transform: scale3d(1.04, 1.04, 1); opacity: 1; } + } + + @keyframes heatHaze { + 0%, 100% { transform: scale3d(1.05, 1.05, 1) skewX(0deg); } + 25% { transform: scale3d(1.06, 1.06, 1) skewX(0.3deg); } + 75% { transform: scale3d(1.05, 1.05, 1) skewX(-0.3deg); } + } + + @keyframes colorWash { + 0% { filter: saturate(1) brightness(1); } + 50% { filter: saturate(1.2) brightness(1.05); } + 100% { filter: saturate(1) brightness(1); } + } + + @keyframes vignetteIn { + from { box-shadow: inset 0 0 0px rgba(0,0,0,0); } + to { box-shadow: inset 0 0 120px rgba(0,0,0,0.6); } + } /* Layer masks for elegant crossfade - overlap in center (45-55%) */ .spotlight-background-layer-left { @@ -197,6 +267,11 @@ opacity: 0; } +/* Logo section heights per size - fixed to prevent layout shift when sliding between items */ +.spotlight-section { --spotlight-logo-height: 120px; } +.spotlight-section[data-size="large"] { --spotlight-logo-height: 180px; } +.spotlight-section[data-size="full"] { --spotlight-logo-height: 240px; } + .spotlight-overlay { position: absolute; top: 0; @@ -206,24 +281,29 @@ background: linear-gradient(to right, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.7) 70%, transparent 100%); padding: 40px; color: white; - display: flex; - flex-direction: column; + display: grid; + grid-template-rows: var(--spotlight-logo-height) auto 1fr minmax(48px, auto); + grid-template-areas: "logo" "metadata" "overview" "buttons"; + align-content: start; + gap: 1em; z-index: 2; } +.spotlight-overlay .spotlight-logo-section { grid-area: logo; height: var(--spotlight-logo-height); min-height: 0; display: flex; align-items: flex-end; overflow: hidden; align-content: center; flex-wrap: wrap; } +.spotlight-overlay .spotlight-metadata-section { grid-area: metadata; } +.spotlight-overlay .spotlight-overview-section { grid-area: overview; display: flex; flex-direction: column; justify-content: flex-start; overflow: hidden; } +.spotlight-overlay .spotlight-buttons-section { grid-area: buttons; display: flex; align-items: center; } + .spotlight-overlay.has-title { - padding: 70px 40px 20px 40px; + padding: 50px 40px 20px 40px; } .spotlight-section-title { position: absolute; - top: 20px; - left: 20px; color: white; font-size: 18px; font-weight: 600; z-index: 10; - background: rgba(0,0,0,0.4); padding: 8px 16px; border-radius: 4px; backdrop-filter: blur(4px); @@ -231,20 +311,29 @@ word-spacing: 2px; } +.spotlight-section-title a { + font-size: 1.1rem; +} + .spotlight-item-title { - margin: 0 0 16px 0; - font-size: 1.6em; + margin: 0; + font-size: 3.5em; font-weight: bold; - height: 80px; - align-content: end; + min-height: 0; + display: flex; + align-items: flex-end; + overflow: hidden; +} +.spotlight-item-title:not(h3) { + height: 100%; } .spotlight-item-logo { - max-width: 206.8px; - height: 80px; + height: 100%; + max-height: var(--spotlight-logo-height); + width: 100%; object-fit: contain; - margin-bottom: 16px; - margin-top: 10px; + margin: 0; background-size: contain; background-repeat: no-repeat; background-position: left; @@ -262,14 +351,22 @@ } .spotlight-rating-container { - display: flex; + -webkit-align-items: center; align-items: center; - gap: 8px; + display: -webkit-flex; + display: flex; + -webkit-justify-content: center; + justify-content: center; + vertical-align: middle; + margin: 0 1em 0 0; } .spotlight-rating-star { - color: #4A9EFF; - font-size: 24px; + color: #f2b01e; + font-size: 1.4em; + height: auto !important; + margin-right: .125em; + width: auto !important; } .spotlight-rating-value { @@ -288,48 +385,160 @@ gap: 12px; font-size: 14px; opacity: 0.9; + overflow: visible; + min-width: 0; } -.spotlight-metadata-row .spotlight-end-time { - opacity: 0.8; +.spotlight-metadata-row .itemDetailsGroup, +.spotlight-metadata-row .sectionTabs { + flex-shrink: 0; +} + +.spotlight-credits-row .spotlight-truncated-list { + flex-shrink: 1; + min-width: 0; +} + +/* Keep each name on one line (no mid-name wrapping) */ +.spotlight-credits-row .spotlight-person-link, +.spotlight-credits-row .spotlight-truncated-list .detailsGroupItem > span:not(.spotlight-list-separator):not(.spotlight-truncated-list-label), +.spotlight-credits-row .spotlight-truncated-list .emby-tab-button > span:not(.spotlight-list-separator):not(.spotlight-truncated-list-label) { + white-space: nowrap; +} + +/* Metadata row icons (end time, etc.) - same pattern as truncated list icons */ +.spotlight-metadata-row .emby-tab-button[data-icon] { + display: inline-flex; + align-items: center; +} +.spotlight-metadata-row .emby-tab-button[data-icon]::before { + content: attr(data-icon); + font-family: 'Material Icons'; + font-size: 1.1em; + margin-right: 6px; + opacity: 0.9; + text-transform: none; +} + +.spotlight-metadata-row .spotlight-studio { + display: inline-flex; + align-items: center; +} +.spotlight-metadata-row .spotlight-studio::before { + content: '\ea40'; /* Material Icons 'apartment' codepoint */ + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 1.1em; + margin-right: 6px; + opacity: 0.9; } .metadata-container { display: flex; flex-direction: column; + align-items: start; gap: 8px; - margin-bottom: 2em; + overflow: visible; + min-width: 0; + width: 100%; } .spotlight-truncated-list { font-size: 14px; opacity: 0.9; position: relative; + overflow: hidden; + min-width: 0; + max-width: 100%; } -.spotlight-truncated-list .spotlight-ellipsis { +/* Allow tooltip to escape overflow when present */ +.spotlight-truncated-list:has(.spotlight-truncated-list-tooltip) { + overflow: visible; +} + +/* Tooltip for truncated lists - full list on hover after 1s, no layout shift */ +.spotlight-truncated-list-tooltip { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 8px; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.95); + color: white; + border-radius: 8px; + font-size: 14px; + line-height: 1.5; + max-width: 400px; + min-width: 200px; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + word-wrap: break-word; + display: flex; + flex-direction: column; + gap: 4px; +} + +.spotlight-truncated-list-tooltip-items { + display: flex; + flex-direction: column; + gap: 4px; +} + +.spotlight-truncated-list-tooltip-item { + display: block; +} + +.spotlight-truncated-list-tooltip.visible { + opacity: 1; + pointer-events: auto; /* Allow hovering tooltip to keep it visible */ +} + +.spotlight-truncated-list-tooltip .spotlight-person-link, +.spotlight-truncated-list-tooltip .spotlight-genre-link, +.spotlight-truncated-list-tooltip .spotlight-studio-link { + color: inherit; + text-decoration: underline; cursor: pointer; } -.spotlight-tagline { - margin: 0 0 24px 0; - font-size: 18px; - font-style: italic; - opacity: 0.9; - line-height: 1.4; - max-width: 90%; +.spotlight-list-separator { + opacity: 0.75; + margin: 0 0.4em; + user-select: none; +} + +/* Overview: shown for all layouts with line clamping to avoid cut-off text */ +.spotlight-overview { display: -webkit-box; + margin: 0; + font-size: 15px; + font-style: normal; + opacity: 0.9; + line-height: 1.45; + max-width: 75%; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; - min-height: 75px; - max-height: 75px; +} + +.spotlight-section[data-size="large"] .spotlight-overview { + -webkit-line-clamp: 4; +} + +.spotlight-section[data-size="full"] .spotlight-overview { + font-size: 1.05em; + -webkit-line-clamp: 4; } .spotlight-buttons-container { display: flex; - gap: 12px; + gap: 5px; align-items: center; margin-top: auto; } @@ -447,27 +656,34 @@ .spotlight-nav-container { position: absolute; - top: 20px; - right: 20px; + top: 0px; + right: 0px; display: flex; gap: 8px; z-index: 10; - background: rgba(0,0,0,0.4); +} + +.spotlight-section[data-layout="Border"] .spotlight-nav-container { border-radius: 4px; - padding: 4px; + top: 5px !important; + right: 5px !important; +} + +.spotlight-section[data-layout="Border"] .spotlight-section-title { + border-radius: 4px; + top: 5px !important; + left: 5px !important; } .spotlight-nav-button { - width: 36px; - height: 36px; background: transparent; - color: white; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; + margin: 0 !important; transition: background 0.2s; } @@ -475,29 +691,6 @@ background: rgba(255,255,255,0.2); } -.spotlight-nav-button .material-icons { - font-size: 24px; -} - -.spotlight-pause-button { - width: 36px; - height: 36px; - background: transparent; - color: white; - border: none; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - z-index: 10; - padding: 0; - transition: background 0.2s; -} - -.spotlight-pause-button .material-icons { - font-size: 24px; -} - .spotlight-dots { position: absolute; bottom: 20px; @@ -527,7 +720,7 @@ .spotlight-dots.spotlight-dots-numeric { background: rgb(0 0 0 / 40%); border-radius: 5px; - padding: 0.25em 0.5em; + padding: 0.25rem 0.5rem; gap: 0; } @@ -544,7 +737,8 @@ } .spotlight-genre-link, -.spotlight-person-link { +.spotlight-person-link, +.spotlight-studio-link { color: inherit; text-decoration: underline; cursor: pointer; @@ -582,8 +776,9 @@ } .spotlight-tagline { - max-height: 175px !important; - -webkit-line-clamp: 4; + max-height: 2.5em !important; + -webkit-line-clamp: 1; + bottom: 60px; } .spotlight-item-logo { @@ -596,15 +791,10 @@ margin-bottom: 45px !important; } -[data-item-type="Movie"] .spotlight-item-logo { - height: 120px; - max-width: 310.2px; -} - +/* Tagline always 1 line - Movie-specific override kept for any edge cases */ [data-item-type="Movie"] .spotlight-tagline { -webkit-line-clamp: 1; - min-height: 30px; - max-height: 30px; + max-height: 2.5em; } // Remove this if we switch back to Jellyfin's default scroll buttons @@ -624,6 +814,23 @@ z-index: 1; } +/* Blurhash placeholder canvas: fill card image area so it's visible until the real image loads */ +/* .cardScalable { + position: relative; +} +.cardScalable .blurhash-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; +} +.cardScalable .blurhash-canvas.lazy-hidden { + display: none !important; +} */ + /* Progressive Section Reveal Animation */ .cardbuilder-section-reveal { opacity: 0; @@ -743,20 +950,19 @@ display: initial !important; } -/* Full-screen spotlight: negative margins for overlap, content inset, top/bottom fade. +/* Borderless layout: full width, no border, negative margins for overlap. Banner has NO padding so the background image overflows edge-to-edge; only content is inset. */ -.spotlight-section[data-full-screen="true"] { +.spotlight-section[data-layout="Borderless"] { --spotlight-fullscreen-vertical: 4%; --spotlight-fullscreen-horizontal: max(env(safe-area-inset-left), env(safe-area-inset-right), 4%); padding: 0; width: 100%; - margin-top: calc(-1 * calc(var(--spotlight-fullscreen-vertical) / 2)); - margin-bottom: calc(-1 * calc(var(--spotlight-fullscreen-vertical) / 2)); + margin-top: calc(-1 * calc(var(--spotlight-fullscreen-vertical) * 1)); + margin-bottom: calc(-1 * calc(var(--spotlight-fullscreen-vertical) * 2)); } -.spotlight-section[data-full-screen="true"] .spotlight-banner-container { +.spotlight-section[data-layout="Borderless"] .spotlight-banner-container { border-radius: 0; - height: 90vh !important; /* No padding: background fills full size so image extends edge-to-edge and into overlap zone */ position: relative; /* Wider mask so backdrop fades gradually at top/bottom (no visible solid edge during animation) */ @@ -781,8 +987,8 @@ } /* Dark gradient overlays at top and bottom (like left-side overlay): graceful fade and better visibility when content overlaps */ -.spotlight-section[data-full-screen="true"] .spotlight-banner-container::before, -.spotlight-section[data-full-screen="true"] .spotlight-banner-container::after { +.spotlight-section[data-layout="Borderless"] .spotlight-banner-container::before, +.spotlight-section[data-layout="Borderless"] .spotlight-banner-container::after { content: ''; position: absolute; left: 0; @@ -793,7 +999,7 @@ z-index: 2; } -.spotlight-section[data-full-screen="true"] .spotlight-banner-container::before { +.spotlight-section[data-layout="Borderless"] .spotlight-banner-container::before { top: 0; background: linear-gradient( to bottom, @@ -803,7 +1009,7 @@ ); } -.spotlight-section[data-full-screen="true"] .spotlight-banner-container::after { +.spotlight-section[data-layout="Borderless"] .spotlight-banner-container::after { bottom: 0; background: linear-gradient( to top, @@ -813,179 +1019,419 @@ ); } -.spotlight-section[data-full-screen="true"] .spotlight-items-container, -.spotlight-section[data-full-screen="true"] .spotlight-item { +.spotlight-section[data-layout="Borderless"] .spotlight-items-container, +.spotlight-section[data-layout="Borderless"] .spotlight-item { /* Full bleed: fill banner so background extends to edges (and past negative margin) */ left: 0; right: 0; 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 { +/* Borderless: base scale so image extends past edges even before animation starts */ +.spotlight-section[data-layout="Borderless"] .spotlight-background-single img, +.spotlight-section[data-layout="Borderless"] .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 { +/* Borderless: use pre-scaled animations so image never shows hard edge at top */ +/* .spotlight-section[data-layout="Borderless"] .spotlight-background-single img.animate { animation: kenBurnsZoomInFullscreen 20s ease-out forwards; } -.spotlight-section[data-full-screen="true"] .spotlight-background-layer-left img.animate { +.spotlight-section[data-layout="Borderless"] .spotlight-background-layer-left img.animate { animation: kenBurnsZoomOutFullscreen 20s ease-out forwards; } -.spotlight-section[data-full-screen="true"] .spotlight-background-layer-right img.animate { +.spotlight-section[data-layout="Borderless"] .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 { +/* Overlay: same ~45% width as smaller spotlights for consistent gradient and content area. */ +.spotlight-section[data-layout="Borderless"] .spotlight-overlay { left: 0; + width: 45%; z-index: 3; padding: calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal); + grid-template-rows: var(--spotlight-logo-height) auto 1fr minmax(48px, auto); + gap: 1.5em; + background: linear-gradient(to right, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.7) 70%, transparent 100%); } -.spotlight-section[data-full-screen="true"] .spotlight-overlay.has-title { - padding: calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal); +.spotlight-section[data-layout="Borderless"][data-size="full"] .spotlight-overlay { + align-content: start; + gap: 1.5em; } -.spotlight-section[data-full-screen="true"] .spotlight-section-title { +.spotlight-section[data-layout="Borderless"][data-size="full"] .spotlight-credits-row { + flex-wrap: nowrap; + flex-direction: row; +} + + +.spotlight-section[data-layout="Borderless"] .spotlight-overlay.has-title { + padding: calc(var(--spotlight-fullscreen-vertical) * 2) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) * 2.25) var(--spotlight-fullscreen-horizontal); +} + +/* Duplicate removed - already defined above for Borderless */ + +.spotlight-section[data-size="full"] .spotlight-item-title { + width: auto; + max-width: none; + font-size: 4rem; +} + +/* Duplicate removed - clearart display:none already set for Borderless above */ + +.spotlight-section[data-layout="Borderless"] .spotlight-section-title { left: var(--spotlight-fullscreen-horizontal); top: calc(var(--spotlight-fullscreen-vertical) * 2.5); } -.spotlight-section[data-full-screen="true"] .spotlight-item-logo { - width: auto; - max-width: none; - 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); -} - -.spotlight-section[data-full-screen="true"] .spotlight-nav-container { +.spotlight-section[data-layout="Borderless"] .spotlight-nav-container { right: var(--spotlight-fullscreen-horizontal); top: calc(var(--spotlight-fullscreen-vertical) * 2.5); } -.spotlight-section[data-full-screen="true"] .spotlight-dots { - bottom: calc(var(--spotlight-fullscreen-vertical) * 2.5); +.spotlight-section[data-layout="Borderless"] .spotlight-dots { + bottom: calc(var(--spotlight-fullscreen-vertical) * 4.5); } -.spotlight-section[data-full-screen="true"] img { +.spotlight-section .spotlight-background-layer img, +.spotlight-section .spotlight-background-single img { object-position: center 0%; } -.spotlight-section[data-full-screen="true"] .spotlight-clearart { +.spotlight-section[data-layout="Borderless"] .spotlight-clearart { display: none; } -:has(.spotlight-section[data-full-screen="true"]) .homeSectionsContainer > *:not(.spotlight-section) { +:has(.spotlight-section[data-layout="Borderless"]) .homeSectionsContainer > *:not(.spotlight-section) { z-index: 1; } -.spotlight-section[data-full-screen="true"] .spotlight-tagline { - max-height: 150px !important; - -webkit-line-clamp: 6; - font-style: normal; +/* Tagline at bottom center above navigation dots (all spotlights) - always 1 line max */ +.spotlight-tagline { + position: absolute !important; + left: 50%; + transform: translateX(-50%); + width: fit-content; + max-width: min(85%, 700px); + max-height: 2.5em; + text-align: center; + font-style: italic; font-size: 1.05em; - margin-bottom: 0; + margin: 0; + padding: 8px 16px; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + border-radius: 6px; + z-index: 4; + pointer-events: none; + line-height: 1.4; +} +.spotlight-tagline > *, .spotlight-section-title > *, .spotlight-nav-container > * { + background: rgba(0, 0, 0, 0.5); + border-radius: 4px; +} +.spotlight-section:not(.hide) .spotlight-tagline p { + font-weight: initial !important; + transform: initial !important; + width: 100%; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + cursor: default; } -/* Full-screen: modern stream-site metadata styles */ -.spotlight-section[data-full-screen="true"] .metadata-container { +/* Normal and large: tagline above dots (dots at bottom: 20px) */ +.spotlight-section[data-layout="Border"] .spotlight-tagline { + bottom: 40px; +} + +.spotlight-section[data-layout="Borderless"] .spotlight-tagline { + bottom: calc(var(--spotlight-fullscreen-vertical) * 4.5 + 40px); +} + +/* Size full: modern stream-site metadata styles */ +.spotlight-section[data-size="full"] .metadata-container { gap: 10px; } -.spotlight-section[data-full-screen="true"] .spotlight-metadata-row { +.spotlight-section[data-size="full"] .spotlight-metadata-row { font-size: 15px; gap: 10px; } -/* Pill-style for year, duration, genres, director/writer/series, end time */ -.spotlight-section[data-full-screen="true"] .spotlight-metadata-row > span { - padding: 5px 12px; +/* Full-screen: uniform padding for all metadata pills (year, duration, genres, director, writer, series) */ +/* .spotlight-section[data-size="full"] .spotlight-metadata-row .detailsGroupItem, +.spotlight-section[data-size="full"] .metadata-container .detailsGroupItem { border-radius: 6px; background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.2); -} +} */ -.spotlight-section[data-full-screen="true"] .spotlight-end-time { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.spotlight-section[data-full-screen="true"] .spotlight-end-time .material-icons { - font-size: 1.1em; - opacity: 0.9; -} - -.spotlight-section[data-full-screen="true"] .spotlight-genre-link { +.spotlight-section[data-size="full"] .spotlight-genre-link { display: inline-block; - padding: 5px 12px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 255, 255, 0.2); transition: background 0.2s, border-color 0.2s; } -.spotlight-section[data-full-screen="true"] .spotlight-genre-link:hover { - background: rgba(255, 255, 255, 0.2); - border-color: rgba(255, 255, 255, 0.35); +/* Full-screen: director/writer use material icon instead of label text */ +/* Director/writer: always use icon, never show "Directed by"/"Written by" text */ +.spotlight-truncated-list[data-icon] .spotlight-truncated-list-label { + display: none; +} +.spotlight-truncated-list .detailsGroupItem[data-icon], +.spotlight-truncated-list .emby-tab-button[data-icon] { + display: inline-flex; + align-items: center; +} +.spotlight-truncated-list .detailsGroupItem[data-icon]::before, +.spotlight-truncated-list .emby-tab-button[data-icon]::before { + content: attr(data-icon); + font-family: 'Material Icons'; + font-size: 1.1em; + margin-right: 6px; + opacity: 0.9; + text-transform: none; /* Prevent parent text-transform: capitalize (e.g. .spotlight-series-info) from breaking icon */ +} +.spotlight-truncated-list[data-icon] .detailsGroupItem[data-icon]::before, +.spotlight-truncated-list[data-icon] .emby-tab-button[data-icon]::before { + content: attr(data-icon); } -/* Director/writer/series info: pill-style container */ -.spotlight-section[data-full-screen="true"] .spotlight-truncated-list:has(>*) { - font-size: 15px; - padding: 6px 14px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.18); - display: inline-block; - max-width: fit-content; +/* Director/writer/series info: no extra padding - detailsGroupItem matches other metadata pills */ +.spotlight-section[data-size="full"] .spotlight-truncated-list.itemDetailsGroup:has(>.detailsGroupItem), +.spotlight-section[data-size="full"] .spotlight-truncated-list.sectionTabs:has(>.emby-tab-button) { + font-size: large; + display: flex; + align-items: center; + align-self: start; } .spotlight-section a:not(:hover) { text-decoration: none !important; } -.spotlight-section[data-full-screen="true"] span.spotlight-end-time::after { - content: 'schedule'; - position: absolute; - left: 10px; - font-family: 'Material Icons'; - top: calc(50% + 1px); - transform: translateY(-50%); +.spotlight-section[data-size="full"] .spotlight-buttons-container { + margin-top: 0px !important; + margin-bottom: 0px !important; + display: flex; + flex-direction: row; + justify-content: start; } -.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 { +.spotlight-section[data-size="full"] .spotlight-item-logo { width: 95%; } -.layout-mobile .spotlight-section[data-full-screen="true"] .spotlight-section-title { +/* Mobile spotlight: uniform layout TITLE -> gap -> LOGO -> METADATA -> OVERVIEW -> BUTTONS -> DOTS */ +.layout-mobile .spotlight-tagline { + display: none !important; +} + +.layout-mobile .spotlight-overlay { + width: 100% !important; + background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.85) 100%) !important; + padding: 4rem 1rem 4rem 1rem !important; + align-items: start; + align-content: start; + grid-template-rows: auto auto 1fr minmax(48px, auto); + grid-template-areas: "logo" "metadata" "overview" "buttons"; + gap: 0.75em; +} + +/* Gap after TITLE: extra top padding when section has a title */ +.layout-mobile .spotlight-overlay.has-title { + padding-top: 6rem !important; +} + +.layout-mobile .spotlight-section .spotlight-section-title { + top: 1rem; + left: 1rem; +} + +.layout-mobile .metadata-container { + display: flex !important; +} + +.layout-mobile .spotlight-logo-section { + height: auto !important; + min-height: 0 !important; +} + +.layout-mobile .spotlight-overview { + max-width: 100%; +} + +.spotlight-series-info { + text-transform: capitalize; +} + +.spotlight-section[data-size="full"] .metadata-container * { + font-size: 1.1rem; +} + +/* Native Jellyfin itemDetailsGroup structure for all spotlight metadata */ +.spotlight-section .itemDetailsGroup:has(.detailsGroupItem:not(.hide)), +.spotlight-section .sectionTabs:has(.emby-tab-button:not(.hide)) { + margin: 0px !important; + padding: 0px !important; + width: initial !important; + min-width: initial !important; +} +.spotlight-section .itemDetailsGroup:not(.hide) .detailsGroupItem, +.spotlight-section .itemDetailsGroup:not(.hide) .emby-tab-button { + margin: 0px !important; + gap: 0px !important; +} + +.spotlight-section .spotlight-buttons-container.mainDetailButtons .btnPlay { + margin-right: 0px !important; + right: 0px !important; +} + +.spotlight-section .spotlight-buttons-container.mainDetailButtons { + margin-left: 0px !important; +} + +.spotlight-section:not(.hide) .headerTabs, +.spotlight-section:not(.hide) .sectionTabs { + display: flex !important; + height: initial !important; + padding: 0px !important; + margin-top: 0px !important; + margin-bottom: 0px !important; + margin-left: 0px !important; + margin-right: 0px !important; +} + +.spotlight-section:not(.hide) .emby-tab-button { + height: initial !important; + padding: 0.5rem 0.5rem !important; + margin: 0px !important; + top: initial !important; + transform: initial !important; +} + +.spotlight-section-title.headerTabs.sectionTabs, +.spotlight-nav-container.headerTabs.sectionTabs { + position: absolute !important; + display: flex !important; + flex-direction: row !important; +} + +.spotlight-section[data-layout="Border"] .spotlight-section-title { + top: 0; +} + +.spotlight-nav-buttons-container { + display: flex !important; + gap: 8px !important; + font-size: 1.1rem !important; +} + +.spotlight-section:not(.hide) span.emby-tab-button.emby-tab-button-active:not(:has(a)) { + cursor: default; +} +.metadata-container *, .spotlight-tagline p { + color: rgba(255, 255, 255, 0.9) !important; +} +.spotlight-nav-button span.material-icons { + font-size: 24px !important; + display: inline-block !important; + top: initial !important; +} +.spotlight-section-title > * { + font-size: 18px !important; +} + +.spotlight-metadata-row *:not(a) { + cursor: default; +} + +.spotlight-section[data-layout="Borderless"][data-size="normal"] { + margin-top: calc(-1 * calc(var(--spotlight-fullscreen-vertical) / 4)); + margin-bottom: calc(-1 * calc(var(--spotlight-fullscreen-vertical) / 4)); +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-banner-container { + height: 33rem; +} + +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overview-section { + display: none !important; +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overlay { +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-buttons-section { + margin-top: -1.5em; +} +.spotlight-section[data-layout="Borderless"][data-size="large"] .spotlight-banner-container { + height: 52rem; +} + +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overlay.has-title { + padding: calc(var(--spotlight-fullscreen-vertical) * 2.25) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) * 1.25) var(--spotlight-fullscreen-horizontal); +} + +.spotlight-section[data-layout="Borderless"][data-size="normal"] { + --spotlight-fullscreen-vertical: 3em; + --spotlight-fullscreen-horizontal: max(env(safe-area-inset-left), env(safe-area-inset-right), 4%); +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-section-title { left: var(--spotlight-fullscreen-horizontal); - top: var(--spotlight-fullscreen-vertical); + top: calc(var(--spotlight-fullscreen-vertical) * 1); +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-tagline { + bottom: calc(var(--spotlight-fullscreen-vertical) * 1.5 + 40px); +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-dots { + bottom: calc(var(--spotlight-fullscreen-vertical) * 1.5); +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-nav-container { + right: var(--spotlight-fullscreen-horizontal); + top: calc(var(--spotlight-fullscreen-vertical) * 1); +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overlay { + grid-template-rows: var(--spotlight-logo-height) auto auto minmax(48px, auto); +} +.spotlight-section[data-size="normal"] .spotlight-overlay { + grid-template-rows: var(--spotlight-logo-height) auto auto 1fr; +} +.spotlight-section[data-size="large"] .spotlight-overlay { + grid-template-rows: var(--spotlight-logo-height) auto auto 1fr; +} +.spotlight-section[data-layout="Border"] .spotlight-overlay { + padding: 65px 40px 15px 40px; +} +.spotlight-section[data-layout="Border"] .spotlight-section-title { + top: 15px !important; + left: 40px !important; +} +.spotlight-section[data-layout="Border"] .spotlight-nav-container { + top: 15px !important; + right: 40px !important; +} +.spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-section-title a { + font-size: 1rem !important; +} +.spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-buttons-section { + align-self: end; +} +.spotlight-section[data-layout="Border"] .spotlight-banner-container { + border: 1px solid rgb(255 255 255 / 10%); +} +.spotlight-section[data-layout="Border"][data-size="large"] .spotlight-banner-container { + height: 40rem; +} + +.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-banner-container { + height: 70vh !important; +} + +.spotlight-section[data-layout="Borderless"][data-size="large"] .spotlight-overview { + -webkit-line-clamp: 3; +} + +.spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-overview { + -webkit-line-clamp: 2; } \ No newline at end of file diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index 2027711..bb31081 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -12,6 +12,22 @@ // Global toggle for progressive section fade-in. // Developers can set this to false to disable the behavior. const FADE_IN_SECTIONS = true; + + // Sliding window: render current slide + 4 prev + 4 next (9 total). As user navigates, remove/add slides. + const SPOTLIGHT_WINDOW_PREV = 4; + const SPOTLIGHT_WINDOW_NEXT = 4; + + function getSpotlightWindowIndices(currentIndex, totalLength) { + if (totalLength <= 9) { + return Array.from({ length: totalLength }, (_, i) => i); + } + const indices = new Set(); + for (let i = -SPOTLIGHT_WINDOW_PREV; i <= SPOTLIGHT_WINDOW_NEXT; i++) { + const idx = (currentIndex + i + totalLength) % totalLength; + indices.add(idx); + } + return Array.from(indices); + } /** * Helper function to shuffle array (Fisher-Yates) @@ -290,6 +306,10 @@ }); } + if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'Random') { + processed = shuffle(processed); + } + const limit = sectionConfig.itemLimit || sectionConfig.queries?.[0]?.queryOptions?.Limit || 0; // Apply local limits for non-API sources @@ -489,7 +509,7 @@ // Replace the section element sectionElement.replaceWith(content); - checkSectionOverflow(content, true); + //checkSectionOverflow(content, true); setTimeout(() => checkSectionOverflow(content, true), 150); // Add refresh button to new content @@ -661,7 +681,7 @@ // This preserves the container element itself and its layout properties (like order) skeletonContaineElement.replaceWith(realContainer); checkSectionOverflow(realContainer, true); - setTimeout(() => checkSectionOverflow(realContainer, true), 150); + //setTimeout(() => checkSectionOverflow(realContainer, true), 150); }).catch(error => { console.error('[KefinTweaks CardBuilder] Error loading items for progressive enhancement:', error); // Optionally remove skeleton on error or show error state @@ -803,7 +823,7 @@ // Batch append sections to avoid Jellyfin emby-scroller processing // all at once (causes render lag when many sections are added) - const BATCH_SIZE = 4; + /* const BATCH_SIZE = 4; const sectionNodes = Array.from(fragment.childNodes); for (let i = 0; i < sectionNodes.length; i += BATCH_SIZE) { const batch = sectionNodes.slice(i, i + BATCH_SIZE); @@ -811,6 +831,12 @@ if (i + BATCH_SIZE < sectionNodes.length) { await new Promise(resolve => requestAnimationFrame(resolve)); } + } */ + + for (const sectionElement of sectionElements) { + container.appendChild(sectionElement); + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => setTimeout(resolve, 0)); } /* for (const sectionElement of sectionElements) { @@ -974,7 +1000,7 @@ sectionElement.replaceWith(content); checkSectionOverflow(content, true); - setTimeout(() => checkSectionOverflow(content, true), 150); + //setTimeout(() => checkSectionOverflow(content, true), 150); // If revealSectionsSequentially is enabled and section hasn't been revealed yet, re-observe it if (revealSectionsSequentially && content.classList.contains('cardbuilder-section-reveal') && !content.classList.contains('in-viewport')) { @@ -1067,8 +1093,8 @@ content.setAttribute(attr.name, attr.value); }); skeleton.replaceWith(content); - checkSectionOverflow(content, true); - setTimeout(() => checkSectionOverflow(content, true), 150); + /* checkSectionOverflow(content, true); + setTimeout(() => checkSectionOverflow(content, true), 150); */ } else { skeleton.remove(); } @@ -1206,6 +1232,186 @@ }; + /** + * Blurhash base83 character set (must match official blurhash package: includes comma and period) + * https://github.com/woltapp/blurhash/blob/master/TypeScript/src/base83.ts + */ + const BLURHASH_B83 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'; + + function blurhashDecode83(str, from, length) { + if (from + length > str.length) return NaN; + let value = 0; + for (let i = 0; i < length; i++) { + const idx = BLURHASH_B83.indexOf(str[from + i]); + if (idx < 0) return NaN; + value = value * 83 + idx; + } + return value; + } + + function blurhashSignPow(val, exp) { + const sign = val < 0 ? -1 : 1; + return sign * Math.pow(Math.abs(val), exp); + } + + function blurhashLinearToSrgb(value) { + const v = Math.max(0, Math.min(1, value)); + return v <= 0.0031308 ? Math.trunc(v * 12.92 * 255 + 0.5) : Math.trunc((1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5); + } + + function blurhashSrgbToLinear(value) { + const v = value / 255; + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + } + + /** + * Decode a blurhash string to RGBA pixel data (Uint8ClampedArray). + * Matches the official blurhash package (same as Jellyfin's worker uses). + * Layout: 1 char size, 1 char quantised max, 4 chars DC (24-bit), then 2 chars per AC (base-19 packed). + */ + function blurhashDecode(blurhash, width, height) { + if (!blurhash || blurhash.length < 6) return null; + try { + const sizeVal = blurhashDecode83(blurhash, 0, 1); + if (isNaN(sizeVal)) return null; + const numX = (sizeVal % 9) + 1; + const numY = Math.floor(sizeVal / 9) + 1; + const expectedLen = 4 + 2 * numX * numY; + if (blurhash.length !== expectedLen) return null; + + const quantisedMax = blurhashDecode83(blurhash, 1, 1); + if (isNaN(quantisedMax)) return null; + const maxVal = (quantisedMax + 1) / 166; + + const components = []; + for (let i = 0; i < numX * numY; i++) { + if (i === 0) { + const dc = blurhashDecode83(blurhash, 2, 4); + if (isNaN(dc)) return null; + const intR = dc >> 16; + const intG = (dc >> 8) & 255; + const intB = dc & 255; + components.push([ + blurhashSrgbToLinear(intR), + blurhashSrgbToLinear(intG), + blurhashSrgbToLinear(intB) + ]); + } else { + const acVal = blurhashDecode83(blurhash, 4 + i * 2, 2); + if (isNaN(acVal)) return null; + const quantR = Math.floor(acVal / (19 * 19)); + const quantG = Math.floor(acVal / 19) % 19; + const quantB = acVal % 19; + components.push([ + blurhashSignPow((quantR - 9) / 9, 2) * maxVal, + blurhashSignPow((quantG - 9) / 9, 2) * maxVal, + blurhashSignPow((quantB - 9) / 9, 2) * maxVal + ]); + } + } + + const pixels = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let r = 0, g = 0, b = 0; + for (let j = 0; j < numY; j++) { + const basisY = Math.cos((Math.PI * y * j) / height); + for (let i = 0; i < numX; i++) { + const basis = Math.cos((Math.PI * x * i) / width) * basisY; + const color = components[i + j * numX]; + r += color[0] * basis; + g += color[1] * basis; + b += color[2] * basis; + } + } + const off = (y * width + x) * 4; + pixels[off] = blurhashLinearToSrgb(r); + pixels[off + 1] = blurhashLinearToSrgb(g); + pixels[off + 2] = blurhashLinearToSrgb(b); + pixels[off + 3] = 255; + } + } + return pixels; + } catch (e) { + return null; + } + } + + /** + * Draw decoded blurhash into an existing canvas (e.g. 20x20). + * @param {HTMLCanvasElement} canvas - Canvas to draw into + * @param {string} blurhashString - Blurhash string + * @returns {boolean} - True if drawn successfully + */ + function drawBlurhashToCanvas(canvas, blurhashString) { + if (!canvas || !blurhashString) return false; + const w = canvas.width || 20; + const h = canvas.height || 20; + const pixels = blurhashDecode(blurhashString, w, h); + if (!pixels) return false; + const ctx = canvas.getContext('2d'); + if (!ctx) return false; + const imageData = ctx.createImageData(w, h); + imageData.data.set(pixels); + ctx.putImageData(imageData, 0, 0); + return true; + } + + /** + * Get blurhash string for the card image when the image is from the current item. + * Mirrors the image URL logic; returns undefined when image comes from parent/series (we don't have their ImageBlurHashes). + * @param {Object} item - Jellyfin item + * @param {string|null} cardFormat - 'backdrop'|'square'|'portrait'|'poster'|'thumb'|null + * @param {string|null} forcedImageType - e.g. 'Primary' + * @returns {string|undefined} - Blurhash string or undefined + */ + function getBlurhashForCard(item, cardFormat, forcedImageType) { + const hashes = item.ImageBlurHashes; + if (!hashes || typeof hashes !== 'object') return undefined; + cardFormat = cardFormat?.toLowerCase() || null; + forcedImageType = forcedImageType?.toLowerCase() || null; + + if (cardFormat === 'backdrop') { + if (item.BackdropImageTags && item.BackdropImageTags[0]) + return hashes.Backdrop?.[item.BackdropImageTags[0]]; + if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb]; + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + return undefined; + } + if (cardFormat === 'square') { + if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') { + if (item.BackdropImageTags && item.BackdropImageTags[0]) + return hashes.Backdrop?.[item.BackdropImageTags[0]]; + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + return undefined; + } + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + if (item.BackdropImageTags && item.BackdropImageTags[0]) + return hashes.Backdrop?.[item.BackdropImageTags[0]]; + if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb]; + return undefined; + } + if (cardFormat === 'portrait' || cardFormat === 'poster') { + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + if (item.PrimaryImageTag) return hashes.Primary?.[item.PrimaryImageTag]; + if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb]; + return undefined; + } + if (cardFormat === 'thumb') { + if (forcedImageType && state.useEpisodeImages && item.Type === 'Episode' && item.ImageTags?.Primary) + return hashes.Primary?.[item.ImageTags.Primary]; + if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb]; + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + return undefined; + } + if (item.IsJellyseerr) return undefined; + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb]; + if (item.BackdropImageTags && item.BackdropImageTags.length > 0) + return hashes.Backdrop?.[item.BackdropImageTags[0]]; + return undefined; + } + /** * Creates a Jellyfin card element from an item * @param {Object} item - The Jellyfin item object @@ -1313,12 +1519,13 @@ cardPadder.appendChild(cardIcon); - // Blurhash canvas (placeholder) + // Blurhash canvas (placeholder) – will be filled and shown when we have a blurhash const blurhashCanvas = document.createElement('canvas'); blurhashCanvas.setAttribute('aria-hidden', 'true'); blurhashCanvas.width = 20; blurhashCanvas.height = 20; - blurhashCanvas.className = 'blurhash-canvas lazy-hidden'; + // lazy-hidden only when we have no blurhash (grey card until image loads) + blurhashCanvas.className = 'blurhash-canvas'; // Card image container const cardImageContainer = document.createElement('a'); @@ -1428,10 +1635,24 @@ cardImageContainer.appendChild(iconSpan); } - + + const blurhashStr = getBlurhashForCard(item, cardFormat, forcedImageType); + if (!blurhashStr) { + blurhashCanvas.classList.add('lazy-hidden'); + } else { + // Draw blurhash immediately so placeholder is visible ASAP (canvas is behind cardImageContainer) + if (!drawBlurhashToCanvas(blurhashCanvas, blurhashStr)) { + blurhashCanvas.classList.add('lazy-hidden'); + } else { + // Hide the image container so the blurhash canvas shows until the real image loads + cardImageContainer.classList.add('lazy-hidden'); + } + } + if (imageUrl) { // Use data-src for lazy loading instead of immediate backgroundImage cardImageContainer.setAttribute('data-src', imageUrl); + if (blurhashStr) cardImageContainer.setAttribute('data-blurhash', blurhashStr); // Add lazy class for styling/selection cardImageContainer.classList.add('lazy'); } @@ -1625,12 +1846,12 @@ secondaryText.appendChild(yearBdi); } - // Assemble card + // Assemble card (blurhash already drawn above when blurhashStr was set) cardScalable.appendChild(cardPadder); cardScalable.appendChild(blurhashCanvas); cardScalable.appendChild(cardImageContainer); cardScalable.appendChild(cardOverlayContainer); - + cardBox.appendChild(cardScalable); cardBox.appendChild(cardTextContainer); @@ -1697,7 +1918,7 @@ if (!items || items.length === 0) { return document.createElement('div'); } - + const { autoPlay = true, interval = 10000, @@ -1707,10 +1928,13 @@ showClearArt = false, panAnimation = true, fullScreen = false, + spotlightSize, dualBackdrops = true, viewMoreUrl = null, pauseOnHover = !fullScreen // Default: don't pause on hover in fullscreen (continue cycling) } = options; + const layout = fullScreen ? 'Borderless' : 'Border'; + const size = spotlightSize ?? (fullScreen ? 'full' : 'normal'); const serverId = ApiClient.serverId(); const serverAddress = ApiClient.serverAddress(); @@ -1735,7 +1959,7 @@ if (viewMoreUrl) { // Create clickable title const titleLink = document.createElement('a'); - titleLink.className = 'spotlight-section-title spotlight-title-link'; + titleLink.className = 'emby-tab-button emby-tab-button-active emby-button-foreground'; titleLink.textContent = title; titleLink.title = 'See All'; titleLink.style.textDecoration = 'none'; @@ -1758,11 +1982,15 @@ } else { // Regular non-clickable title sectionTitleEl = document.createElement('div'); - sectionTitleEl.className = 'spotlight-section-title'; + sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground'; sectionTitleEl.textContent = title; } + + const sectionTitleContainer = document.createElement('div'); + sectionTitleContainer.className = `spotlight-section-title ${viewMoreUrl ? '' : 'spotlight-title-link'} headerTabs sectionTabs`; + sectionTitleContainer.appendChild(sectionTitleEl); - bannerContainer.appendChild(sectionTitleEl); + bannerContainer.appendChild(sectionTitleContainer); } // Create items container (for fade transitions) @@ -1771,21 +1999,18 @@ const isMobileLayout = document.documentElement.classList.contains('mobile-layout'); - // Create items - items.forEach((item, index) => { - // Get item type early for use throughout + // Sliding window: render current + 4 prev + 4 next (9 slides). Update DOM as user navigates. + let currentWindowIndices = getSpotlightWindowIndices(0, items.length); + + function createAndAppendSlide(item, index, eagerLoadImages) { const itemType = item.Type || 'Movie'; const itemDiv = document.createElement('div'); itemDiv.className = 'spotlight-item'; itemDiv.setAttribute('data-id', item.Id); - itemDiv.setAttribute('data-index', index); + itemDiv.setAttribute('data-index', String(index)); itemDiv.setAttribute('data-item-type', itemType); - if (index === 0) { - itemDiv.style.opacity = '1'; - } else { - itemDiv.style.opacity = '0'; - } + itemDiv.style.opacity = eagerLoadImages ? '1' : '0'; let imageUrl = ''; @@ -1839,7 +2064,7 @@ leftImg.setAttribute('data-src', firstUrl); leftImg.alt = ''; leftImg.draggable = false; - if (index === 0) { + if (eagerLoadImages) { leftImg.src = firstUrl; leftImg.loading = 'eager'; } @@ -1851,7 +2076,7 @@ rightImg.setAttribute('data-src', secondUrl); rightImg.alt = ''; rightImg.draggable = false; - if (index === 0) { + if (eagerLoadImages) { rightImg.src = secondUrl; rightImg.loading = 'eager'; } @@ -1877,7 +2102,7 @@ singleImg.setAttribute('data-src', imageUrl); singleImg.alt = ''; singleImg.draggable = false; - if (index === 0) { + if (eagerLoadImages) { singleImg.src = imageUrl; singleImg.loading = 'eager'; } @@ -1897,11 +2122,12 @@ if (hasLogo) { // Use logo image instead of text title - const logoUrl = `${serverAddress}/Items/${item.Id}/Images/Logo?fillHeight=${fullScreen ? '300' : '200'}&quality=96&tag=${item.ImageTags.Logo}`; + const logoHeight = size === 'full' ? '300' : (size === 'large' ? '250' : '200'); + const logoUrl = `${serverAddress}/Items/${item.Id}/Images/Logo?fillHeight=${logoHeight}&quality=96&tag=${item.ImageTags.Logo}`; titleEl = document.createElement('div'); titleEl.className = 'spotlight-item-logo'; titleEl.setAttribute('data-background-url', logoUrl); - if (index === 0) titleEl.style.backgroundImage = `url("${logoUrl}")`; + if (eagerLoadImages) titleEl.style.backgroundImage = `url("${logoUrl}")`; titleEl.alt = item.Name || 'Unknown'; } else { // Use text title as fallback @@ -1910,6 +2136,23 @@ titleEl.textContent = item.Name || 'Unknown'; } + // Helper: wrap content in native Jellyfin itemDetailsGroup > detailsGroupItem structure + // icon: optional Material Icons name (e.g. 'schedule') - same pattern as genres/director/writer + function createMetadataItem(content, tag = 'span', extraClasses = '', icon = null) { + const group = document.createElement('div'); + group.className = 'headerTabs sectionTabs'; + const item = document.createElement(tag); + item.className = 'emby-tab-button' + (extraClasses ? ' ' + extraClasses : ''); + if (icon) item.setAttribute('data-icon', icon); + if (typeof content === 'string') { + item.textContent = content; + } else { + item.appendChild(content); + } + group.appendChild(item); + return group; + } + // Combined: Year + Time + Ends At + Genres (all on one line) const metadataRow = document.createElement('div'); metadataRow.className = 'spotlight-metadata-row'; @@ -1917,13 +2160,12 @@ // Year const year = item.ProductionYear || (item.PremiereDate ? new Date(item.PremiereDate).getFullYear() : null); if (year) { - const yearEl = document.createElement('span'); - yearEl.textContent = year; - metadataRow.appendChild(yearEl); + metadataRow.appendChild(createMetadataItem(String(year))); } - // Runtime - if (item.RunTimeTicks) { + // Runtime and estimated end time (Movies only - not for Series/Season/Episode) + const isSeriesType = itemType === 'Series' || itemType === 'Season' || itemType === 'Episode'; + if (!isSeriesType && item.RunTimeTicks) { const runtimeMinutes = Math.round(item.RunTimeTicks / 10000000 / 60); const hours = Math.floor(runtimeMinutes / 60); const minutes = runtimeMinutes % 60; @@ -1934,62 +2176,47 @@ runtimeText = `${minutes}m`; } - const runtimeEl = document.createElement('span'); - runtimeEl.textContent = runtimeText; - metadataRow.appendChild(runtimeEl); + metadataRow.appendChild(createMetadataItem(runtimeText)); // End time (when it would end if started now) const now = new Date(); - // RunTimeTicks is in 100-nanosecond intervals, convert to milliseconds const runtimeMs = item.RunTimeTicks / 10000; const endTime = new Date(now.getTime() + runtimeMs); - const endTimeEl = document.createElement('span'); - endTimeEl.className = 'spotlight-end-time'; - endTimeEl.textContent = `${endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; - - metadataRow.appendChild(endTimeEl); + const endTimeItem = createMetadataItem(endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), 'span', 'spotlight-end-time', 'schedule'); + metadataRow.appendChild(endTimeItem); } - // Genres (on same line) - make clickable if IDs are available - if (item.Genres && item.Genres.length > 0) { - const genreFragment = document.createDocumentFragment(); - - if (item.GenreItems && item.GenreItems.length > 0) { - item.GenreItems.forEach((genreItem, index) => { - const genreName = genreItem.Name; - const genreId = genreItem.Id; - - if (genreName) { - const genreLink = document.createElement('a'); - genreLink.className = 'spotlight-genre-link'; - genreLink.href = `${serverAddress}/web/#/list.html?genreId=${genreId}&serverId=${serverId}`; - genreLink.textContent = genreName; - genreLink.addEventListener('click', (e) => { - e.stopPropagation(); - }); - - genreFragment.appendChild(genreLink); - } - }); + // Genres - merged into single container, max 3 visible, tooltip shows full list on hover + if (item.Genres && item.Genres.length > 0 && item.GenreItems && item.GenreItems.length > 0) { + const genreContainer = createTruncatedList('', item.GenreItems, 3, false, 'theater_comedy', 'genre'); + if (genreContainer) { + metadataRow.appendChild(genreContainer); } - - metadataRow.appendChild(genreFragment); } - // Helper function to create truncated list that expands on ellipsis hover + // Helper function to create truncated list with tooltip for overflow items // items can be array of strings (names) or array of objects with Name and Id - function createTruncatedList(label, items, maxItems = 3, isPeople = false) { + // icon: optional Material Icons name for full-screen display (e.g. 'movie', 'edit') + // linkType: 'person' | 'genre' | 'studio' | null - when set, create links + function createTruncatedList(label, items, maxItems = 3, isPeople = false, icon = null, linkType = null) { const container = document.createElement('div'); - container.className = 'spotlight-truncated-list'; + container.className = 'spotlight-truncated-list headerTabs sectionTabs'; + container.setAttribute('data-label', label); + if (icon) container.setAttribute('data-icon', icon); if (!items || items.length === 0) { return container; } const displayContainer = document.createElement('span'); - const labelSpan = document.createElement('span'); - labelSpan.textContent = `${label} `; - displayContainer.appendChild(labelSpan); + displayContainer.className = 'emby-tab-button'; + if (icon) displayContainer.setAttribute('data-icon', icon); + if (label) { + const labelSpan = document.createElement('span'); + labelSpan.className = 'spotlight-truncated-list-label'; + labelSpan.textContent = `${label} `; + displayContainer.appendChild(labelSpan); + } // Extract names and IDs from items (handle both strings and objects) const itemsData = items.map(item => { @@ -2008,15 +2235,17 @@ const hasMore = itemsData.length > maxItems; - // Store references to hidden elements for easy show/hide - const hiddenElements = []; + // Resolve link type: explicit linkType, or fall back to isPeople for person links + const usePersonLinks = linkType === 'person' || (linkType !== 'genre' && linkType !== 'studio' && isPeople); + const useGenreLinks = linkType === 'genre'; + const useStudioLinks = linkType === 'studio'; - // Create spans for all names (but initially hide the ones beyond maxItems) + // Create spans for visible names only (no layout-shifting expand) itemsData.forEach((itemData, index) => { + if (index >= maxItems) return; let nameElement; - // Create link if it's a person with an ID, otherwise just text - if (isPeople && itemData.id) { + if (usePersonLinks && itemData.id) { nameElement = document.createElement('a'); nameElement.className = 'spotlight-person-link'; nameElement.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`; @@ -2024,6 +2253,18 @@ nameElement.addEventListener('click', (e) => { e.stopPropagation(); }); + } else if (useGenreLinks && itemData.id) { + nameElement = document.createElement('a'); + nameElement.className = 'spotlight-genre-link'; + nameElement.href = `${serverAddress}/web/#/list.html?genreId=${itemData.id}&serverId=${serverId}`; + nameElement.textContent = itemData.name; + nameElement.addEventListener('click', (e) => e.stopPropagation()); + } else if (useStudioLinks && itemData.id) { + nameElement = document.createElement('a'); + nameElement.className = 'spotlight-studio-link'; + nameElement.href = `${serverAddress}/web/#/list.html?studioId=${itemData.id}&serverId=${serverId}`; + nameElement.textContent = itemData.name; + nameElement.addEventListener('click', (e) => e.stopPropagation()); } else { nameElement = document.createElement('span'); nameElement.textContent = itemData.name; @@ -2031,61 +2272,107 @@ nameElement.setAttribute('data-name-index', index); - // Hide names beyond maxItems initially - if (index >= maxItems) { - nameElement.style.display = 'none'; - hiddenElements.push(nameElement); - } - displayContainer.appendChild(nameElement); - // Add comma separator (except for last item) - if (index < itemsData.length - 1) { - const comma = document.createElement('span'); - comma.textContent = ', '; - comma.setAttribute('data-comma-after', index); - - // Hide comma if it's after the last visible item - if (index >= maxItems - 1) { - comma.style.cssText = 'display: none;'; - hiddenElements.push(comma); - } - displayContainer.appendChild(comma); + // Bullet separator (except for last visible item - use actual count when fewer than maxItems) + const lastVisibleIndex = Math.min(maxItems, itemsData.length) - 1; + if (index < lastVisibleIndex) { + const separator = document.createElement('span'); + separator.className = 'spotlight-list-separator'; + separator.textContent = '•'; + displayContainer.appendChild(separator); } }); - // Add ellipsis as separate hoverable element if there are more items + // Tooltip when more items exist (full list on hover) if (hasMore) { - const ellipsisElement = document.createElement('span'); - ellipsisElement.className = 'spotlight-ellipsis'; - ellipsisElement.textContent = '...'; - displayContainer.appendChild(ellipsisElement); + // Tooltip: full list as vertical stack with clickable links (no layout shift) + const tooltip = document.createElement('div'); + tooltip.className = 'spotlight-truncated-list-tooltip'; + if (label) { + const labelSpan = document.createElement('span'); + labelSpan.className = 'spotlight-truncated-list-tooltip-label'; + labelSpan.textContent = label + ' '; + tooltip.appendChild(labelSpan); + } + const listWrapper = document.createElement('div'); + listWrapper.className = 'spotlight-truncated-list-tooltip-items'; + itemsData.forEach((itemData) => { + const itemRow = document.createElement('div'); + itemRow.className = 'spotlight-truncated-list-tooltip-item'; + let linkEl; + if (usePersonLinks && itemData.id) { + linkEl = document.createElement('a'); + linkEl.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`; + linkEl.className = 'spotlight-person-link'; + linkEl.textContent = itemData.name; + linkEl.addEventListener('click', (e) => e.stopPropagation()); + } else if (useGenreLinks && itemData.id) { + linkEl = document.createElement('a'); + linkEl.href = `${serverAddress}/web/#/list.html?genreId=${itemData.id}&serverId=${serverId}`; + linkEl.className = 'spotlight-genre-link'; + linkEl.textContent = itemData.name; + linkEl.addEventListener('click', (e) => e.stopPropagation()); + } else if (useStudioLinks && itemData.id) { + linkEl = document.createElement('a'); + linkEl.href = `${serverAddress}/web/#/list.html?studioId=${itemData.id}&serverId=${serverId}`; + linkEl.className = 'spotlight-studio-link'; + linkEl.textContent = itemData.name; + linkEl.addEventListener('click', (e) => e.stopPropagation()); + } else { + linkEl = document.createElement('span'); + linkEl.textContent = itemData.name; + } + itemRow.appendChild(linkEl); + listWrapper.appendChild(itemRow); + }); + tooltip.appendChild(listWrapper); + container.appendChild(tooltip); + container.style.position = 'relative'; - let isExpanded = false; + const TOOLTIP_DELAY_MS = 600; + const HIDE_DELAY_MS = 150; + let tooltipTimer = null; + let hideTimer = null; - // On ellipsis hover, reveal all hidden names and hide ellipsis - ellipsisElement.addEventListener('mouseenter', () => { - if (!isExpanded) { - // Show all hidden elements - hiddenElements.forEach(el => { - el.style.display = ''; - }); - ellipsisElement.style.display = 'none'; - isExpanded = true; + const showTooltip = () => { + tooltip.classList.add('visible'); + tooltipTimer = null; + }; + const hideTooltip = () => { + tooltip.classList.remove('visible'); + if (tooltipTimer) { + clearTimeout(tooltipTimer); + tooltipTimer = null; + } + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + }; + const scheduleHide = () => { + if (hideTimer) clearTimeout(hideTimer); + hideTimer = setTimeout(hideTooltip, HIDE_DELAY_MS); + }; + + container.addEventListener('mouseenter', () => { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + tooltipTimer = setTimeout(showTooltip, TOOLTIP_DELAY_MS); + }); + container.addEventListener('mouseleave', () => { + scheduleHide(); + }); + tooltip.addEventListener('mouseenter', () => { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; } }); - - // Keep expanded state when mouse moves within the container - // Only collapse when mouse leaves the entire container - container.addEventListener('mouseleave', () => { - if (isExpanded) { - // Hide names beyond maxItems again - hiddenElements.forEach(el => { - el.style.display = 'none'; - }); - ellipsisElement.style.display = ''; - isExpanded = false; - } + tooltip.addEventListener('mouseleave', () => { + hideTooltip(); }); } @@ -2093,39 +2380,39 @@ return container; } + // Studios - show 1 visible, rest in tooltip with links (like directors/writers) + const studiosData = Array.isArray(item.Studios) && item.Studios.length > 0 + ? item.Studios + : (typeof item.Studio === 'string' && item.Studio) + ? item.Studio.split('|').map(s => ({ Name: s.trim(), Id: null })).filter(x => x.Name) + : []; + const studioContainer = studiosData.length > 0 + ? createTruncatedList('Produced by', studiosData, 1, false, 'apartment', 'studio') + : null; + // For Series: show seasons/episodes. For Movies: show director/writer let directorContainer = null; let writerContainer = null; let combinedContainer = null; - let seriesInfoContainer = null; - // Create series info container - seriesInfoContainer = document.createElement('div'); - seriesInfoContainer.className = 'spotlight-truncated-list'; + // Create series info container (seasons/episodes count) + const seriesInfoContainer = document.createElement('div'); + seriesInfoContainer.className = 'spotlight-metadata-row spotlight-series-info'; if (itemType === 'Series') { + // Studio first (before seasons/episodes) + if (studioContainer) seriesInfoContainer.appendChild(studioContainer); // For Series, show seasons and episodes count const seasonsCount = item.ChildCount || 0; - - // Initial text with seasons count - const seasonsText = `${seasonsCount} ${seasonsCount === 1 ? 'season' : 'seasons'}`; - seriesInfoContainer.textContent = seasonsText; - - // Fetch episode count asynchronously and update - (async () => { - try { - const episodesCount = item.RecursiveItemCount || 0; - - // Update the series info container with episode count - if (seriesInfoContainer) { - const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`; - seriesInfoContainer.textContent = `${seasonsText} - ${episodesText}`; - } - } catch (err) { - console.error('Failed to fetch episode count:', err); - // Keep just the seasons text if episode fetch fails - } - })(); + if (seasonsCount > 1) { + const seasonsText = `${seasonsCount} ${seasonsCount === 1 ? 'season' : 'seasons'}`; + seriesInfoContainer.appendChild(createMetadataItem(seasonsText)); + } + const episodesCount = item.RecursiveItemCount || 0; + if (episodesCount > 0) { + const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`; + seriesInfoContainer.appendChild(createMetadataItem(episodesText)); + } } else if (itemType === 'Movie') { // For Movies, show director/writer (existing logic) if (item.People && Array.isArray(item.People)) { @@ -2143,59 +2430,57 @@ if (areIdentical) { // Combine into single "Directed and Written by" row (with person objects for links) - combinedContainer = createTruncatedList('Directed and Written by', directors, 3, true); + combinedContainer = createTruncatedList('Directed and Written by', directors, 1, true, 'movie'); // Add empty writer container to maintain spacing writerContainer = document.createElement('div'); writerContainer.className = 'spotlight-empty-writer-container'; } else { // Show separately (with person objects for links) - directorContainer = createTruncatedList('Directed by', directors, 3, true); - writerContainer = createTruncatedList('Written by', writers, 3, true); + if (directors.length > 0) { + directorContainer = createTruncatedList('Directed by', directors, 1, true, 'movie'); + } + if (writers.length > 0) { + writerContainer = createTruncatedList('Written by', writers, 1, true, 'edit'); + } } } } else if (itemType === 'Season') { + // Studio first (before episodes) + if (studioContainer) seriesInfoContainer.appendChild(studioContainer); // For Seasons, show episodes count const episodesCount = item.RecursiveItemCount || 0; const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`; - seriesInfoContainer.textContent = episodesText; + seriesInfoContainer.appendChild(createMetadataItem(episodesText)); } else if (itemType === 'Episode') { + // Studio first (before season/episode) + if (studioContainer) seriesInfoContainer.appendChild(studioContainer); // For Episodes, show episode number const seasonNumber = item.ParentIndexNumber || 0; const seasonText = `Season ${seasonNumber}`; const episodeNumber = item.IndexNumber || 0; const episodeText = `Episode ${episodeNumber}`; - seriesInfoContainer.textContent = `${seasonText} - ${episodeText}`; + seriesInfoContainer.appendChild(createMetadataItem(`${seasonText} - ${episodeText}`)); } - // Taglines for Movies, Overview for Series - let taglineEl = null; - taglineEl = document.createElement('p'); - taglineEl.className = 'spotlight-tagline'; - if (itemType === 'Series') { - // For Series, show Overview instead of Tagline - if (item.Overview) { - taglineEl.textContent = item.Overview; - } - } else { - // For Movies, show Tagline (if available) - if (item.Overview && fullScreen) { - taglineEl.textContent = item.Overview; - } else { - if (item.Taglines && Array.isArray(item.Taglines) && item.Taglines.length > 0) { - taglineEl.textContent = item.Taglines[0]; // Show first tagline - } else if (item.Overview) { - taglineEl.textContent = item.Overview; - } - } - } + // Tagline (tagline only) and Overview (separate element) - show/hide via CSS based on spotlight type + const taglineEl = document.createElement('p'); + taglineEl.className = 'emby-tab-button'; + const tagline = (item.Taglines && Array.isArray(item.Taglines) && item.Taglines.length > 0) + ? item.Taglines[0] : ''; + if (tagline) taglineEl.textContent = tagline; + + const overviewEl = document.createElement('p'); + overviewEl.className = 'spotlight-overview'; + if (item.Overview) overviewEl.textContent = item.Overview; // Buttons container const buttonsContainer = document.createElement('div'); - buttonsContainer.className = 'spotlight-buttons-container'; + buttonsContainer.className = 'spotlight-buttons-container mainDetailButtons'; // Play button (material icon span button) const playButton = document.createElement('button'); - playButton.className = 'spotlight-play-button emby-button'; + playButton.className = 'emby-button submit-button raised button-flat btnPlay detailButton'; + playButton.title = 'Play'; const playIcon = document.createElement('span'); playIcon.className = 'material-icons'; playIcon.textContent = 'play_arrow'; @@ -2221,7 +2506,7 @@ // Watchlist button (material icon span button with bookmark icon) const watchlistButton = document.createElement('button'); - watchlistButton.className = 'spotlight-watchlist-button emby-button'; + watchlistButton.className = 'emby-button button-flat'; // Check watchlist status - try to get from cache or API let isInWatchlist = false; @@ -2238,6 +2523,7 @@ const watchlistIcon = document.createElement('span'); watchlistIcon.className = 'material-icons'; watchlistIcon.textContent = 'bookmark'; + watchlistIcon.title = isInWatchlist ? 'Remove from Watchlist' : 'Add to Watchlist'; watchlistButton.appendChild(watchlistIcon); if (isInWatchlist) { watchlistButton.classList.add('watchlisted'); @@ -2272,10 +2558,11 @@ // Info button (shows overview on hover) const infoButton = document.createElement('button'); - infoButton.className = 'spotlight-info-button emby-button'; + infoButton.className = 'emby-button button-flat'; const infoIcon = document.createElement('span'); infoIcon.className = 'material-icons'; infoIcon.textContent = 'info'; + infoIcon.title = 'Go To Item'; infoButton.appendChild(infoIcon); // Overview tooltip (hidden by default, shown on hover) @@ -2295,24 +2582,16 @@ if (rating && typeof rating === 'number') { const ratingContainer = document.createElement('div'); - ratingContainer.className = 'spotlight-rating-container'; + ratingContainer.className = 'starRatingContainer mediaInfoItem spotlight-rating-container'; const starIcon = document.createElement('span'); - starIcon.className = 'material-icons spotlight-rating-star'; - starIcon.textContent = 'star'; - - const ratingValue = document.createElement('span'); - ratingValue.className = 'spotlight-rating-value'; - if (rating && typeof rating === 'number') { - ratingValue.textContent = rating.toFixed(1); - } else { - ratingValue.textContent = 'N/A'; - ratingValue.classList.add('na'); + starIcon.className = 'material-icons starIcon star spotlight-rating-star'; + + if (rating && typeof rating === 'number') { + ratingContainer.innerText = rating.toFixed(1); + ratingContainer.prepend(starIcon); + buttonsContainer.appendChild(ratingContainer); } - - ratingContainer.appendChild(starIcon); - ratingContainer.appendChild(ratingValue); - buttonsContainer.appendChild(ratingContainer); } // Build overlay content in order: Name, Rating, Year+Time+EndsAt+Genres, Directed by, Written by, Taglines, Buttons @@ -2337,35 +2616,54 @@ emptyWriterContainer.className = 'spotlight-empty-writer-container'; if (combinedContainer) { - metadataContainer.appendChild(combinedContainer); - metadataContainer.appendChild(emptyWriterContainer); - } else { - if (directorContainer) { - metadataContainer.appendChild(directorContainer); - } - - if (writerContainer) { - metadataContainer.appendChild(writerContainer); - if (!directorContainer) { - metadataContainer.appendChild(emptyDirectorContainer); - } - } else { - if (!directorContainer) { - metadataContainer.appendChild(emptyDirectorContainer); - } - metadataContainer.appendChild(emptyWriterContainer); - } + const creditsRow = document.createElement('div'); + creditsRow.className = 'spotlight-metadata-row spotlight-credits-row'; + if (studioContainer) creditsRow.appendChild(studioContainer); + creditsRow.appendChild(combinedContainer); + metadataContainer.appendChild(creditsRow); + } else if (directorContainer || writerContainer) { + const creditsRow = document.createElement('div'); + creditsRow.className = 'spotlight-metadata-row spotlight-credits-row'; + if (studioContainer) creditsRow.appendChild(studioContainer); + if (directorContainer) creditsRow.appendChild(directorContainer); + if (writerContainer) creditsRow.appendChild(writerContainer); + metadataContainer.appendChild(creditsRow); + } else if (studioContainer) { + const studioRow = document.createElement('div'); + studioRow.className = 'spotlight-metadata-row spotlight-credits-row'; + studioRow.appendChild(studioContainer); + metadataContainer.appendChild(studioRow); } } - if (metadataContainer.children.length > 0) { - overlay.appendChild(metadataContainer); - } - if (taglineEl) { - overlay.appendChild(taglineEl); - } - overlay.appendChild(buttonsContainer); - + // Fixed 4-section grid: Logo, Metadata, Overview, Buttons (always render all to prevent layout shift) + const logoSection = document.createElement('div'); + logoSection.className = 'spotlight-logo-section'; + logoSection.appendChild(titleEl); + + const metadataSection = document.createElement('div'); + metadataSection.className = 'spotlight-metadata-section'; + metadataSection.appendChild(metadataContainer); + + const overviewSection = document.createElement('div'); + overviewSection.className = 'spotlight-overview-section'; + if (overviewEl.textContent) overviewSection.appendChild(overviewEl); + + const buttonsSection = document.createElement('div'); + buttonsSection.className = 'spotlight-buttons-section'; + buttonsSection.appendChild(buttonsContainer); + + overlay.appendChild(logoSection); + overlay.appendChild(metadataSection); + overlay.appendChild(overviewSection); + overlay.appendChild(buttonsSection); + itemDiv.appendChild(overlay); + if (taglineEl.textContent) { + const taglineContainer = document.createElement('div'); + taglineContainer.className = 'spotlight-tagline headerTabs sectionTabs'; + taglineContainer.appendChild(taglineEl); + itemDiv.appendChild(taglineContainer); + } // ClearArt image (bottom right corner, if enabled and available) if (showClearArt && item.ImageTags?.Art) { @@ -2373,7 +2671,7 @@ const clearArtEl = document.createElement('img'); clearArtEl.className = 'spotlight-clearart'; clearArtEl.setAttribute('data-src', clearArtUrl); - if (index === 0) clearArtEl.src = clearArtUrl; + if (eagerLoadImages) clearArtEl.src = clearArtUrl; clearArtEl.alt = item.Name || 'Unknown'; itemDiv.appendChild(clearArtEl); } @@ -2384,6 +2682,12 @@ }); itemsContainer.appendChild(itemDiv); + return itemDiv; + } + + // Initial render: create slides for window around index 0 + currentWindowIndices.forEach((idx) => { + createAndAppendSlide(items[idx], idx, idx === 0); }); bannerContainer.appendChild(itemsContainer); @@ -2396,13 +2700,13 @@ // Navigation buttons (top right) if (showNavButtons && items.length > 1) { - const navContainer = document.createElement('div'); - navContainer.className = 'spotlight-nav-container'; + const navButtonsContainer = document.createElement('div'); + navButtonsContainer.className = 'spotlight-nav-buttons-container emby-tab-button emby-tab-button-active'; const prevButton = document.createElement('button'); prevButton.className = 'spotlight-nav-button spotlight-nav-prev emby-button'; const prevIcon = document.createElement('span'); - prevIcon.className = 'material-icons'; + prevIcon.className = 'material-icons emby-button-foreground'; prevIcon.textContent = 'chevron_left'; prevButton.appendChild(prevIcon); prevButton.addEventListener('click', (e) => { @@ -2414,7 +2718,7 @@ const nextButton = document.createElement('button'); nextButton.className = 'spotlight-nav-button spotlight-nav-next emby-button'; const nextIcon = document.createElement('span'); - nextIcon.className = 'material-icons'; + nextIcon.className = 'material-icons emby-button-foreground'; nextIcon.textContent = 'chevron_right'; nextButton.appendChild(nextIcon); nextButton.addEventListener('click', (e) => { @@ -2423,18 +2727,23 @@ goToItem((currentIndex + 1) % items.length, true); }); - navContainer.appendChild(prevButton); - navContainer.appendChild(nextButton); + navButtonsContainer.appendChild(prevButton); + navButtonsContainer.appendChild(nextButton); + + const navContainer = document.createElement('div'); + navContainer.className = 'spotlight-nav-container headerTabs sectionTabs'; + navContainer.appendChild(navButtonsContainer); + bannerContainer.appendChild(navContainer); } // Pause button (bottom right) if (autoPlay && items.length > 1) { - const navContainer = bannerContainer.querySelector('.spotlight-nav-container'); + const navButtonsContainer = bannerContainer.querySelector('.spotlight-nav-container .spotlight-nav-buttons-container'); const pauseButton = document.createElement('button'); pauseButton.className = 'spotlight-pause-button spotlight-nav-button emby-button'; const pauseIcon = document.createElement('span'); - pauseIcon.className = 'material-icons'; + pauseIcon.className = 'material-icons emby-button-foreground'; pauseIcon.textContent = 'pause'; pauseButton.appendChild(pauseIcon); pauseButton.addEventListener('click', (e) => { @@ -2448,7 +2757,8 @@ pauseIcon.textContent = 'pause'; } }); - navContainer.insertBefore(pauseButton, navContainer.firstChild); + + navButtonsContainer.insertBefore(pauseButton, navButtonsContainer.firstChild); } // Slide state container (dots or numeric "N / X") @@ -2524,6 +2834,21 @@ // Go to item function (fade transition) function goToItem(index, resetTimer = true) { if (index === currentIndex) return; + + const newWindowIndices = getSpotlightWindowIndices(index, items.length); + const indexToRemove = currentWindowIndices.find(i => !newWindowIndices.includes(i)); + const indexToAdd = newWindowIndices.find(i => !currentWindowIndices.includes(i)); + + if (indexToRemove !== undefined) { + const slideToRemove = bannerContainer.querySelector(`.spotlight-item[data-index="${indexToRemove}"]`); + if (slideToRemove) slideToRemove.remove(); + } + if (indexToAdd !== undefined) { + const newSlide = createAndAppendSlide(items[indexToAdd], indexToAdd, false); + newSlide.style.opacity = '0'; + } + currentWindowIndices = newWindowIndices; + ensureSlideImagesLoaded(index); ensureSlideImagesLoaded((index + 1) % items.length); @@ -2725,9 +3050,8 @@ container.appendChild(bannerContainer); - if (fullScreen) { - container.dataset.fullScreen = 'true'; - } + container.dataset.layout = layout; + container.dataset.size = size; return container; } @@ -3412,27 +3736,45 @@ const observerOptions = { threshold: 0.1, // Trigger when 10% of element is visible - rootMargin: '100px' // Start loading 100px before element enters viewport + rootMargin: '800px' // Start loading well before element enters viewport to avoid grey flash }; lazyImageObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { - if (entry.isIntersecting) { - const cardImageContainer = entry.target; - const imageUrl = cardImageContainer.getAttribute('data-src'); - - if (imageUrl) { - // Load the image - cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; - // Remove lazy class and add loaded class - cardImageContainer.classList.remove('lazy'); - cardImageContainer.classList.add('lazy-loaded'); - // Remove data-src to prevent re-loading - cardImageContainer.removeAttribute('data-src'); - // Unobserve this element - lazyImageObserver.unobserve(cardImageContainer); + if (!entry.isIntersecting) return; + const cardImageContainer = entry.target; + const imageUrl = cardImageContainer.getAttribute('data-src'); + if (!imageUrl) return; + if (cardImageContainer.hasAttribute('data-loading')) return; + + cardImageContainer.setAttribute('data-loading', 'true'); + const img = new Image(); + img.onload = () => { + cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; + cardImageContainer.classList.remove('lazy'); + cardImageContainer.classList.add('lazy-loaded'); + cardImageContainer.removeAttribute('data-src'); + cardImageContainer.removeAttribute('data-loading'); + cardImageContainer.classList.remove('lazy-hidden'); + const canvas = cardImageContainer.previousElementSibling; + if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) { + canvas.classList.add('lazy-hidden'); } - } + lazyImageObserver.unobserve(cardImageContainer); + }; + img.onerror = () => { + cardImageContainer.removeAttribute('data-loading'); + cardImageContainer.removeAttribute('data-src'); + cardImageContainer.classList.remove('lazy'); + cardImageContainer.classList.add('lazy-loaded'); + cardImageContainer.classList.remove('lazy-hidden'); + const canvas = cardImageContainer.previousElementSibling; + if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) { + canvas.classList.add('lazy-hidden'); + } + lazyImageObserver.unobserve(cardImageContainer); + }; + img.src = imageUrl; }); }, observerOptions); }