From 065b16588e69e252ac3444213d78b19d68f6d412 Mon Sep 17 00:00:00 2001 From: MakD Date: Fri, 28 Aug 2026 13:16:35 +0530 Subject: [PATCH 1/6] v6.0.0: slideshow rework, new fonts, updated install snippet --- README.md | 27 +- slideshowpure.css | 1816 +++++++++++++++++++---- slideshowpure.js | 3601 +++++++++++++++++++++++++++++++-------------- 3 files changed, 4089 insertions(+), 1355 deletions(-) diff --git a/README.md b/README.md index b1f4c2b..b4ee151 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,32 @@ Here I present my version with some code improvements, loading optimizations, an index.html - 1. Navigate to your `jellyfin-web` folder and search for the file index.html. (you can use any code editor, just remember to open with administrator privileges. + 1. Navigate to your `jellyfin-web` folder and search for the file index.html. (You can use any code editor — just remember to open it with administrator privileges.) 2. Search for `` 3. Just before the ``, plug the below code + +```html + + + + + ``` - - -``` + + +
+ +Upgrading from v5 + +Replace your two old lines with the block above. Three things are different: + +- **Fonts.** v6 sets its type in **Archivo Narrow** and **IBM Plex Mono**. The bar still works without the font links — it falls back to Noto Sans and your system monospace — but it will not look the way it is meant to. +- **`async` → `defer`.** `defer` waits for the page to finish parsing and keeps script order predictable; `async` fires whenever the download happens to land. +- **`@latest` → `@6.0.0`.** A pinned version cannot change under you when a new release goes out. Use `@6` instead if you would rather pick up 6.x updates automatically. +
And that is it. Hard refresh your web page (CTRL+Shift+R) twice, and Profit! diff --git a/slideshowpure.css b/slideshowpure.css index 3af9920..218bbf0 100644 --- a/slideshowpure.css +++ b/slideshowpure.css @@ -1,13 +1,44 @@ /* - * Jellyfin Slideshow by M0RPH3US v5.0.1 + * Jellyfin Slideshow by M0RPH3US v6.0.0 + * + * The Archivo Narrow webfont is linked from index.html, not @import-ed here: + * an @import cannot start downloading until this stylesheet has itself been + * fetched and parsed, which serialises two round trips before any text can + * render in the intended face. */ -@import url(https://fonts.googleapis.com/css2?family=Archivo+Narrow:ital,wght@0,400..700;1,400..700&display=swap); +:root { + + --slideshow-height: 100vh; + + --slideshow-content-top: 65vh; + + --slideshow-dots-reserve: max(2.5vh, 1.75rem); + + --slideshow-gutter: 4vw; + --slideshow-font: "Archivo Narrow", "Noto Sans", sans-serif; + + --plate: rgb(28 30 34); + --plate-accent: rgb(150 156 166); + + --slideshow-bottom-fade: linear-gradient( + to top, + #fff0 2%, + rgb(0 0 0 / 0.5) 6%, + #000 8% + ); + + --slideshow-video-side-fade: linear-gradient( + to right, + transparent 10%, + black 30% + ); +} .backdrop.animate { animation: frostedGlass 1.2s cubic-bezier(0.4, 0, 0.2, 1), - kenBurnsZoomIn 10s ease-out forwards; + kenBurnsZoomIn 12s ease-out forwards; } .logo.animate { @@ -28,11 +59,11 @@ @keyframes kenBurnsZoomIn { from { - transform: scale3d(1, 1, 0); + transform: scale3d(1, 1, 1); } to { - transform: scale3d(1.1, 1.1, 0.1); + transform: scale3d(1.1, 1.1, 1); } } @@ -115,66 +146,24 @@ transition: width 0.2s ease-in-out; } -.splashLogo { +.bar-loading .splashLogo { height: 12%; } -/* .video-container { - position: absolute; - top: 0; - right: 0; - height: 100%; - width: 0; - z-index: 2; - overflow: hidden; - transition: - width 0.5s ease-in-out, - opacity 0.5s ease-in-out; - opacity: 0; - pointer-events: none; - mask-image: - linear-gradient(to right, transparent 10%, black 30%), - linear-gradient(to top, transparent 2%, rgba(0, 0, 0, 0.5) 6%, black 8%); - - -webkit-mask-image: - linear-gradient(to right, transparent 10%, black 30%), - linear-gradient(to top, transparent 2%, rgba(0, 0, 0, 0.5) 6%, black 8%); - - mask-composite: intersect; - -webkit-mask-composite: source-in; -} - -.video-container.active { - opacity: 1; - pointer-events: auto; -} - -.video-player { - width: 100%; - height: 100%; -} */ - .video-container { position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 100vw; - height: 56.25vw; - min-height: 100vh; - min-width: 177.77vh; + inset: 0; + container-type: size; z-index: 0; pointer-events: none; overflow: hidden; opacity: 0; transition: opacity 0.5s ease-in-out; - mask-image: - linear-gradient(to right, transparent 10%, black 30%), - linear-gradient(to top, transparent 15%, rgba(0, 0, 0, 0.5) 20%, black 30%); - -webkit-mask-image: - linear-gradient(to right, transparent 10%, black 30%), - linear-gradient(to top, transparent 15%, rgba(0, 0, 0, 0.5) 20%, black 30%); + mask-image: var(--slideshow-video-side-fade), var(--slideshow-bottom-fade); + + -webkit-mask-image: var(--slideshow-video-side-fade), + var(--slideshow-bottom-fade); mask-composite: intersect; -webkit-mask-composite: source-in; @@ -186,16 +175,27 @@ .video-player { position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100cqw; + height: 56.25cqw; + min-width: 177.78cqh; + min-height: 100cqh; } -.video-player iframe { +.video-player iframe, +.video-player .local-trailer { width: 100%; height: 100%; pointer-events: none; + border: 0; +} + +.video-player .local-trailer { + object-fit: cover; + display: block; + background: #000; } .volume-toggle { @@ -255,40 +255,65 @@ .homeSectionsContainer { position: relative; - top: 65vh; + + margin-top: calc( + var(--slideshow-content-top) - var(--slideshow-page-offset, 0px) + ); z-index: 6; } #slides-container { - position: relative; - width: 100vw; - height: 90%; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: var(--slideshow-height); overflow: hidden; - margin: 0 auto; } .arrow { position: absolute; top: 50%; transform: translateY(-50%); - font-size: 12px; - cursor: pointer; - color: #fff; - z-index: 5; + z-index: 7; display: flex; justify-content: center; align-items: center; + width: 3rem; + height: 3rem; + border-radius: 50%; + background: rgb(0 0 0 / 0.4); + color: #fff; + cursor: pointer; + opacity: 0; + visibility: hidden; + transition: + opacity 0.3s ease, + visibility 0.3s ease, + background-color 0.2s ease; -webkit-tap-highlight-color: #fff0; } +.arrow i { + font-size: 2rem; +} + +.arrow:hover { + background: rgb(0 0 0 / 0.7); +} + +#slides-container:hover .arrow, +#slides-container:focus-within .arrow { + opacity: 1; + visibility: visible; +} + .left-arrow { left: 20px; - font-size: 12px; } .right-arrow { right: 20px; - font-size: 12px; } .pause-button { @@ -329,8 +354,11 @@ .dots-container { position: absolute; - top: calc(50% + 18vh); right: 3%; + bottom: calc( + var(--slideshow-height) - var(--slideshow-content-top) + + var(--slideshow-dots-reserve) + ); z-index: 5; display: flex; justify-content: center; @@ -382,18 +410,8 @@ right: 0%; width: 100%; height: 100%; - mask-image: linear-gradient( - to top, - #fff0 2%, - rgb(0 0 0 / 0.5) 6%, - #000000 8% - ); - -webkit-mask-image: linear-gradient( - to top, - #fff0 2%, - rgb(0 0 0 / 0.5) 6%, - #000000 8% - ); + mask-image: var(--slideshow-bottom-fade); + -webkit-mask-image: var(--slideshow-bottom-fade); } .backdrop { @@ -407,24 +425,37 @@ transition: width 1.5s ease-in-out, mask-image 1.5s ease-in-out, - -webkit-mask-image 1.5s ease-in-out; + -webkit-mask-image 1.5s ease-in-out, + opacity 0.45s ease-in-out; will-change: width; } .backdrop.with-video { width: 40%; - mask-image: - linear-gradient(to top, #fff0 2%, rgb(0 0 0 / 0.5) 6%, #000000 8%), + mask-image: var(--slideshow-bottom-fade), linear-gradient(to right, black 30%, transparent 85%); - -webkit-mask-image: - linear-gradient(to top, #fff0 2%, rgb(0 0 0 / 0.5) 6%, #000000 8%), + -webkit-mask-image: var(--slideshow-bottom-fade), linear-gradient(to right, black 30%, transparent 85%); mask-composite: intersect; -webkit-mask-composite: source-in; } +.backdrop { + background-size: cover; + background-position: center 20%; + background-repeat: no-repeat; +} + +.backdrop.awaiting-image { + opacity: 0; +} + +.backdrop.image-failed { + opacity: 0 !important; +} + .backdrop-overlay { position: absolute; top: 0; @@ -434,18 +465,8 @@ background-color: rgba(0, 0, 0, 0.2); border-radius: 5px; z-index: 4; - mask-image: linear-gradient( - to top, - #fff0 2%, - rgb(0 0 0 / 0.5) 4%, - #000000 6% - ); - -webkit-mask-image: linear-gradient( - to top, - #fff0 2%, - rgb(0 0 0 / 0.5) 4%, - #000000 6% - ); + mask-image: var(--slideshow-bottom-fade); + -webkit-mask-image: var(--slideshow-bottom-fade); } .gradient-overlay { @@ -461,92 +482,92 @@ rgba(29, 29, 29, 0) 100% ); z-index: 4; - mask-image: linear-gradient( - to top, - #fff0 2%, - rgb(0 0 0 / 0.5) 4%, - #000000 6% - ); - -webkit-mask-image: linear-gradient( - to top, - #fff0 2%, - rgb(0 0 0 / 0.5) 4%, - #000000 6% + mask-image: var(--slideshow-bottom-fade); + -webkit-mask-image: var(--slideshow-bottom-fade); +} + +.slide-content { + position: absolute; + left: var(--slideshow-gutter); + bottom: calc( + var(--slideshow-height) - var(--slideshow-content-top) + + var(--slideshow-dots-reserve) ); + width: min(46%, 46rem); + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.9rem; + z-index: 5; + color: #fff; + + max-height: calc(var(--slideshow-content-top) - 5vh); + overflow: hidden; } .logo-container { - width: 40%; - height: 35%; - position: relative; display: flex; - align-items: center; - z-index: 5; - top: 15vh; - left: 4vw; + align-items: flex-end; + width: 100%; + height: 22vh; + + flex: 0 1 auto; + min-height: 0; + margin-bottom: 0.4rem; +} + +.info-container, +.genre, +.plot-container, +.button-container { + flex: 0 0 auto; } .logo { - max-height: 70%; + max-height: 100%; max-width: 100%; height: auto; width: auto; object-fit: contain; + object-position: left bottom; filter: brightness(1.5); font-size: 4rem; } .plot-container { - position: absolute; - top: calc(50% + 8vh); - color: #fff; - height: 15%; - width: 90%; - left: 4vw; - z-index: 5; - display: flex; - align-items: flex-start; - justify-content: flex-start; - text-align: justify; + width: 100%; box-sizing: border-box; transition: width 0.5s ease-in-out; } +.slide-content:has(~ .video-container.active) .plot-container, .plot-container.with-video { - width: 45%; + width: 90%; } .plot { display: -webkit-box; - line-clamp: 2; - -webkit-line-clamp: 2; + line-clamp: 3; + -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; + margin: 0; } .genre { display: flex; gap: 5px; align-items: center; - justify-content: center; - position: absolute; - top: calc(50% + 4vh); - left: 4vw; - text-align: center; color: #fff; - font-family: "Archivo Narrow", sans-serif; - z-index: 5; + font-family: var(--slideshow-font); flex-wrap: wrap; } .button-container { - position: absolute; - top: calc(50% + 17vh); - left: 4vw; display: flex; - z-index: 5; - justify-content: space-between; + align-items: center; gap: 15px; + margin-top: 0.4rem; } .play-button { @@ -554,45 +575,61 @@ flex-direction: row; align-items: center; justify-content: center; - padding: 8px 16px; - border: solid 0px rgba(255, 255, 255, 0); - font-family: "Archivo Narrow", sans-serif; + height: 50px; + + padding-top: 0; + padding-bottom: 0; + border: none; + background: #fff; + color: #000; + font-family: var(--slideshow-font); font-size: 18px; white-space: nowrap; cursor: pointer; - transition: all 0.3s ease; + transition: + background-color 0.2s ease, + transform 0.2s ease; font-weight: 700; gap: 6px; -webkit-tap-highlight-color: #fff0; border-radius: 8px; } -.detail-button { +.play-button:hover { + background: rgb(255 255 255 / 0.85); +} + +.detail-button, +.favorite-button { + display: inline-flex; + align-items: center; + justify-content: center; font-size: 18px; - color: rgb(0, 0, 0); border-radius: 50%; height: 50px; width: 50px; - border: none; + padding: 0; + border: 1px solid rgb(255 255 255 / 0.35); + background: rgb(0 0 0 / 0.45); + color: #fff; cursor: pointer; - transition: color 0.2s; + backdrop-filter: blur(6px); + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease; -webkit-tap-highlight-color: #fff0; } -.favorite-button { - font-size: 18px; - color: red; - border-radius: 50%; - height: 50px; - width: 50px; - border: none; - cursor: pointer; - transition: color 0.2s; - -webkit-tap-highlight-color: #fff0; +.detail-button:hover, +.favorite-button:hover { + background: rgb(0 0 0 / 0.7); + border-color: rgb(255 255 255 / 0.7); } .favorite-button.favorited { - color: red; + color: #ff4d4d; + border-color: rgb(255 77 77 / 0.6); } .favorite-button::before { @@ -626,32 +663,29 @@ vertical-align: middle; } -.play-button:hover, -.detail-button:hover, -.favorite-button:hover { - opacity: 0.8; +.play-button:focus-visible, +.detail-button:focus-visible, +.favorite-button:focus-visible, +.arrow:focus-visible, +.pause-button:focus-visible, +.volume-toggle:focus-visible { + outline: 3px solid #fff; + outline-offset: 3px; } .info-container { - position: absolute; - top: calc(50% + 0vh); display: flex; align-items: center; - justify-content: center; - left: 4vw; color: #fff; - z-index: 5; - align-content: center; flex-wrap: wrap; font-weight: 500; } .misc-info { - font-family: "Archivo Narrow", sans-serif; + font-family: var(--slideshow-font); display: flex; align-items: center; - z-index: 5; - position: relative; + flex-wrap: wrap; gap: 10px; } @@ -710,14 +744,13 @@ .separator-icon { font-size: 10px; - color: aquamarine; + color: rgb(255 255 255 / 0.55); } .featured-content { display: none; } -/*Portrait-Modes Phone*/ @media only screen and (max-width: 767px) and (orientation: portrait) { .plot-container { display: none; @@ -730,11 +763,9 @@ height: 100%; } - .video-container { - display: none !important; - } - - .volume-toggle i { + .video-container, + .arrow, + .volume-toggle { display: none !important; } @@ -789,9 +820,46 @@ ); } - .dots-container { - top: calc(50% + 20vh); + .slide-content { left: 50%; + right: auto; + transform: translateX(-50%); + bottom: calc( + var(--slideshow-height) - var(--slideshow-content-top) + 7vh + ); + width: 92%; + align-items: center; + text-align: center; + } + + .logo-container { + height: 20vh; + justify-content: center; + align-items: center; + } + + .logo { + object-position: center; + } + + .genre, + .info-container, + .misc-info { + justify-content: center; + } + + .button-container { + justify-content: center; + transform: scale(0.95); + } + + .dots-container { + top: auto; + bottom: calc( + var(--slideshow-height) - var(--slideshow-content-top) + 1.5vh + ); + left: 50%; + right: auto; transform: translateX(-50%) scale(0.8); background-color: #ffffff00; } @@ -799,103 +867,1323 @@ .dot.active { transform: scale(1.6); } +} - .genre { - top: calc(50% + 15vh); - left: 50%; - width: 100%; - transform: translateX(-50%); +@media only screen and (max-width: 1024px) and (max-height: 767px) and (orientation: landscape) { + :root { + + --slideshow-content-top: 68vh; + --slideshow-gutter: 5vw; } - .info-container { - top: calc(50% + 10vh); - left: 50%; - transform: translateX(-50%); - width: 95%; - } - - .button-container { - top: calc(50% + 25vh); - left: 50%; - transform: translateX(-50%) scale(0.95); - } - - .logo { - position: absolute; - top: 50%; - left: 50%; - max-height: 60%; - max-width: 100%; - width: auto; - z-index: 5; - filter: brightness(1.5); - transform: translate(-50%, -50%); - transition: filter 0.3s ease; + .slide-content { + gap: 0.35rem; } .logo-container { - width: 75%; - height: 25%; - position: relative; - display: flex; - align-items: start; - z-index: 5; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } -} - -/*Landscape Mode Phones*/ -@media only screen and (max-height: 767px) and (orientation: landscape) { - #slides-container { - height: 100%; + height: 13vh; + margin-bottom: 0.15rem; } - .homeSectionsContainer { - top: 57vh; + .plot { + -webkit-line-clamp: 1; + line-clamp: 1; } .button-container { - left: 3vw; - transform: scale(0.85); + transform: scale(0.8); + transform-origin: left center; + margin-top: 0; } .dots-container { - scale: 0.6; - } - - .info-container { - top: calc(50% + -10vh); - } - - .plot-container { - top: calc(50% + 6vh); - } - - .genre { - top: calc(50% + -1vh); - } - - .logo-container { - height: 30%; - top: 10%; - } - - .logo-container, - .info-container, - .genre, - .plot-container { - left: 5vw; + transform: scale(0.6); } } @media only screen and (min-width: 2560px) { - .button-container { - top: calc(50% + 15vh); + .slide-content { + gap: 1.2rem; } - .dots-container { - top: calc(50% + 15vh); + .logo-container { + height: 26vh; + } +} + +@media (prefers-reduced-motion: reduce) { + .backdrop.animate, + .logo.animate { + animation: none !important; + } + + .slide, + .backdrop, + .dot, + .arrow, + .plot-container, + .video-container, + .play-button, + .detail-button, + .favorite-button, + .pause-button, + .volume-toggle, + .progress-bar, + .unfilled-bar, + .bar-loading { + transition: none !important; + } +} + +#slides-container.layout-plate { + --slideshow-gutter: clamp(1.25rem, 3.4vw, 4.5rem); + + --plate-width: clamp(19rem, 32vw, 34rem); + + --plate-fade: clamp(3rem, 9vw, 11rem); + + --plate-text: clamp(0.82rem, 0.62rem + 0.5vw, 1.05rem); + --plate-logo-height: clamp(4.5rem, 13vh, 11rem); + --plate-control: clamp(2.5rem, 2.1rem + 1.1vw, 3.25rem); + + --plate-focal: 68%; +} + +#slides-container.layout-plate .plate { + position: absolute; + inset: 0 auto 0 0; + width: calc(var(--plate-width) + var(--plate-fade)); + z-index: 4; + pointer-events: none; + background: linear-gradient( + to right, + var(--plate) 0, + var(--plate) calc(100% - var(--plate-fade)), + transparent 100% + ); + mask-image: var(--slideshow-bottom-fade); + -webkit-mask-image: var(--slideshow-bottom-fade); + transition: background 0.6s ease-in-out; +} + +#slides-container.layout-plate .slide-content { + width: calc(var(--plate-width) - var(--slideshow-gutter) * 2); + gap: clamp(0.5rem, 1.4vh, 1.1rem); + font-size: var(--plate-text); +} + +#slides-container.layout-plate .logo-container { + height: var(--plate-logo-height); +} + +#slides-container.layout-plate .backdrop { + object-position: var(--plate-focal, 68%) 22%; +} + +#slides-container.layout-plate .info-container, +#slides-container.layout-plate .misc-info, +#slides-container.layout-plate .genre, +#slides-container.layout-plate .plot { + font-size: var(--plate-text); +} + +#slides-container.layout-plate .plot { + color: rgb(255 255 255 / 0.76); +} + +#slides-container.layout-plate .separator-icon { + color: var(--plate-accent); + opacity: 0.8; +} + +#slides-container.layout-plate .gradient-overlay { + background: linear-gradient( + 130deg, + rgb(29 29 29 / 0.25) 10%, + rgb(29 29 29 / 0.12) 34%, + rgb(29 29 29 / 0) 100% + ); +} + +#slides-container.layout-plate .video-container.active { + left: var(--plate-width); +} + +#slides-container.layout-plate .backdrop.with-video { + width: 100%; + opacity: 0; + mask-image: var(--slideshow-bottom-fade); + -webkit-mask-image: var(--slideshow-bottom-fade); +} + +#slides-container.layout-plate .plot-container.with-video { + width: 100%; +} + +#slides-container.layout-plate .dots-container { + left: var(--slideshow-gutter); + right: auto; + bottom: calc(var(--slideshow-height) - var(--slideshow-content-top) - 2.2vh); + gap: 0; +} + +#slides-container.layout-plate .dot { + width: 0.4rem; + height: 0.4rem; + margin: 0 0.28rem; + background-color: rgb(255 255 255 / 0.32); + transform: none; + transition: + width 0.45s cubic-bezier(0.4, 0, 0.2, 1), + background-color 0.45s ease-in-out; +} + +#slides-container.layout-plate .dot.active { + width: 1.5rem; + height: 0.4rem; + border-radius: 0.2rem; + background-color: #fff; + transform: none; +} + +#slides-container.layout-plate .play-button, +#slides-container.layout-plate .detail-button, +#slides-container.layout-plate .favorite-button { + height: var(--plate-control); +} + +#slides-container.layout-plate .detail-button, +#slides-container.layout-plate .favorite-button { + width: var(--plate-control); + border-color: rgb(255 255 255 / 0.28); +} + +#slides-container.layout-plate .detail-button:hover, +#slides-container.layout-plate .favorite-button:hover { + border-color: var(--plate-accent); +} + +.layout-tv #slides-container.layout-plate { + --slideshow-gutter: 5vw; + --plate-width: clamp(26rem, 34vw, 44rem); + --plate-text: clamp(1.35rem, 1.35vw, 1.75rem); + --plate-logo-height: clamp(8rem, 17vh, 15rem); + --plate-control: clamp(4rem, 4.5vw, 5.25rem); +} + +.layout-tv #slides-container.layout-plate .slide-content { + gap: clamp(1rem, 2vh, 1.9rem); + bottom: calc(var(--slideshow-height) - var(--slideshow-content-top) + 5vh); +} + +.layout-tv #slides-container.layout-plate .dots-container { + bottom: calc(var(--slideshow-height) - var(--slideshow-content-top) + 0.6vh); +} + +.layout-tv #slides-container.layout-plate .dot { + width: 0.75rem; + height: 0.75rem; +} + +.layout-tv #slides-container.layout-plate .dot.active { + width: 2.75rem; + height: 0.75rem; +} + +.layout-tv #slides-container.layout-plate .play-button:focus-visible, +.layout-tv #slides-container.layout-plate .detail-button:focus-visible, +.layout-tv #slides-container.layout-plate .favorite-button:focus-visible { + outline: none; + box-shadow: + 0 0 0 0.35rem rgb(255 255 255 / 0.95), + 0 0 0 0.7rem rgb(0 0 0 / 0.6); +} + +@media (max-width: 1100px) and (orientation: landscape) { + #slides-container.layout-plate { + --plate-width: clamp(17rem, 42vw, 26rem); + --plate-fade: clamp(2.5rem, 7vw, 7rem); + --plate-logo-height: clamp(3.5rem, 15vh, 7rem); + } +} + +@media (max-width: 767px) and (orientation: portrait) { + #slides-container.layout-plate { + --plate-fade: 20vh; + + --plate-focal: 50%; + } + + #slides-container.layout-plate .backdrop { + object-position: var(--plate-focal) 16%; + } + + #slides-container.layout-plate .plate { + inset: auto 0 0 0; + width: auto; + height: calc(var(--slideshow-height) - var(--slideshow-content-top) + 36vh); + background: linear-gradient( + to top, + var(--plate) 0, + var(--plate) calc(100% - var(--plate-fade)), + transparent 100% + ); + } + + #slides-container.layout-plate .slide-content { + left: var(--slideshow-gutter); + right: var(--slideshow-gutter); + width: auto; + transform: none; + align-items: flex-start; + text-align: left; + } + + #slides-container.layout-plate .logo-container { + justify-content: flex-start; + } + + #slides-container.layout-plate .logo { + object-position: left bottom; + } + + #slides-container.layout-plate .genre, + #slides-container.layout-plate .info-container, + #slides-container.layout-plate .misc-info, + #slides-container.layout-plate .button-container { + justify-content: flex-start; + } + + #slides-container.layout-plate .button-container { + transform: none; + } + + #slides-container.layout-plate .play-button { + flex: 1 1 auto; + } + + #slides-container.layout-plate .dots-container { + left: var(--slideshow-gutter); + right: auto; + transform: none; + } +} + +#slides-container.layout-plate .slide-content { + + top: auto; + bottom: calc( + var(--slideshow-height) - var(--slideshow-content-top) + + var(--slideshow-dots-reserve) + ); + + max-height: calc( + var(--slideshow-rows-top, var(--slideshow-content-top)) - 3.5rem - 3vh + ); + gap: clamp(0.7rem, 1.9vh, 1.6rem); +} + +#slides-container.layout-plate .plate-eyebrow { + font-family: "IBM Plex Mono", ui-monospace, "Archivo Narrow", monospace; + font-size: clamp(0.6rem, 0.5rem + 0.24vw, 0.78rem); + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--plate-accent); + flex: 0 0 auto; + font-variant-numeric: tabular-nums; +} + +#slides-container.layout-plate .plot-container { + width: 100%; +} + +#slides-container.layout-plate .plot { + -webkit-line-clamp: 3; + line-clamp: 3; + color: rgb(255 255 255 / 0.72); + line-height: 1.55; +} + +#slides-container.layout-plate .plate-specs { + display: flex; + flex-direction: column; + width: 100%; + margin: 0; + flex: 0 1 auto; + min-height: 0; + overflow: hidden; +} + +#slides-container.layout-plate .spec-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding-bottom: clamp(0.3rem, 0.7vh, 0.55rem); + margin-bottom: clamp(0.3rem, 0.7vh, 0.55rem); + border-bottom: 1px solid color-mix(in srgb, var(--plate-accent) 30%, transparent); +} + +#slides-container.layout-plate .spec-row.is-last { + border-bottom: 0; + margin-bottom: 0; + padding-bottom: 0; +} + +#slides-container.layout-plate .spec-label { + font-family: "IBM Plex Mono", ui-monospace, "Archivo Narrow", monospace; + font-size: clamp(0.58rem, 0.48rem + 0.22vw, 0.74rem); + letter-spacing: 0.11em; + text-transform: uppercase; + color: rgb(255 255 255 / 0.55); + margin: 0; + white-space: nowrap; +} + +#slides-container.layout-plate .spec-value { + font-family: var(--slideshow-font); + font-size: var(--plate-text); + font-weight: 600; + color: #fff; + margin: 0; + text-align: right; + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#slides-container.layout-plate .button-container { + flex: 0 0 auto; + margin-top: clamp(0.2rem, 1vh, 0.7rem); +} + +#slides-container.layout-plate .dots-container { + top: auto; + bottom: calc(var(--slideshow-height) - var(--slideshow-content-top) + 1.5vh); +} + +.layout-tv #slides-container.layout-plate .slide-content { + top: clamp(5rem, 15vh, 11rem); + bottom: auto; + max-height: calc( + var(--slideshow-content-top) - clamp(5rem, 15vh, 11rem) - 6vh + ); +} + +.layout-tv #slides-container.layout-plate .plate-eyebrow { + font-size: clamp(1rem, 1vw, 1.3rem); +} + +.layout-tv #slides-container.layout-plate .spec-label { + font-size: clamp(0.95rem, 0.95vw, 1.2rem); +} + +@media (max-width: 767px) and (orientation: portrait) { + #slides-container.layout-plate .slide-content { + top: auto; + bottom: calc(var(--slideshow-height) - var(--slideshow-content-top) + 7vh); + max-height: none; + } + + #slides-container.layout-plate .plate-specs { + flex-direction: row; + flex-wrap: wrap; + gap: 0 1.1rem; + } + + #slides-container.layout-plate .spec-row { + border-bottom: 0; + padding-bottom: 0; + margin-bottom: 0; + gap: 0.4rem; + justify-content: flex-start; + } + + #slides-container.layout-plate .plot { + -webkit-line-clamp: 2; + line-clamp: 2; + } +} + +:root.sspure-plate { + --slideshow-content-top: 74vh; +} + +#slides-container.layout-plate .logo-container { + flex: 0 1 auto; + min-height: 3.5rem; +} + +#slides-container.layout-plate .plot-container { + flex: 0 1 auto; + min-height: 0; + overflow: hidden; +} + +#slides-container.layout-plate { + --plate-logo-height: clamp(5rem, 17vh, 13rem); +} + +#slides-container.layout-plate .plate { + mask-image: none; + -webkit-mask-image: none; +} + +.layout-tv #slides-container.layout-plate { + --plate-logo-height: clamp(9rem, 20vh, 17rem); +} + +@media (max-width: 1100px) and (orientation: landscape) { + #slides-container.layout-plate { + --plate-logo-height: clamp(3.5rem, 16vh, 7rem); + } + + #slides-container.layout-plate .plot-container { + display: none; + } +} + +@media (max-width: 767px) and (orientation: portrait) { + #slides-container.layout-plate { + --plate-logo-height: clamp(4.5rem, 12vh, 8rem); + } + + #slides-container.layout-plate .plate { + mask-image: var(--slideshow-bottom-fade); + -webkit-mask-image: var(--slideshow-bottom-fade); + } +} + +#slides-container.layout-plate .backdrop.with-video { + opacity: 1; + width: 100%; +} + +#slides-container.layout-plate .slide:has(.video-container.active) .backdrop { + opacity: 0; +} + +#slides-container.layout-plate .dots-container { + top: auto; + bottom: calc( + 100% - var(--slideshow-content-top) + var(--slideshow-page-offset, 0px) + + 3vh + ); +} + +.layout-tv #slides-container.layout-plate .dots-container { + bottom: calc( + 100% - var(--slideshow-content-top) + var(--slideshow-page-offset, 0px) + + 4vh + ); +} + +@media (max-width: 767px) and (orientation: portrait) { + #slides-container.layout-plate .dots-container { + bottom: calc( + 100% - var(--slideshow-content-top) + var(--slideshow-page-offset, 0px) + + 2vh + ); + } +} + +#slides-container.layout-marquee { + --slideshow-gutter: clamp(1.25rem, 3.6vw, 4.5rem); + --mq-logo-height: clamp(4.5rem, 15vh, 12rem); + --mq-control: clamp(2.5rem, 2.1rem + 1.1vw, 3.25rem); + --mq-spec: clamp(0.62rem, 0.5rem + 0.32vw, 0.82rem); + --mq-rule-width: clamp(16rem, 32vw, 34rem); +} + +#slides-container.layout-marquee .slide-content { + width: min(52%, 50rem); + gap: clamp(0.55rem, 1.5vh, 1.15rem); +} + +#slides-container.layout-marquee .logo-container { + height: var(--mq-logo-height); + flex: 0 0 auto; +} + +#slides-container.layout-marquee .spec-rail { + position: relative; + width: var(--mq-rule-width); + max-width: 100%; + height: 1px; + background: rgb(255 255 255 / 0.22); + flex: 0 0 auto; + margin-top: 0.35rem; +} + +#slides-container.layout-marquee .spec-progress { + position: absolute; + inset: 0 auto 0 0; + width: 0; + background: #fff; +} + +#slides-container.layout-marquee .spec-line { + display: flex; + align-items: center; + flex-wrap: wrap; + row-gap: 0.4rem; + font-family: "IBM Plex Mono", ui-monospace, "Archivo Narrow", monospace; + font-size: var(--mq-spec); + letter-spacing: 0.13em; + text-transform: uppercase; + color: rgb(255 255 255 / 0.88); + font-variant-numeric: tabular-nums; + flex: 0 0 auto; +} + +#slides-container.layout-marquee .spec-item { + padding: 0 0.85em; +} + +#slides-container.layout-marquee .spec-item:first-child { + padding-left: 0; +} + +#slides-container.layout-marquee .spec-divider { + width: 1px; + height: 0.95em; + background: rgb(255 255 255 / 0.3); + flex: 0 0 auto; +} + +#slides-container.layout-marquee .spec-cert { + margin-left: 0.85em; + border: 1px solid rgb(255 255 255 / 0.5); + border-radius: 3px; + padding: 0.1em 0.45em; + font-weight: 600; + letter-spacing: 0.06em; +} + +#slides-container.layout-marquee .plot { + -webkit-line-clamp: 2; + line-clamp: 2; + color: rgb(255 255 255 / 0.74); +} + +#slides-container.layout-marquee .plot-container { + width: 100%; + flex: 0 1 auto; + min-height: 0; + overflow: hidden; +} + +#slides-container.layout-marquee .button-container { + flex: 0 0 auto; + margin-top: clamp(0.2rem, 0.9vh, 0.6rem); +} + +#slides-container.layout-marquee .play-button, +#slides-container.layout-marquee .detail-button, +#slides-container.layout-marquee .favorite-button { + height: var(--mq-control); +} + +#slides-container.layout-marquee .detail-button, +#slides-container.layout-marquee .favorite-button { + width: var(--mq-control); +} + +#slides-container.layout-marquee .dots-container { + left: var(--slideshow-gutter); + right: auto; + gap: 0; +} + +#slides-container.layout-marquee .dot { + width: 0.4rem; + height: 0.4rem; + margin: 0 0.28rem; + background-color: rgb(255 255 255 / 0.32); + transform: none; + transition: + width 0.45s cubic-bezier(0.4, 0, 0.2, 1), + background-color 0.45s ease-in-out; +} + +#slides-container.layout-marquee .dot.active { + width: 1.5rem; + height: 0.4rem; + border-radius: 0.2rem; + background-color: #fff; + transform: none; +} + +.layout-tv #slides-container.layout-marquee { + --slideshow-gutter: 5vw; + --mq-logo-height: clamp(9rem, 20vh, 17rem); + --mq-control: clamp(4rem, 4.5vw, 5.25rem); + --mq-spec: clamp(1.1rem, 1.1vw, 1.4rem); + --mq-rule-width: clamp(28rem, 42vw, 52rem); +} + +.layout-tv #slides-container.layout-marquee .spec-rail { + height: 2px; +} + +.layout-tv #slides-container.layout-marquee .dot { + width: 0.75rem; + height: 0.75rem; +} + +.layout-tv #slides-container.layout-marquee .dot.active { + width: 2.75rem; + height: 0.75rem; +} + +.layout-tv #slides-container.layout-marquee .play-button:focus-visible, +.layout-tv #slides-container.layout-marquee .detail-button:focus-visible, +.layout-tv #slides-container.layout-marquee .favorite-button:focus-visible { + outline: none; + box-shadow: + 0 0 0 0.35rem rgb(255 255 255 / 0.95), + 0 0 0 0.7rem rgb(0 0 0 / 0.6); +} + +@media (max-width: 1100px) and (orientation: landscape) { + #slides-container.layout-marquee { + --mq-logo-height: clamp(3.5rem, 16vh, 7rem); + --mq-rule-width: clamp(14rem, 42vw, 22rem); + } + + #slides-container.layout-marquee .plot-container { + display: none; + } + + #slides-container.layout-marquee .slide-content { + width: min(62%, 34rem); + } +} + +@media (max-width: 767px) and (orientation: portrait) { + #slides-container.layout-marquee { + --mq-logo-height: clamp(4rem, 11vh, 7rem); + } + + #slides-container.layout-marquee .slide-content { + left: var(--slideshow-gutter); + right: var(--slideshow-gutter); + width: auto; + padding: 1.15rem 1.15rem 1.1rem; + border-radius: 1.1rem; + border: 1px solid rgb(255 255 255 / 0.13); + background: rgb(14 17 24 / 0.56); + backdrop-filter: blur(20px) saturate(140%); + -webkit-backdrop-filter: blur(20px) saturate(140%); + align-items: stretch; + text-align: left; + } + + #slides-container.layout-marquee .logo-container { + justify-content: flex-start; + } + + #slides-container.layout-marquee .logo { + object-position: left center; + } + + #slides-container.layout-marquee .spec-rail { + width: 100%; + } + + #slides-container.layout-marquee .spec-line { + letter-spacing: 0.09em; + font-size: clamp(0.6rem, 2.4vw, 0.72rem); + } + + #slides-container.layout-marquee .plot { + -webkit-line-clamp: 2; + line-clamp: 2; + } + + #slides-container.layout-marquee .button-container { + justify-content: flex-start; + transform: none; + } + + #slides-container.layout-marquee .play-button { + flex: 1 1 auto; + } + + #slides-container.layout-marquee .dots-container { + left: 50%; + right: auto; + transform: translateX(-50%); + } +} + +#slides-container.layout-marquee .slide-content { + top: auto; + bottom: calc( + var(--slideshow-height) - var(--slideshow-content-top) + + var(--slideshow-dots-reserve) + ); + max-height: calc( + var(--slideshow-rows-top, var(--slideshow-content-top)) - 8vh + ); +} + +#slides-container.layout-marquee .play-button, +#slides-container.layout-plate .play-button { + border-radius: 999px; +} + +@media (max-width: 767px) and (orientation: portrait) { + #slides-container.layout-marquee .detail-button, + #slides-container.layout-marquee .favorite-button, + #slides-container.layout-plate .detail-button, + #slides-container.layout-plate .favorite-button { + flex: 0 0 auto; + } + + #slides-container.layout-marquee .button-container, + #slides-container.layout-plate .button-container { + width: 100%; + } +} + +@media (max-width: 767px) and (orientation: portrait) { + #slides-container.layout-marquee .slide-content { + transform: none; + } +} + +@media (max-width: 359px) and (orientation: portrait) { + #slides-container.layout-marquee .plot-container, + #slides-container.layout-plate .plot-container { + display: none; + } +} + +@media (max-width: 767px) and (orientation: portrait) { + #slides-container.layout-marquee .plot-container, + #slides-container.layout-plate .plot-container { + display: block; + } + + #slides-container.layout-marquee .plot, + #slides-container.layout-plate .plot { + -webkit-line-clamp: 2; + line-clamp: 2; + } +} + +@media (max-width: 359px) and (orientation: portrait) { + #slides-container.layout-marquee .plot-container, + #slides-container.layout-plate .plot-container { + display: none; + } +} + +#slides-container.layout-marquee .spec-line { + min-height: 1.62em; + align-items: center; +} + +#slides-container.layout-marquee .spec-line { + min-height: 0; +} + +#slides-container.layout-marquee .spec-item, +#slides-container.layout-marquee .spec-cert { + padding-top: 0.1em; + padding-bottom: 0.1em; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + line-height: 1.2; +} + +#slides-container.layout-marquee .spec-cert { + border-color: rgb(255 255 255 / 0.5); +} + +#slides-container.layout-marquee .spec-item + .spec-item, +#slides-container.layout-marquee .spec-item + .spec-cert { + border-left: 1px solid rgb(255 255 255 / 0.3); +} + +#slides-container.layout-marquee .spec-divider { + display: none; +} + +#slides-container.layout-marquee .spec-item .community-rating-star { + font-size: 1.25em; + color: #f2b01e; + margin-right: 0.3em; + vertical-align: -0.22em; +} + +#slides-container.layout-marquee .spec-item svg { + height: 1.15em; + width: auto; + margin-right: 0.3em; + vertical-align: -0.22em; +} + +#slides-container.layout-marquee .spec-critics, +#slides-container.layout-marquee .spec-rating { + display: inline-flex; + align-items: center; +} + +@media (max-width: 767px) and (orientation: portrait) { + + #slides-container.layout-marquee .spec-line { + flex-wrap: nowrap; + overflow: hidden; + } + + #slides-container.layout-marquee .spec-genre { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + line-height: 1.7; + } +} + +#slides-container.layout-marquee .spec-item, +#slides-container.layout-marquee .spec-cert { + display: inline-flex; + align-items: center; + height: 1.7em; + box-sizing: border-box; +} + +#slides-container.layout-marquee .spec-line { + align-items: center; +} + +#slides-container.layout-marquee .spec-item + .spec-cert { + position: relative; + border-left-color: rgb(255 255 255 / 0.5); +} + +#slides-container.layout-marquee .spec-item + .spec-cert::before { + content: ""; + position: absolute; + left: -0.85em; + top: 0; + bottom: 0; + width: 1px; + background: rgb(255 255 255 / 0.3); +} + +@media (max-width: 767px) and (orientation: portrait) { + + :root.sspure-marquee, + :root.sspure-plate { + + --slideshow-height: 66vh; + --slideshow-content-top: 54vh; + } + + #slides-container.layout-marquee .backdrop { + object-position: center 28%; + } +} + +@media (min-width: 768px) and (max-height: 700px) { + #slides-container.layout-plate .plot-container { + display: none; + } + + #slides-container.layout-plate { + --plate-logo-height: clamp(3.5rem, 11vh, 7rem); + } + + #slides-container.layout-plate .slide-content { + gap: clamp(0.4rem, 1vh, 0.7rem); + } +} + +#slides-container.layout-plate .spec-row.is-placeholder { + visibility: hidden; + border-bottom-color: transparent; +} + +#slides-container .arrow { + width: 2.25rem; + height: 2.25rem; + border: 1px solid rgb(255 255 255 / 0.28); + background: rgb(10 12 18 / 0.42); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + transition: + opacity 0.25s ease, + visibility 0.25s ease, + background-color 0.2s ease, + border-color 0.2s ease; +} + +#slides-container .arrow i { + font-size: 1.25rem; +} + +#slides-container .arrow:hover { + background: rgb(10 12 18 / 0.72); + border-color: rgb(255 255 255 / 0.6); +} + +#slides-container .left-arrow { + left: auto; + right: calc(var(--slideshow-gutter) + 2.25rem + 0.5rem); +} + +#slides-container .right-arrow { + right: var(--slideshow-gutter); +} + +#slides-container.layout-plate .arrow, +#slides-container.layout-marquee .arrow { + top: auto; + bottom: 3.5vh; + transform: none; +} + +.layout-tv #slides-container .arrow { + width: 3.5rem; + height: 3.5rem; +} + +.layout-tv #slides-container .arrow i { + font-size: 2rem; +} + +.layout-tv #slides-container .left-arrow { + right: calc(var(--slideshow-gutter) + 3.5rem + 0.75rem); +} + +.ss-settings-toggle { + position: absolute; + top: 5rem; + right: 6.2rem; + cursor: pointer; + color: white; + z-index: 10; + opacity: 0.3; + transition: opacity 0.3s ease; + text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.8); +} + +.ss-settings-toggle:hover, +.ss-settings-toggle:focus-visible { + opacity: 0.9; +} + +.ss-settings-toggle i { + font-size: 1.5rem; +} + +@media (max-width: 1599px) { + .ss-settings-toggle { + top: 8rem; + } + + .ss-settings-toggle i { + font-size: 2rem; + } +} + +@media (max-width: 767px) { + .ss-settings-toggle { + right: 3.5rem; + } + + .ss-settings-toggle i { + font-size: 2.5rem; + } +} + +.ss-settings { + position: fixed; + top: 4.5rem; + right: 1rem; + z-index: 60; + width: min(21rem, calc(100vw - 2rem)); + max-height: min(32rem, calc(100vh - 6rem)); + display: flex; + flex-direction: column; + border-radius: 0.9rem; + border: 1px solid rgb(255 255 255 / 0.14); + background: rgb(14 17 24 / 0.82); + backdrop-filter: blur(22px) saturate(140%); + -webkit-backdrop-filter: blur(22px) saturate(140%); + box-shadow: 0 1.5rem 3rem rgb(0 0 0 / 0.5); + color: #fff; + font-family: "Archivo Narrow", "Noto Sans", sans-serif; + + opacity: 0; + visibility: hidden; + transform: translateY(-0.5rem); + transition: + opacity 0.2s ease, + visibility 0.2s ease, + transform 0.2s ease; +} + +.ss-settings.is-open { + opacity: 1; + visibility: visible; + transform: none; +} + +.ss-settings-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem 0.7rem 0.9rem 1.1rem; + border-bottom: 1px solid rgb(255 255 255 / 0.1); +} + +.ss-settings-head h2 { + margin: 0; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgb(255 255 255 / 0.72); +} + +.ss-settings-close { + display: flex; + align-items: center; + justify-content: center; + width: 1.9rem; + height: 1.9rem; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: rgb(255 255 255 / 0.6); + cursor: pointer; +} + +.ss-settings-close:hover { + background: rgb(255 255 255 / 0.1); + color: #fff; +} + +.ss-settings-close i { + font-size: 1.15rem; +} + +.ss-settings-body { + padding: 0.35rem 1.1rem 0.6rem; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.ss-set-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.85rem; + padding: 0.72rem 0; + border-bottom: 1px solid rgb(255 255 255 / 0.07); +} + +.ss-set-row:last-child { + border-bottom: 0; +} + +.ss-set-row.is-stacked { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; +} + +.ss-set-row.is-inactive { + opacity: 0.4; +} + +.ss-set-label { + font-size: 0.88rem; + color: rgb(255 255 255 / 0.9); +} + +.ss-set-value { + margin-left: auto; + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: 0.72rem; + font-variant-numeric: tabular-nums; + color: rgb(255 255 255 / 0.55); +} + +.ss-set-note { + flex-basis: 100%; + font-size: 0.68rem; + letter-spacing: 0.04em; + color: rgb(255 255 255 / 0.42); +} + +.ss-choice { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: 0.25rem; + padding: 0.2rem; + border-radius: 0.55rem; + background: rgb(255 255 255 / 0.07); +} + +.ss-choice-btn { + padding: 0.4rem 0.3rem; + border: 0; + border-radius: 0.4rem; + background: transparent; + color: rgb(255 255 255 / 0.62); + font-family: inherit; + font-size: 0.8rem; + cursor: pointer; + transition: + background-color 0.18s ease, + color 0.18s ease; +} + +.ss-choice-btn:hover:not(:disabled):not(.is-active) { + color: #fff; +} + +.ss-choice-btn.is-active { + background: #fff; + color: #10131a; + font-weight: 600; +} + +.ss-switch { + appearance: none; + -webkit-appearance: none; + flex: 0 0 auto; + position: relative; + width: 2.35rem; + height: 1.3rem; + margin: 0; + border-radius: 0.65rem; + background: rgb(255 255 255 / 0.18); + cursor: pointer; + transition: background-color 0.2s ease; +} + +.ss-switch::after { + content: ""; + position: absolute; + top: 0.17rem; + left: 0.17rem; + width: 0.96rem; + height: 0.96rem; + border-radius: 50%; + background: #fff; + transition: transform 0.2s ease; +} + +.ss-switch:checked { + background: #2f81f7; +} + +.ss-switch:checked::after { + transform: translateX(1.05rem); +} + +.ss-range { + flex: 0 0 8rem; + accent-color: #2f81f7; + cursor: pointer; +} + +.ss-settings input:disabled, +.ss-settings button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.ss-settings-foot { + padding: 0.7rem 1.1rem 0.9rem; + border-top: 1px solid rgb(255 255 255 / 0.1); +} + +.ss-settings-reset { + width: 100%; + padding: 0.55rem; + border: 1px solid rgb(255 255 255 / 0.2); + border-radius: 0.5rem; + background: transparent; + color: rgb(255 255 255 / 0.75); + font-family: inherit; + font-size: 0.8rem; + cursor: pointer; + transition: + background-color 0.18s ease, + color 0.18s ease; +} + +.ss-settings-reset:hover { + background: rgb(255 255 255 / 0.1); + color: #fff; +} + +.ss-settings :focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} + +@media (max-width: 767px) { + .ss-settings { + top: auto; + bottom: 0; + left: 0; + right: 0; + width: auto; + max-height: 78vh; + border-radius: 1.1rem 1.1rem 0 0; + transform: translateY(1rem); + } +} + +@media (prefers-reduced-motion: reduce) { + .ss-settings, + .ss-switch, + .ss-switch::after, + .ss-choice-btn { + transition: none; + } +} + +@media (hover: hover) { + #slides-container .ss-chrome { + opacity: 0; + visibility: hidden; + transition: + opacity 0.25s ease, + visibility 0.25s ease; + } + + #slides-container:hover .ss-chrome, + #slides-container:focus-within .ss-chrome, + #slides-container.is-paused .ss-chrome, + #slides-container.settings-open .ss-chrome { + opacity: 0.3; + visibility: visible; + } + + #slides-container .ss-chrome:hover, + #slides-container .ss-chrome:focus-visible { + opacity: 0.9; + visibility: visible; + } +} + +@media (prefers-reduced-motion: reduce) { + #slides-container .ss-chrome { + transition: none; } } diff --git a/slideshowpure.js b/slideshowpure.js index 10a20fa..f2b8490 100644 --- a/slideshowpure.js +++ b/slideshowpure.js @@ -1,8 +1,7 @@ /* - * Jellyfin Slideshow by M0RPH3US v5.0.1 + * Jellyfin Slideshow by M0RPH3US v6.0.0 */ -//Core Module Configuration const CONFIG = { IMAGE_SVG: { freshTomato: @@ -10,24 +9,39 @@ const CONFIG = { rottenTomato: '', }, + // ms each slide is shown before advancing. 12000 = 12s. shuffleInterval: 12000, + // ms between retries when waiting on Jellyfin. 250-1000. retryInterval: 500, + // px of horizontal travel before a swipe counts. Lower = twitchier. minSwipeDistance: 50, + // ms between checks for the app being ready (loading screen only). loadingCheckInterval: 100, + // legacy character cap for the synopsis. The CSS line-clamp does the real work. maxPlotLength: 360, - maxMovies: 15, - maxTvShows: 15, + // how many titles to pull from the library. Items without logo art are dropped after this. maxItems: 50, + + // movies per run when quotas are on. 0 = off (or none, if maxSeries > 0). + maxMovies: 0, + // series per run when quotas are on. 0 = off (or none, if maxMovies > 0). + maxSeries: 0, + // slides built ahead of the current one. 1 = next only; >1 also builds the previous. preloadCount: 1, + + // upper bound on pagination dots. Fewer titles than this show one dot each. + maxDots: 5, + + // leading slides that must include every item type present. 0 = off (plain proportional shuffle). + mixTypesInFirst: 3, + // ms of the crossfade between slides. Must match the CSS transition. fadeTransitionDuration: 500, + // true|false. Ken Burns zoom and blur-in on the backdrop and logo. slideAnimationEnabled: true, + // true|false. Master switch for trailer playback, local and YouTube. enableTrailers: true, - // Name fragments identifying alternate cuts of a trailer: accessibility - // variants and social crops. Matched case insensitively against the - // RemoteTrailers entry name. These are demoted within their tier, so an - // alternate cut loses to an ordinary entry of the same tier but still - // beats lower tier promo clips, and an item whose only trailer is an - // alternate cut still plays something. + + // name fragments that demote a trailer (sign language, vertical crops, etc). trailerAlternateCutTerms: [ "sign language", "asl trailer", @@ -36,11 +50,311 @@ const CONFIG = { "described audio", "vertical", ], - syncPageBackdrop: true, + + // true|false. Mirror the slide onto Jellyfin's page backdrop. Intrusive: it deletes Jellyfin's own backdrop layers while active. + syncPageBackdrop: false, + // ms to wait for the YouTube iframe API before giving up and carrying on. youtubeApiLoadTimeoutMs: 8000, + + // ms the backdrop is shown before the trailer starts. 0 = immediately. + trailerStartDelayMs: 3500, + + // ms to wait for playback to actually begin before resuming the slideshow. Guards a silent failure. + trailerPlaybackWatchdogMs: 4000, + // 0-100. Volume once unmuted. Trailers always start muted. + trailerVolume: 50, + + // px. At or below this width the layout is swipe-only: no arrows, no trailer, no volume. + touchLayoutMaxWidth: 767, + + // true|false. Black overlay hiding the app until the slideshow is ready. Not needed since the early start. + showLoadingScreen: false, + + // ms before the overlay lifts regardless. Prevents a permanent black screen. + loadingScreenTimeoutMs: 10000, + + // ms to wait for a signed-in user before giving up entirely. + authWaitTimeoutMs: 30000, + + // ms between sign-in checks during the first moments. Cheap, so it is tight. + authFastPollIntervalMs: 30, + // ms of fast polling before backing off to retryInterval. + authFastPollWindowMs: 8000, + + // true|false. Start from Jellyfin's stored credentials instead of waiting for window.ApiClient (~2.4s earlier). + earlyStartFromStoredCredentials: true, + + // true|false. Paint a blurred approximation of the backdrop while the real image loads. Costs no request. + useBlurHashPlaceholder: true, + + // "marquee" | "plate" | "classic". marquee = spec line + progress rule, panel on phone. plate = type on a tinted panel. classic = the original. + layout: "marquee", + + // 0-1. Plate layout only. Target lightness of the panel. 0.035 holds white text near 12:1. + plateLuminance: 0.035, + // 0-1. Plate layout only. Lightness of the accent taken from the artwork. + plateAccentLuminance: 0.42, + + // CSS colour. Plate panel when an item has no BlurHash to derive from. + plateFallback: "rgb(28 30 34)", + // CSS colour. Accent when an item has no BlurHash. + plateAccentFallback: "rgb(150 156 166)", + + // max rows in the plate layout's spec table. Fields are added in priority order. + plateSpecRows: 4, + + // true|false. Prefer a trailer file on the server over the YouTube embed. Avoids embed blocks and autoplay refusals. + preferLocalTrailers: true, + + // true|false. Hold the rotation while the pointer is over the stage. Resumes mid-period, does not restart. + pauseOnHover: true, + + // true|false. Keep the shuffled order and position for the browser session, so returning to home resumes. + rememberOrderForSession: true, + + // true|false. Skip trailers when the browser reports a metered connection. + respectDataSaver: true, + + // true|false. Also time Jellyfin's own rows for comparison. Costs a body-wide observer; read marks via performance.getEntriesByType('mark'). + measureStartup: false, + + // true|false. Look up trailer intros via sponsor.ajay.app. Sends the video id to a third party, so off by default. + enableSponsorBlock: false, }; -// State management +const STORED_CONFIG_KEY = "mediabar.config"; +const STORED_CONFIG_VERSION = 1; + +const CONFIG_RULES = { + shuffleInterval: [2000, 600000], + retryInterval: [50, 10000], + minSwipeDistance: [10, 400], + loadingCheckInterval: [16, 5000], + maxPlotLength: [40, 4000], + maxItems: [1, 500], + maxMovies: [0, 500], + maxSeries: [0, 500], + preloadCount: [0, 10], + maxDots: [1, 40], + mixTypesInFirst: [0, 20], + fadeTransitionDuration: [0, 5000], + youtubeApiLoadTimeoutMs: [1000, 60000], + trailerStartDelayMs: [0, 60000], + trailerPlaybackWatchdogMs: [500, 60000], + trailerVolume: [0, 100], + touchLayoutMaxWidth: [320, 2000], + loadingScreenTimeoutMs: [1000, 120000], + authWaitTimeoutMs: [1000, 300000], + authFastPollIntervalMs: [10, 5000], + authFastPollWindowMs: [0, 60000], + plateLuminance: [0, 1], + plateAccentLuminance: [0, 1], + plateSpecRows: [1, 8], + layout: ["classic", "marquee", "plate"], + plateFallback: "colour", + plateAccentFallback: "colour", +}; + +const STRUCTURAL_CONFIG_KEYS = new Set([ + "layout", + "maxItems", + "maxMovies", + "maxSeries", + "maxDots", + "mixTypesInFirst", + "preloadCount", + "plateLuminance", + "plateAccentLuminance", + "plateFallback", + "plateAccentFallback", + "plateSpecRows", + "useBlurHashPlaceholder", +]); + +const lockedConfigKeys = new Set(); + +const validateConfigValue = (key, value) => { + const current = CONFIG[key]; + const rule = CONFIG_RULES[key]; + + if (typeof current === "boolean") { + if (typeof value === "boolean") return value; + + if (value === "true" || value === "1") return true; + if (value === "false" || value === "0") return false; + return null; + } + + if (typeof current === "number") { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) return null; + if (!Array.isArray(rule)) return parsed; + return Math.min(rule[1], Math.max(rule[0], parsed)); + } + + if (typeof current === "string") { + const text = String(value); + if (Array.isArray(rule)) return rule.includes(text) ? text : null; + + if (rule === "colour") { + return window.CSS?.supports("color", text) ? text : null; + } + return text; + } + + if (Array.isArray(current)) { + return Array.isArray(value) ? value.map(String) : null; + } + + return null; +}; + +const applyImageSvgOverrides = (svgs) => { + if (!svgs || typeof svgs !== "object") return false; + + let changed = false; + for (const [rawKey, value] of Object.entries(svgs)) { + const key = rawKey.charAt(0).toLowerCase() + rawKey.slice(1); + if (!(key in CONFIG.IMAGE_SVG)) continue; + if (typeof value !== "string" || !/^\s*]/i.test(value)) continue; + CONFIG.IMAGE_SVG[key] = value; + changed = true; + } + return changed; +}; + +const applyConfig = (source, options = {}) => { + if (!source || typeof source !== "object") return []; + + const { trusted = false, sentinel = null, label = "override" } = options; + const changed = []; + + if (trusted && Array.isArray(source.lock)) { + source.lock.forEach((key) => lockedConfigKeys.add(String(key))); + } + + for (const [rawKey, value] of Object.entries(source)) { + if (rawKey === "lock" || rawKey === "__v") continue; + + if (rawKey === "IMAGE_SVG" || rawKey === "ImageSvgs" || rawKey === "imageSvgs") { + if (trusted && applyImageSvgOverrides(value)) changed.push("IMAGE_SVG"); + continue; + } + + const key = + rawKey in CONFIG ? rawKey : rawKey.charAt(0).toLowerCase() + rawKey.slice(1); + + if (!(key in CONFIG)) continue; + if (!trusted && lockedConfigKeys.has(key)) continue; + if (sentinel !== null && value === sentinel) continue; + + const clean = validateConfigValue(key, value); + if (clean === null) { + console.warn( + `[slideshow] ignoring ${label}.${rawKey} =`, + value, + "(invalid; keeping", + CONFIG[key], + ")", + ); + continue; + } + + if (CONFIG[key] !== clean) { + CONFIG[key] = clean; + changed.push(key); + } + } + + return changed; +}; + +const readStoredConfig = () => { + try { + const raw = localStorage.getItem(STORED_CONFIG_KEY); + if (!raw) return null; + + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return null; + + if (parsed.__v !== STORED_CONFIG_VERSION) { + localStorage.removeItem(STORED_CONFIG_KEY); + return null; + } + return parsed; + } catch { + + return null; + } +}; + +const saveStoredConfig = (patch) => { + const next = { + ...(readStoredConfig() || {}), + ...patch, + __v: STORED_CONFIG_VERSION, + }; + try { + localStorage.setItem(STORED_CONFIG_KEY, JSON.stringify(next)); + } catch { + + } + return next; +}; + +const readUrlConfig = () => { + const found = {}; + try { + const hash = window.location.hash; + const hashQuery = hash.includes("?") + ? hash.slice(hash.indexOf("?") + 1) + : ""; + + for (const params of [ + new URLSearchParams(window.location.search), + new URLSearchParams(hashQuery), + ]) { + for (const [key, value] of params) { + if (key.startsWith("ss_")) found[key.slice(3)] = value; + } + } + } catch { + return found; + } + return found; +}; + +const setConfig = (patch) => { + saveStoredConfig(patch); + const changed = applyConfig(patch, { label: "runtime" }); + + if (changed.some((key) => STRUCTURAL_CONFIG_KEYS.has(key))) { + resetSlideshowState(); + bootstrap(); + } + return changed; +}; + +const clearStoredConfig = () => { + try { + localStorage.removeItem(STORED_CONFIG_KEY); + } catch { + + } +}; + +applyConfig(window.MediaBarConfig, { + trusted: true, + sentinel: -1, + label: "plugin", +}); +applyConfig(window.SlideshowConfig, { trusted: true, label: "index.html" }); + +const CONFIG_SERVER_DEFAULTS = JSON.parse(JSON.stringify(CONFIG)); + +applyConfig(readStoredConfig(), { label: "localStorage" }); +applyConfig(readUrlConfig(), { label: "url" }); + const STATE = { jellyfinData: { userId: null, @@ -53,15 +367,27 @@ const STATE = { }, slideshow: { hasInitialized: false, + isBootstrapping: false, + signOutWatcherAttached: false, + listenersAttached: false, isTransitioning: false, isPaused: false, currentSlideIndex: 0, - focusedSlide: null, containerFocused: false, slideInterval: null, itemIds: [], loadedItems: {}, createdSlides: {}, + + slideVideoIds: {}, + trailerStartTimer: null, + trailerWatchdog: null, + firstSlideShown: false, + hoverHeld: false, + resumeIndex: 0, + startedFromStoredCredentials: false, + credentialsRejected: false, + homeRenderWatched: false, totalItems: 0, isLoading: false, players: {}, @@ -122,63 +448,47 @@ const loadYouTubeAPI = () => { return STATE.slideshow.ytPromise; }; -// Request throttling system -const requestQueue = []; -let isProcessingQueue = false; +const mark = (name) => { + try { + performance.mark(`sspure:${name}`); + } catch (error) { -/** - * Process the next request in the queue with throttling - */ -const processNextRequest = () => { - if (requestQueue.length === 0) { - isProcessingQueue = false; + } +}; + +const markJellyfinHomeRender = () => { + + if (!CONFIG.measureStartup) return; + if (STATE.slideshow.homeRenderWatched) return; + STATE.slideshow.homeRenderWatched = true; + + const alreadyRendered = () => + document.querySelector(".homeSectionsContainer .sectionTitle, .homeSectionsContainer .card"); + + if (alreadyRendered()) { + mark("jellyfin-home-rows"); return; } - isProcessingQueue = true; - const { url, callback } = requestQueue.shift(); + const observer = new MutationObserver(() => { + if (!alreadyRendered()) return; + mark("jellyfin-home-rows"); + observer.disconnect(); + }); + observer.observe(document.body, { childList: true, subtree: true }); - fetch(url) - .then((response) => { - if (response.ok) { - return response; - } - throw new Error(`Failed to fetch: ${response.status}`); - }) - .then(callback) - .catch((error) => { - console.error("Error in throttled request:", error); - }) - .finally(() => { - setTimeout(processNextRequest, 100); - }); + setTimeout(() => observer.disconnect(), 30000); }; -/** - * Add a request to the throttled queue - * @param {string} url - URL to fetch - * @param {Function} callback - Callback to run on successful fetch - */ -const addThrottledRequest = (url, callback) => { - requestQueue.push({ url, callback }); - if (!isProcessingQueue) { - processNextRequest(); - } -}; - -/** - * Checks if the user is currently logged in - * @returns {boolean} True if logged in, false otherwise - */ - const isUserLoggedIn = () => { try { - return ( - window.ApiClient && - window.ApiClient._currentUser && - window.ApiClient._currentUser.Id && - window.ApiClient._serverInfo && - window.ApiClient._serverInfo.AccessToken + const apiClient = window.ApiClient; + return Boolean( + apiClient && + typeof apiClient.isLoggedIn === "function" && + apiClient.isLoggedIn() && + apiClient.accessToken() && + apiClient.getCurrentUserId(), ); } catch (error) { console.error("Error checking login status:", error); @@ -186,41 +496,91 @@ const isUserLoggedIn = () => { } }; -/** - * Initializes Jellyfin data from ApiClient - * @param {Function} callback - Function to call once data is initialized - */ -const initJellyfinData = (callback) => { - if (!window.ApiClient) { - console.warn("⏳ window.ApiClient is not available yet. Retrying..."); - setTimeout(() => initJellyfinData(callback), CONFIG.retryInterval); - return; - } - +const hasUsableCredentials = () => { try { const apiClient = window.ApiClient; - STATE.jellyfinData = { - userId: apiClient.getCurrentUserId() || "Not Found", - appName: apiClient._appName || "Not Found", - appVersion: apiClient._appVersion || "Not Found", - deviceName: apiClient._deviceName || "Not Found", - deviceId: apiClient._deviceId || "Not Found", - accessToken: apiClient._serverInfo.AccessToken || "Not Found", - serverId: apiClient._serverInfo.Id || "Not Found", - serverAddress: apiClient._serverAddress || "Not Found", - }; - if (callback && typeof callback === "function") { - callback(); - } + return Boolean( + apiClient && + typeof apiClient.accessToken === "function" && + apiClient.accessToken() && + apiClient.serverAddress() && + apiClient.getCurrentUserId(), + ); } catch (error) { - console.error("Error initializing Jellyfin data:", error); - setTimeout(() => initJellyfinData(callback), CONFIG.retryInterval); + return false; + } +}; + +const readStoredCredentials = () => { + if (!CONFIG.earlyStartFromStoredCredentials) return null; + + try { + const raw = localStorage.getItem("jellyfin_credentials"); + if (!raw) return null; + + const server = JSON.parse(raw)?.Servers?.[0]; + if (!server?.AccessToken || !server?.UserId) return null; + + const serverAddress = + server.ManualAddress || server.LocalAddress || window.location.origin; + if (!serverAddress) return null; + + return { + userId: server.UserId, + accessToken: server.AccessToken, + serverAddress: serverAddress.replace(/\/$/, ""), + serverId: server.Id || null, + }; + } catch (error) { + return null; + } +}; + +const initJellyfinData = () => { + try { + + if (hasUsableCredentials()) { + const apiClient = window.ApiClient; + STATE.jellyfinData = { + userId: apiClient.getCurrentUserId() || null, + appName: apiClient.appName() || "Jellyfin Web", + appVersion: apiClient.appVersion() || "unknown", + deviceName: apiClient.deviceName() || "Browser", + deviceId: apiClient.deviceId() || null, + accessToken: apiClient.accessToken() || null, + serverId: apiClient.serverId() || null, + serverAddress: apiClient.serverAddress() || null, + }; + STATE.slideshow.startedFromStoredCredentials = false; + } else { + + const stored = readStoredCredentials(); + if (!stored) return false; + + STATE.jellyfinData = { + userId: stored.userId, + appName: "Jellyfin Web", + appVersion: "unknown", + deviceName: "Browser", + deviceId: localStorage.getItem("_deviceId2") || null, + accessToken: stored.accessToken, + serverId: stored.serverId, + serverAddress: stored.serverAddress, + }; + STATE.slideshow.startedFromStoredCredentials = true; + } + + return Boolean( + STATE.jellyfinData.userId && + STATE.jellyfinData.accessToken && + STATE.jellyfinData.serverAddress, + ); + } catch (error) { + console.error("Error reading Jellyfin client data:", error); + return false; } }; -/** - * Initializes localization by loading translation chunks - */ const initLocalization = async () => { try { const locale = await LocalizationUtils.getCurrentLocale(); @@ -231,11 +591,9 @@ const initLocalization = async () => { } }; -/** - * Creates and displays loading screen - */ - const initLoadingScreen = () => { + if (!CONFIG.showLoadingScreen) return; + const currentPath = window.location.href.toLowerCase(); const isHomePage = currentPath.includes("/web/#/home.html") || @@ -315,30 +673,38 @@ const initLoadingScreen = () => { } }, CONFIG.loadingCheckInterval); + let finished = false; const finishLoading = () => { + if (finished) return; + finished = true; + clearInterval(progressInterval); clearInterval(checkInterval); + clearTimeout(safetyTimeout); + progressBar.style.transition = "width 300ms ease-in-out"; progressBar.style.width = "100%"; unfilledBar.style.width = "0%"; - progressBar.addEventListener("transitionend", () => { - requestAnimationFrame(() => { - const loader = document.querySelector(".bar-loading"); - if (loader) { - loader.style.opacity = "0"; - setTimeout(() => { - loader.remove(); - }, 300); - } - }); - }); + const loader = document.getElementById("page-loader"); + if (!loader) return; + + setTimeout(() => { + loader.style.opacity = "0"; + setTimeout(() => loader.remove(), 300); + }, 300); }; + + const safetyTimeout = setTimeout(() => { + console.warn( + "Loading screen timed out after " + + CONFIG.loadingScreenTimeoutMs + + "ms; removing overlay.", + ); + finishLoading(); + }, CONFIG.loadingScreenTimeoutMs); }; -/** - * Resets the slideshow state completely - */ const resetSlideshowState = () => { console.log("🔄 Resetting slideshow state..."); @@ -346,109 +712,142 @@ const resetSlideshowState = () => { STATE.slideshow.slideInterval.stop(); } + destroyAllPlayers(); + + PageBackdrop.clear(); + const container = document.getElementById("slides-container"); if (container) { - while (container.firstChild) { - container.removeChild(container.firstChild); - } + container.remove(); } STATE.slideshow.hasInitialized = false; STATE.slideshow.isTransitioning = false; STATE.slideshow.isPaused = false; STATE.slideshow.currentSlideIndex = 0; - STATE.slideshow.focusedSlide = null; STATE.slideshow.containerFocused = false; STATE.slideshow.slideInterval = null; STATE.slideshow.itemIds = []; STATE.slideshow.loadedItems = {}; STATE.slideshow.createdSlides = {}; STATE.slideshow.totalItems = 0; - STATE.slideshow.isLoading = false; + STATE.slideshow.isVideoPlaying = false; }; -/** - * Watches for login status changes - */ -const startLoginStatusWatcher = () => { - let wasLoggedIn = false; - - setInterval(() => { - const isLoggedIn = isUserLoggedIn(); - - if (isLoggedIn !== wasLoggedIn) { - if (isLoggedIn) { - console.log("👤 User logged in. Initializing slideshow..."); - if (!STATE.slideshow.hasInitialized) { - waitForApiClientAndInitialize(); - } else { - console.log("🔄 Slideshow already initialized, skipping"); - } - } else { - console.log("👋 User logged out. Stopping slideshow..."); - resetSlideshowState(); +const destroyAllPlayers = () => { + Object.keys(STATE.slideshow.players).forEach((itemId) => { + const player = STATE.slideshow.players[itemId]; + if (player && typeof player.destroy === "function") { + try { + player.destroy(); + } catch (error) { + console.warn("Error destroying player:", error); } - wasLoggedIn = isLoggedIn; } - }, 2000); + }); + STATE.slideshow.players = {}; + STATE.slideshow.isVideoPlaying = false; }; -/** - * Wait for ApiClient to initialize before starting the slideshow - */ -const waitForApiClientAndInitialize = () => { - if (window.slideshowCheckInterval) { - clearInterval(window.slideshowCheckInterval); - } +const waitForSignIn = () => { + if (isUserLoggedIn()) return Promise.resolve(); - window.slideshowCheckInterval = setInterval(() => { - if (!window.ApiClient) { - console.log("⏳ ApiClient not available yet. Waiting..."); + return new Promise((resolve, reject) => { + const deadline = Date.now() + CONFIG.authWaitTimeoutMs; + const started = Date.now(); + + const tick = () => { + + if (isUserLoggedIn() || readStoredCredentials()) { + mark("credentials-available"); + resolve(); + return; + } + + if (Date.now() > deadline) { + reject(new Error("Timed out waiting for sign-in")); + return; + } + + const elapsed = Date.now() - started; + const interval = + elapsed < CONFIG.authFastPollWindowMs + ? CONFIG.authFastPollIntervalMs + : CONFIG.retryInterval; + setTimeout(tick, interval); + }; + + tick(); + }); +}; + +const bootstrap = async () => { + if (STATE.slideshow.hasInitialized || STATE.slideshow.isBootstrapping) return; + STATE.slideshow.isBootstrapping = true; + + mark("bootstrap-start"); + markJellyfinHomeRender(); + initLoadingScreen(); + watchForSignOut(); + + try { + await waitForSignIn(); + mark("signed-in"); + + if (!initJellyfinData()) { + console.warn("Slideshow: ApiClient data incomplete, not starting."); return; } - if ( - window.ApiClient._currentUser && - window.ApiClient._currentUser.Id && - window.ApiClient._serverInfo && - window.ApiClient._serverInfo.AccessToken - ) { - console.log( - "🔓 User is fully logged in. Starting slideshow initialization...", - ); - clearInterval(window.slideshowCheckInterval); + await initLocalization(); + mark("localization-ready"); - if (!STATE.slideshow.hasInitialized) { - initJellyfinData(async () => { - console.log("✅ Jellyfin API client initialized successfully"); - await initLocalization(); - if (CONFIG.enableTrailers) { - await loadYouTubeAPI(); - } - slidesInit(); - }); - } else { - console.log("🔄 Slideshow already initialized, skipping"); - } - } else { - console.log( - "🔒 Authentication incomplete. Waiting for complete login...", - ); + await slidesInit(); + + if ( + STATE.slideshow.credentialsRejected && + STATE.slideshow.startedFromStoredCredentials + ) { + STATE.slideshow.credentialsRejected = false; + resetSlideshowState(); + STATE.slideshow.isBootstrapping = false; + + await new Promise((resolve) => { + const waitForApiClient = () => { + if (hasUsableCredentials()) resolve(); + else setTimeout(waitForApiClient, CONFIG.retryInterval); + }; + waitForApiClient(); + }); + + console.log("Slideshow: retrying with ApiClient credentials."); + await bootstrap(); } - }, CONFIG.retryInterval); + } catch (error) { + console.warn("Slideshow: not starting —", error.message); + } finally { + STATE.slideshow.isBootstrapping = false; + } }; -waitForApiClientAndInitialize(); +const watchForSignOut = () => { + if (STATE.slideshow.signOutWatcherAttached) return; + STATE.slideshow.signOutWatcherAttached = true; + + window.addEventListener("hashchange", () => { + if (STATE.slideshow.hasInitialized && !isUserLoggedIn()) { + console.log("Slideshow: signed out, tearing down."); + resetSlideshowState(); + } else if (!STATE.slideshow.hasInitialized && isUserLoggedIn()) { + bootstrap(); + } + }); +}; + +bootstrap(); -/** - * Utility functions for slide creation and management - */ const SlideUtils = { - /** - * Shuffles array elements randomly - * @param {Array} array - Array to shuffle - * @returns {Array} Shuffled array - */ + shuffleArray(array) { const newArray = [...array]; for (let i = newArray.length - 1; i > 0; i--) { @@ -458,37 +857,44 @@ const SlideUtils = { return newArray; }, - /** - * Truncates text to specified length and adds ellipsis - * @param {HTMLElement} element - Element containing text to truncate - * @param {number} maxLength - Maximum length before truncation - */ - truncateText(element, maxLength) { - if (!element) return; + mixTypesInHead(items, windowSize) { + if (!windowSize || items.length <= windowSize) return items; - const text = element.innerText || element.textContent; - if (text && text.length > maxLength) { - element.innerText = text.substring(0, maxLength) + "..."; + const result = [...items]; + const presentTypes = new Set(result.map((item) => item.Type)); + if (presentTypes.size < 2) return result; + + for (const type of presentTypes) { + const head = result.slice(0, windowSize); + if (head.some((item) => item.Type === type)) continue; + + const from = result.findIndex( + (item, index) => index >= windowSize && item.Type === type, + ); + if (from === -1) continue; + + const counts = {}; + head.forEach((item) => { + counts[item.Type] = (counts[item.Type] || 0) + 1; + }); + const spare = head + .map((item, index) => (counts[item.Type] > 1 ? index : -1)) + .filter((index) => index !== -1); + if (!spare.length) break; + + const to = spare[Math.floor(Math.random() * spare.length)]; + [result[from], result[to]] = [result[to], result[from]]; } + + return result; }, - /** - * Creates a separator icon element - * @returns {HTMLElement} Separator element - */ createSeparator() { const separator = document.createElement("i"); - separator.className = "material-icons fiber_manual_record separator-icon"; //material-icons radio_button_off + separator.className = "material-icons fiber_manual_record separator-icon"; return separator; }, - /** - * Creates a DOM element with attributes and properties - * @param {string} tag - Element tag name - * @param {Object} attributes - Element attributes - * @param {string|HTMLElement} [content] - Element content - * @returns {HTMLElement} Created element - */ createElement(tag, attributes = {}, content = null) { const element = document.createElement(tag); @@ -501,8 +907,13 @@ const SlideUtils = { element.className = value; } else if (key === "innerHTML") { element.innerHTML = value; - } else if (key === "onclick" && typeof value === "function") { - element.addEventListener("click", value); + } else if (key === "textContent") { + element.textContent = value; + } else if (key.startsWith("on") && typeof value === "function") { + element.addEventListener(key.slice(2), value); + } else if (typeof value === "boolean") { + + element[key] = value; } else { element.setAttribute(key, value); } @@ -519,53 +930,236 @@ const SlideUtils = { return element; }, - /** - * Find or create the slides container - * @returns {HTMLElement} Slides container element - */ getOrCreateSlidesContainer() { let container = document.getElementById("slides-container"); if (!container) { container = this.createElement("div", { id: "slides-container" }); document.body.appendChild(container); } + + container.classList.toggle("layout-plate", CONFIG.layout === "plate"); + container.classList.toggle("layout-marquee", CONFIG.layout === "marquee"); + + const root = document.documentElement; + root.classList.toggle("sspure-plate", CONFIG.layout === "plate"); + root.classList.toggle("sspure-marquee", CONFIG.layout === "marquee"); + return container; }, - /** - * Formats genres into a readable string - * @param {Array} genresArray - Array of genre strings - * @returns {string} Formatted genres string - */ - parseGenres(genresArray) { - if (Array.isArray(genresArray) && genresArray.length > 0) { - return genresArray.slice(0, 3).join(this.createSeparator().outerHTML); - } - return "No Genre Available"; - }, + buildGenres(genresArray) { + if (!Array.isArray(genresArray) || genresArray.length === 0) return null; - /** - * Creates a loading indicator - * @returns {HTMLElement} Loading indicator element - */ - createLoadingIndicator() { - const loadingIndicator = this.createElement("div", { - className: "slide-loading-indicator", - innerHTML: ` -
-
-
-
-
- `, + const genres = genresArray.slice(0, 3).filter(Boolean); + if (!genres.length) return null; + + const fragment = document.createDocumentFragment(); + genres.forEach((genre, index) => { + if (index > 0) fragment.appendChild(this.createSeparator()); + const span = document.createElement("span"); + span.textContent = genre; + fragment.appendChild(span); }); - return loadingIndicator; + return fragment; + }, +}; + +const isDataSaverOn = () => { + if (!CONFIG.respectDataSaver) return false; + try { + return Boolean(navigator.connection?.saveData); + } catch (error) { + return false; + } +}; + +const createLocalPlayer = (video) => ({ + isLocal: true, + element: video, + playVideo() { + + const started = video.play(); + if (started && typeof started.catch === "function") { + started.catch(() => { + video.dispatchEvent(new Event("error")); + }); + } + }, + pauseVideo() { + video.pause(); + }, + seekTo(seconds) { + try { + video.currentTime = seconds || 0; + } catch (error) { + + } + }, + mute() { + video.muted = true; + }, + unMute() { + video.muted = false; + }, + setVolume(percent) { + video.volume = Math.max(0, Math.min(1, (percent || 0) / 100)); + }, + getPlayerState() { + return video.paused ? 2 : 1; + }, + destroy() { + try { + video.pause(); + video.removeAttribute("src"); + video.load(); + video.remove(); + } catch (error) { + + } + }, +}); + +const BlurHash = { + DIGITS: + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~", + + decode83(str, start, end) { + let value = 0; + for (let i = start; i < end; i++) { + const digit = this.DIGITS.indexOf(str[i]); + if (digit === -1) return NaN; + value = value * 83 + digit; + } + return value; + }, + + toLinear(value) { + const v = value / 255; + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }, + + fromLinear(value) { + const v = Math.max(0, Math.min(1, value)); + return Math.round( + (v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255, + ); + }, + + decodeDC(value) { + return [ + this.toLinear(value >> 16), + this.toLinear((value >> 8) & 255), + this.toLinear(value & 255), + ]; + }, + + decodeAC(value, maxValue) { + const quant = (v) => { + const n = Math.floor(v); + return Math.sign(n - 9) * Math.pow(Math.abs(n - 9) / 9, 2) * maxValue; + }; + return [ + quant(value / (19 * 19)), + quant((value / 19) % 19), + quant(value % 19), + ]; + }, + + toDataURL(hash, width = 32, height = 18) { + try { + if (typeof hash !== "string" || hash.length < 6) return null; + + const sizeFlag = this.decode83(hash, 0, 1); + const numX = (sizeFlag % 9) + 1; + const numY = Math.floor(sizeFlag / 9) + 1; + if (hash.length !== 4 + 2 * numX * numY) return null; + + const maxValue = (this.decode83(hash, 1, 2) + 1) / 166; + const colours = new Array(numX * numY); + colours[0] = this.decodeDC(this.decode83(hash, 2, 6)); + for (let i = 1; i < colours.length; i++) { + const from = 4 + i * 2; + colours[i] = this.decodeAC( + this.decode83(hash, from, from + 2), + maxValue, + ); + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return null; + + const imageData = context.createImageData(width, height); + const pixels = imageData.data; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let r = 0; + let g = 0; + let b = 0; + for (let j = 0; j < numY; j++) { + for (let i = 0; i < numX; i++) { + const basis = + Math.cos((Math.PI * x * i) / width) * + Math.cos((Math.PI * y * j) / height); + const colour = colours[i + j * numX]; + r += colour[0] * basis; + g += colour[1] * basis; + b += colour[2] * basis; + } + } + const index = 4 * (x + y * width); + pixels[index] = this.fromLinear(r); + pixels[index + 1] = this.fromLinear(g); + pixels[index + 2] = this.fromLinear(b); + pixels[index + 3] = 255; + } + } + + context.putImageData(imageData, 0, 0); + return canvas.toDataURL(); + } catch (error) { + return null; + } + }, + + plateColours(item) { + const hashes = item?.ImageBlurHashes?.Backdrop; + const hash = hashes ? Object.values(hashes)[0] : null; + if (typeof hash !== "string" || hash.length < 6) return null; + + try { + const dc = this.decodeDC(this.decode83(hash, 2, 6)); + if (dc.some((channel) => Number.isNaN(channel))) return null; + + const luminance = 0.2126 * dc[0] + 0.7152 * dc[1] + 0.0722 * dc[2]; + const pin = (target) => { + const scale = target / (luminance || 1e-6); + return dc.map((channel) => Math.min(1, channel * scale)); + }; + const toCss = (linear) => + `rgb(${linear.map((channel) => this.fromLinear(channel)).join(" ")})`; + + return { + plate: toCss(pin(CONFIG.plateLuminance)), + accent: toCss(pin(CONFIG.plateAccentLuminance)), + }; + } catch (error) { + return null; + } + }, + + forItem(item, imageType, tag) { + const hashes = item?.ImageBlurHashes?.[imageType]; + if (!hashes) return null; + if (tag && hashes[tag]) return hashes[tag]; + const first = Object.values(hashes)[0]; + return typeof first === "string" ? first : null; }, }; -/** - * Localization utilities for fetching and using Jellyfin translations - */ const LocalizationUtils = { translations: {}, locale: null, @@ -573,79 +1167,24 @@ const LocalizationUtils = { cachedLocale: null, chunkUrlCache: {}, - /** - * Gets the current locale from user preference, server config, or HTML tag - * @returns {Promise} Locale code (e.g., "de", "en-us") - */ async getCurrentLocale() { if (this.cachedLocale) { return this.cachedLocale; } - let locale = null; - - if (window.ApiClient && STATE.jellyfinData?.accessToken) { - try { - const userData = window.ApiClient.getCurrentUser(); - if (userData.Configuration?.AudioLanguagePreference) { - locale = userData.Configuration.AudioLanguagePreference.toLowerCase(); - } - } catch (error) { - console.warn("Could not fetch user language preference:", error); - } - } - - if (!locale && window.ApiClient && STATE.jellyfinData?.accessToken) { - try { - const configUrl = window.ApiClient.getUrl("System/Configuration"); - const configResponse = await fetch(configUrl, { - headers: ApiUtils.getAuthHeaders(), - }); - if (configResponse.ok) { - const configData = await configResponse.json(); - if (configData.PreferredMetadataLanguage) { - locale = configData.PreferredMetadataLanguage.toLowerCase(); - if (configData.MetadataCountryCode) { - locale = `${locale}-${configData.MetadataCountryCode.toLowerCase()}`; - } - } - } - } catch (error) { - console.warn("Could not fetch server language preference:", error); - } - } - - if (!locale) { - const langAttr = document.documentElement.getAttribute("lang"); - if (langAttr) { - locale = langAttr.toLowerCase(); - } - } + let locale = document.documentElement.getAttribute("lang"); if (!locale) { const navLang = navigator.language || navigator.userLanguage; - locale = navLang ? navLang.toLowerCase() : "en-us"; - } - if (locale.length === 3) { - const countriesData = await window.ApiClient.getCountries(); - const countryData = Object.values(countriesData).find( - (countryData) => - countryData.ThreeLetterISORegionName === locale.toUpperCase(), - ); - if (countryData) { - locale = countryData.TwoLetterISORegionName.toLowerCase(); - } + locale = navLang || "en-us"; } + locale = locale.toLowerCase(); + this.cachedLocale = locale; return locale; }, - /** - * Finds the translation chunk URL from performance entries - * @param {string} locale - Locale code - * @returns {string|null} URL to translation chunk or null - */ findTranslationChunkUrl(locale) { const localePrefix = locale.split("-")[0]; @@ -676,11 +1215,6 @@ const LocalizationUtils = { return null; }, - /** - * Fetches and loads translations from the chunk JSON - * @param {string} locale - Locale code - * @returns {Promise} - */ async loadTranslations(locale) { if (this.translations[locale]) return; if (this.isLoading[locale]) { @@ -702,23 +1236,6 @@ const LocalizationUtils = { ); } - /** - * @example - * Standard version - * ```js - * "use strict"; - * (self.webpackChunk = self.webpackChunk || []).push([[62634], { - * 30985: function(e) { - * e.exports = JSON.parse('{"Absolute":"..."}') - * } - * }]); - * ``` - * - * Minified version - * ```js - * "use strict";(self.webpackChunk=self.webpackChunk||[]).push([[24072],{60715:function(e){e.exports=JSON.parse('{"Absolute":"..."}')}}]); - * ``` - */ const chunkText = await response.text(); const replaceEscaped = (text) => @@ -773,13 +1290,6 @@ const LocalizationUtils = { await loadPromise; }, - /** - * Gets a localized string (synchronous - translations must be loaded first) - * @param {string} key - Localization key (e.g., "EndsAtValue", "Play") - * @param {string} fallback - Fallback English string - * @param {...any} args - Optional arguments for placeholders (e.g., {0}, {1}) - * @returns {string} Localized string or fallback - */ getLocalizedString(key, fallback, ...args) { const locale = this.cachedLocale || "en-us"; let translated = this.translations[locale]?.[key] || fallback; @@ -794,56 +1304,51 @@ const LocalizationUtils = { }, }; -/** - * API utilities for fetching data from Jellyfin server - */ const ApiUtils = { - /** - * Fetches details for a specific item by ID - * @param {string} itemId - Item ID - * @returns {Promise} Item details - */ - async fetchItemDetails(itemId) { + + itemFieldsQuery() { + return [ + "fields=Overview,Genres,RemoteTrailers,ChildCount,LocalTrailerCount", + "enableUserData=true", + "enableImageTypes=Backdrop,Logo,Primary", + "enableTotalRecordCount=false", + ].join("&"); + }, + + async fetchItemsByIds(ids) { + if (!ids.length) return []; + try { - if (STATE.slideshow.loadedItems[itemId]) { - return STATE.slideshow.loadedItems[itemId]; - } - - const MAX_CACHED_ITEMS = 20; - const cacheKeys = Object.keys(STATE.slideshow.loadedItems); - if (cacheKeys.length >= MAX_CACHED_ITEMS) { - delete STATE.slideshow.loadedItems[cacheKeys[0]]; - } - const response = await fetch( - `${STATE.jellyfinData.serverAddress}/Items/${itemId}`, - { - headers: this.getAuthHeaders(), - }, + `${STATE.jellyfinData.serverAddress}/Items` + + `?Ids=${ids.map(encodeURIComponent).join(",")}` + + `&Recursive=true&${this.itemFieldsQuery()}`, + { headers: this.getAuthHeaders() }, ); if (!response.ok) { - throw new Error(`Failed to fetch item details: ${response.statusText}`); + if (response.status === 401 || response.status === 403) { + STATE.slideshow.credentialsRejected = true; + } + console.warn(`Slideshow: could not load listed items (${response.status}).`); + return []; } - const itemData = await response.json(); - - STATE.slideshow.loadedItems[itemId] = itemData; - - return itemData; + const { Items: items = [] } = await response.json(); + return items; } catch (error) { - console.error(`Error fetching details for item ${itemId}:`, error); - return null; + console.error("Error fetching listed items:", error); + return []; } }, - /** - * API utilities for fetching SponsorBlock JSON data - */ - async getSkipSegments(videoId) { + + if (!CONFIG.enableSponsorBlock) return 0; + try { - const categories = '["intro","sponsor","selfpromo","interaction"]'; + + const categories = '["intro"]'; const response = await fetch( `https://sponsor.ajay.app/api/skipSegments?videoID=${videoId}&categories=${categories}`, ); @@ -865,101 +1370,155 @@ const ApiUtils = { } }, - /** - * Fetch item IDs from the list file - * @returns {Promise} Array of item IDs - */ async fetchItemIdsFromList() { try { const listFileName = `${STATE.jellyfinData.serverAddress}/web/avatars/list.txt?userId=${STATE.jellyfinData.userId}`; const response = await fetch(listFileName); if (!response.ok) { - console.warn("list.txt not found or inaccessible. Using random items."); + + if (response.status !== 404) { + console.warn( + `Slideshow: list.txt returned ${response.status}; using library items.`, + ); + } + return []; + } + + const contentType = response.headers.get("content-type") || ""; + if (/html|xml/i.test(contentType)) { + console.warn( + "Slideshow: list.txt returned HTML rather than a text file " + + "(likely a proxy fallback); using library items.", + ); return []; } const text = await response.text(); - return text - .split("\n") - .map((id) => id.trim()) - .filter((id) => id) - .slice(1); + if (/^\s* line.trim()) + + .filter((line) => line && !line.startsWith("#")) + + .filter((line) => /^[0-9a-f-]{32,36}$/i.test(line)) + ); } catch (error) { console.error("Error fetching list.txt:", error); return []; } }, - /** - * Fetches random items from the server - * @returns {Promise} Array of item objects - */ - async fetchItemIdsFromServer() { - try { - if ( - !STATE.jellyfinData.accessToken || - STATE.jellyfinData.accessToken === "Not Found" - ) { - console.warn("Access token not available. Delaying API request..."); - return []; - } + async fetchItemPage(types, limit) { + const response = await fetch( + `${STATE.jellyfinData.serverAddress}/Items` + + `?IncludeItemTypes=${types}&Recursive=true&hasOverview=true` + + `&imageTypes=Logo,Backdrop&SortBy=Random&isPlayed=False` + + `&Limit=${limit}&${this.itemFieldsQuery()}`, + { + headers: this.getAuthHeaders(), + }, + ); - if ( - !STATE.jellyfinData.serverAddress || - STATE.jellyfinData.serverAddress === "Not Found" - ) { - console.warn("Server address not available. Delaying API request..."); - return []; - } + if (!response.ok) { - console.log("Fetching random items from server..."); - - const response = await fetch( - `${STATE.jellyfinData.serverAddress}/Items?IncludeItemTypes=Movie,Series&Recursive=true&hasOverview=true&imageTypes=Logo,Backdrop&sortBy=Random&isPlayed=False&enableUserData=true&Limit=${CONFIG.maxItems}&fields=Id,ImageTags,RemoteTrailers`, - { - headers: this.getAuthHeaders(), - }, - ); - - if (!response.ok) { - console.error( - `Failed to fetch items: ${response.status} ${response.statusText}`, + if (response.status === 401 || response.status === 403) { + STATE.slideshow.credentialsRejected = true; + console.warn( + "Slideshow: stored credentials rejected; waiting for ApiClient.", ); return []; } - const data = await response.json(); - const items = data.Items || []; - - console.log( - `Successfully fetched ${items.length} random items from server`, + console.error( + `Failed to fetch items: ${response.status} ${response.statusText}`, ); + return []; + } - return items - .filter((item) => item.ImageTags && item.ImageTags.Logo) - .map((item) => item.Id); + const { Items: items = [] } = await response.json(); + + return items.filter((item) => item.ImageTags && item.ImageTags.Logo); + }, + + async fetchItemsFromServer() { + try { + if (!STATE.jellyfinData.accessToken) { + console.warn("Access token not available. Skipping API request."); + return []; + } + + if (!STATE.jellyfinData.serverAddress) { + console.warn("Server address not available. Skipping API request."); + return []; + } + + const movieQuota = Math.max(0, CONFIG.maxMovies || 0); + const seriesQuota = Math.max(0, CONFIG.maxSeries || 0); + + if (!movieQuota && !seriesQuota) { + return await this.fetchItemPage("Movie,Series", CONFIG.maxItems); + } + + const [movies, series] = await Promise.all([ + movieQuota ? this.fetchItemPage("Movie", movieQuota) : [], + seriesQuota ? this.fetchItemPage("Series", seriesQuota) : [], + ]); + + const merged = movies.concat(series); + if (merged.length) return merged; + + if (STATE.slideshow.credentialsRejected) return []; + + console.warn( + "Slideshow: the configured quota matched no items; using the pooled query.", + ); + return await this.fetchItemPage("Movie,Series", CONFIG.maxItems); } catch (error) { - console.error("Error fetching item IDs:", error); + console.error("Error fetching items:", error); return []; } }, - /** - * Get authentication headers for API requests - * @returns {Object} Headers object - */ + async fetchLocalTrailerUrl(itemId) { + try { + const response = await fetch( + `${STATE.jellyfinData.serverAddress}/Items/${itemId}/LocalTrailers`, + { headers: this.getAuthHeaders() }, + ); + if (!response.ok) return null; + + const trailers = await response.json(); + const trailer = Array.isArray(trailers) ? trailers[0] : null; + if (!trailer?.Id) return null; + + const mediaSourceId = trailer.MediaSources?.[0]?.Id || trailer.Id; + + return ( + `${STATE.jellyfinData.serverAddress}/Videos/${trailer.Id}/stream` + + `?static=true&mediaSourceId=${encodeURIComponent(mediaSourceId)}` + + `&api_key=${encodeURIComponent(STATE.jellyfinData.accessToken)}` + ); + } catch (error) { + console.warn(`Could not resolve local trailer for ${itemId}:`, error); + return null; + } + }, + getAuthHeaders() { return { Authorization: `MediaBrowser Client="${STATE.jellyfinData.appName}", Device="${STATE.jellyfinData.deviceName}", DeviceId="${STATE.jellyfinData.deviceId}", Version="${STATE.jellyfinData.appVersion}", Token="${STATE.jellyfinData.accessToken}"`, }; }, - /** - * Send a command to play an item - * @param {string} itemId - Item ID to play - * @returns {Promise} Success status - */ async playItem(itemId) { try { const sessionId = await this.getSessionId(); @@ -988,10 +1547,6 @@ const ApiUtils = { } }, - /** - * Gets current session ID - * @returns {Promise} Session ID or null - */ async getSessionId() { try { const response = await fetch( @@ -1026,10 +1581,9 @@ const ApiUtils = { async toggleFavorite(itemId, button) { try { - const userId = STATE.jellyfinData.userId; const isFavorite = button.classList.contains("favorited"); - const url = `${STATE.jellyfinData.serverAddress}/Users/${userId}/FavoriteItems/${itemId}`; + const url = `${STATE.jellyfinData.serverAddress}/UserFavoriteItems/${itemId}`; const method = isFavorite ? "DELETE" : "POST"; const response = await fetch(url, { @@ -1043,97 +1597,141 @@ const ApiUtils = { if (!response.ok) { throw new Error(`Failed to toggle favorite: ${response.statusText}`); } + button.classList.toggle("favorited", !isFavorite); + + const cached = STATE.slideshow.loadedItems[itemId]; + if (cached) { + cached.UserData = cached.UserData || {}; + cached.UserData.IsFavorite = !isFavorite; + } } catch (error) { console.error("Error toggling favorite:", error); } }, }; -/** - * Class for managing slide timing - */ class SlideTimer { - /** - * Creates a new slide timer - * @param {Function} callback - Function to call on interval - * @param {number} interval - Interval in milliseconds - */ + constructor(callback, interval) { this.callback = callback; this.interval = interval; this.timerId = null; + + this.resumeId = null; + this.startedAt = null; + this.remaining = interval; this.start(); } - /** - * Stops the timer - * @returns {SlideTimer} This instance for chaining - */ stop() { if (this.timerId) { clearInterval(this.timerId); this.timerId = null; } + if (this.resumeId) { + clearTimeout(this.resumeId); + this.resumeId = null; + } + this.remaining = this.interval; + this.startedAt = null; return this; } - /** - * Starts the timer - * @returns {SlideTimer} This instance for chaining - */ start() { - if (!this.timerId) { - this.timerId = setInterval(this.callback, this.interval); + if (this.timerId || this.resumeId) return this; + this.startedAt = Date.now(); + this.remaining = this.interval; + this.timerId = setInterval(this.callback, this.interval); + return this; + } + + pause() { + if (!this.timerId && !this.resumeId) return this; + + const elapsed = this.startedAt ? Date.now() - this.startedAt : 0; + this.remaining = Math.max(0, this.interval - (elapsed % this.interval)); + + if (this.timerId) { + clearInterval(this.timerId); + this.timerId = null; + } + if (this.resumeId) { + clearTimeout(this.resumeId); + this.resumeId = null; } return this; } - /** - * Restarts the timer - * @returns {SlideTimer} This instance for chaining - */ + resume() { + if (this.timerId || this.resumeId) return this; + + if (this.remaining >= this.interval) return this.start(); + + this.startedAt = Date.now() - (this.interval - this.remaining); + this.resumeId = setTimeout(() => { + this.resumeId = null; + this.callback(); + this.startedAt = Date.now(); + this.remaining = this.interval; + this.timerId = setInterval(this.callback, this.interval); + }, this.remaining); + return this; + } + restart() { return this.stop().start(); } } -/** - * Observer for handling slideshow visibility based on current page - */ const VisibilityObserver = { wasVisible: false, + pending: false, + lastDisplay: null, + + scheduleUpdate() { + if (this.pending) return; + this.pending = true; + + const run = () => { + if (!this.pending) return; + this.pending = false; + this.updateVisibility(); + }; + + requestAnimationFrame(run); + setTimeout(run, 250); + }, updateVisibility() { - const activeTab = document.querySelector(".emby-tab-button-active"); const container = document.getElementById("slides-container"); - if (!container) return; + const activeTab = document.querySelector(".emby-tab-button-active"); + const hash = window.location.hash; + const onHome = hash === "#/home.html" || hash === "#/home"; + const isVisible = - (window.location.hash === "#/home.html" || - window.location.hash === "#/home") && - (activeTab ? activeTab.getAttribute("data-index") === "0" : false); + onHome && + (activeTab ? activeTab.getAttribute("data-index") === "0" : true); - container.style.display = isVisible ? "block" : "none"; + const display = isVisible ? "block" : "none"; + if (display !== this.lastDisplay) { + container.style.display = display; + this.lastDisplay = display; + } - if (isVisible && !this.wasVisible) { + if (isVisible === this.wasVisible) return; + + if (isVisible) { SlideshowManager.updateCurrentSlide(STATE.slideshow.currentSlideIndex); - } else if (!isVisible && this.wasVisible) { - if (STATE.slideshow.slideInterval) { - STATE.slideshow.slideInterval.stop(); - } - Object.keys(STATE.slideshow.players).forEach((itemId) => { - const player = STATE.slideshow.players[itemId]; - if (player && typeof player.destroy === "function") { - try { - player.destroy(); - } catch (e) {} - } - }); - STATE.slideshow.players = {}; + } else { + if (STATE.slideshow.slideInterval) STATE.slideshow.slideInterval.stop(); + SlideshowManager.clearSlideTimers(); + destroyAllPlayers(); container.querySelectorAll(".slide").forEach((slide) => slide.remove()); STATE.slideshow.createdSlides = {}; + STATE.slideshow.slideVideoIds = {}; PageBackdrop.clear(); } @@ -1147,42 +1745,26 @@ const VisibilityObserver = { target.closest(".pageTabButton") || target.closest(".navMenuOption") ) { - VisibilityObserver.updateVisibility(); + VisibilityObserver.scheduleUpdate(); } }, init() { - const observer = new MutationObserver(this.updateVisibility.bind(this)); + const observer = new MutationObserver(() => this.scheduleUpdate()); observer.observe(document.body, { childList: true, subtree: true }); document.body.addEventListener("click", this.handleClick.bind(this)); - window.addEventListener("hashchange", this.updateVisibility.bind(this)); + window.addEventListener("hashchange", () => this.scheduleUpdate()); this.updateVisibility(); }, }; -/** - * Mirrors the featured slide into Jellyfin's own page backdrop layer, so the - * background behind the home page follows whatever the slideshow is showing - * instead of staying on unrelated library art. - * - * Reuses the exact backdrop URL the active slide already requested, so the - * browser serves it from cache and no extra image is fetched. - * - * Only active while the slideshow is visible. Leaving the home page restores - * Jellyfin's normal backdrop behaviour, so no other page is affected. - * Disable with CONFIG.syncPageBackdrop. - */ const PageBackdrop = { LAYER_CLASS: "slideshow-page-backdrop", observer: null, isWriting: false, currentItemId: null, - /** - * Jellyfin creates .backdropContainer lazily, so it may not exist yet - * @returns {HTMLElement} The backdrop container - */ getOrCreateContainer() { let container = document.querySelector(".backdropContainer"); if (!container) { @@ -1194,10 +1776,6 @@ const PageBackdrop = { return container; }, - /** - * @param {HTMLElement} container - The backdrop container - * @returns {HTMLElement} Our own backdrop layer, created if needed - */ getOrCreateLayer(container) { let layer = container.querySelector(`.${this.LAYER_CLASS}`); if (!layer || !layer.isConnected) { @@ -1209,14 +1787,7 @@ const PageBackdrop = { return layer; }, - /** - * Jellyfin runs its own backdrop rotator into the same container: it adds - * .backdropImage siblings and cycles them on a timer, which paints over - * whatever we set. While the slideshow owns the screen, keep our layer the - * only one and make sure it stays last. - * @param {HTMLElement} container - The backdrop container - */ - evictForeignLayers(container) { + removeRotatorLayers(container) { const layers = container.querySelectorAll(".backdropImage"); layers.forEach((layer) => { if (!layer.classList.contains(this.LAYER_CLASS)) { @@ -1224,25 +1795,22 @@ const PageBackdrop = { } }); - const ours = container.querySelector(`.${this.LAYER_CLASS}`); - if (ours && ours !== container.lastElementChild) { - container.appendChild(ours); + const slideshowLayer = container.querySelector(`.${this.LAYER_CLASS}`); + if (slideshowLayer && slideshowLayer !== container.lastElementChild) { + container.appendChild(slideshowLayer); } }, - /** - * Reacts to the rotator instead of polling for it - */ startObserver(container) { if (this.observer) return; this.observer = new MutationObserver(() => { - // Our own eviction mutates the container, which would re-enter here + if (this.isWriting) return; this.isWriting = true; try { - this.evictForeignLayers(container); + this.removeRotatorLayers(container); } finally { this.isWriting = false; } @@ -1257,16 +1825,11 @@ const PageBackdrop = { this.observer = null; }, - /** - * Points the page backdrop at the given item - * @param {string} itemId - Id of the item the slideshow is showing - */ update(itemId) { if (!CONFIG.syncPageBackdrop) return; const item = STATE.slideshow.loadedItems[itemId]; - // Item data arrives with the slide. Until it does, leave the previous - // backdrop up rather than clearing it, so the page does not flash. + if (!item) return; const src = SlideCreator.buildImageUrl( @@ -1287,13 +1850,13 @@ const PageBackdrop = { if (this.currentItemId !== itemId || layer.style.backgroundImage === "") { layer.style.backgroundImage = `url("${src.replace(/"/g, "%22")}")`; layer.classList.remove("backdropImageFadeIn"); - // Force a reflow so the fade replays for the new image + void layer.offsetWidth; layer.classList.add("backdropImageFadeIn"); this.currentItemId = itemId; } - this.evictForeignLayers(container); + this.removeRotatorLayers(container); } finally { this.isWriting = false; } @@ -1304,33 +1867,22 @@ const PageBackdrop = { this.startObserver(container); }, - /** - * Hands the backdrop back to Jellyfin - */ clear() { this.stopObserver(); this.currentItemId = null; - document.querySelector(`.${this.LAYER_CLASS}`)?.remove(); + const slideshowLayer = document.querySelector(`.${this.LAYER_CLASS}`); + if (!slideshowLayer) return; + + slideshowLayer.remove(); document .querySelector(".backgroundContainer") ?.classList.remove("withBackdrop"); }, }; -/** - * Slideshow UI creation and management - */ const SlideCreator = { - /** - * Builds a tag-based image URL for cache-friendly image requests - * @param {Object} item - Item data containing ImageTags - * @param {string} imageType - Image type (Backdrop, Logo, Primary, etc.) - * @param {number} [index] - Image index (for Backdrop, Primary, etc.) - * @param {string} serverAddress - Server address - * @param {number} [quality] - Image quality (0-100). If tag is available, both tag and quality are used. - * @returns {string} Image URL with tag parameter (and quality if tag available), or quality-only fallback - */ + buildImageUrl(item, imageType, index, serverAddress, quality) { const itemId = item.Id; let tag = null; @@ -1371,21 +1923,6 @@ const SlideCreator = { } }, - /** - * Picks the YouTube video id of the most appropriate trailer for an item. - * - * RemoteTrailers arrives in metadata provider order, which is not preference - * order. The first entry is frequently a promo clip, a teaser or an - * accessibility variant rather than the main trailer, and it is not - * guaranteed to be a YouTube link at all. Entries are ranked by name, with - * provider order breaking ties, and any entry that does not yield a video id - * is skipped instead of losing the trailer for that slide. Alternate cuts - * are demoted within their tier: they lose to an ordinary entry of the same - * tier but still beat lower tier promo clips. - * - * @param {Array} remoteTrailers - RemoteTrailers array from the item - * @returns {string|null} YouTube video id, or null if no entry is usable - */ selectTrailerVideoId(remoteTrailers) { if (!Array.isArray(remoteTrailers) || remoteTrailers.length === 0) { return null; @@ -1438,12 +1975,6 @@ const SlideCreator = { return best ? best.videoId : null; }, - /** - * Creates a slide element for an item - * @param {Object} item - Item data - * @param {string} title - Title type (Movie/TV Show) - * @returns {Object} Object containing slide, videoId, and container - */ createSlideElement(item, title) { if (!item || !item.Id) { console.error("Invalid item data:", item); @@ -1459,12 +1990,18 @@ const SlideCreator = { }); let videoId = null; + let hasLocalTrailer = false; let trailerContainer = null; - if (CONFIG.enableTrailers) { - videoId = this.selectTrailerVideoId(item.RemoteTrailers); + if (CONFIG.enableTrailers && !isTouchLayout() && !isDataSaverOn()) { - if (videoId) { + hasLocalTrailer = + CONFIG.preferLocalTrailers && (item.LocalTrailerCount || 0) > 0; + videoId = hasLocalTrailer + ? null + : this.selectTrailerVideoId(item.RemoteTrailers); + + if (hasLocalTrailer || videoId) { trailerContainer = SlideUtils.createElement("div", { className: "video-container", id: `trailer-${item.Id}`, @@ -1480,10 +2017,21 @@ const SlideCreator = { } } + if (isPlateLayout()) { + const plate = BlurHash.plateColours(item); + slide.style.setProperty("--plate", plate?.plate || CONFIG.plateFallback); + slide.style.setProperty( + "--plate-accent", + plate?.accent || CONFIG.plateAccentFallback, + ); + } + const backdrop = SlideUtils.createElement("img", { className: "backdrop high-quality", src: this.buildImageUrl(item, "Backdrop", 0, serverAddress, 60), - alt: LocalizationUtils.getLocalizedString("Backdrop", "Backdrop"), + alt: "", + + "aria-hidden": "true", loading: "eager", }); @@ -1494,6 +2042,31 @@ const SlideCreator = { const backdropContainer = SlideUtils.createElement("div", { className: "backdrop-container", }); + + if (CONFIG.useBlurHashPlaceholder) { + const backdropTag = + item.BackdropImageTags?.[0] || item.ImageTags?.Backdrop; + const placeholder = BlurHash.toDataURL( + BlurHash.forItem(item, "Backdrop", backdropTag), + ); + if (placeholder) { + + backdrop.style.backgroundImage = `url("${placeholder}")`; + backdrop.classList.add("awaiting-image"); + const reveal = () => backdrop.classList.remove("awaiting-image"); + if (backdrop.complete) reveal(); + else { + backdrop.addEventListener("load", reveal, { once: true }); + + backdrop.addEventListener( + "error", + () => backdrop.classList.add("image-failed"), + { once: true }, + ); + } + } + } + backdropContainer.append(backdrop, backdropOverlay); const logo = SlideUtils.createElement("img", { @@ -1516,15 +2089,11 @@ const SlideCreator = { title, ); - const plot = item.Overview || "No overview available"; const plotElement = SlideUtils.createElement( "div", - { - className: "plot", - }, - plot, + { className: "plot" }, + item.Overview || "", ); - SlideUtils.truncateText(plotElement, CONFIG.maxPlotLength); const plotContainer = SlideUtils.createElement("div", { className: "plot-container", @@ -1544,8 +2113,10 @@ const SlideCreator = { const genreElement = SlideUtils.createElement("div", { className: "genre", - innerHTML: SlideUtils.parseGenres(item.Genres), }); + const genres = SlideUtils.buildGenres(item.Genres); + if (genres) genreElement.appendChild(genres); + else genreElement.style.display = "none"; const buttonContainer = SlideUtils.createElement("div", { className: "button-container", @@ -1554,27 +2125,233 @@ const SlideCreator = { const playButton = this.createPlayButton(itemId); const detailButton = this.createDetailButton(itemId); const favoriteButton = this.createFavoriteButton(item); - buttonContainer.append(detailButton, playButton, favoriteButton); - slide.append( - logoContainer, - backdropContainer, - gradientOverlay, - featuredContent, - plotContainer, - infoContainer, - genreElement, - buttonContainer, - ); + if (isMarqueeLayout() || isPlateLayout()) { + buttonContainer.append(playButton, detailButton, favoriteButton); + } else { + buttonContainer.append(detailButton, playButton, favoriteButton); + } - return { slide, videoId, trailerContainer }; + const content = SlideUtils.createElement("div", { + className: "slide-content", + }); + + if (isMarqueeLayout()) { + + const rail = SlideUtils.createElement("div", { + className: "spec-rail", + }); + rail.appendChild( + SlideUtils.createElement("span", { className: "spec-progress" }), + ); + + content.append( + logoContainer, + rail, + this.createSpecLine(item), + plotContainer, + buttonContainer, + ); + } else if (isPlateLayout()) { + + const position = STATE.slideshow.itemIds.indexOf(itemId); + const eyebrow = SlideUtils.createElement( + "div", + { className: "plate-eyebrow" }, + position >= 0 && STATE.slideshow.totalItems + ? `${LocalizationUtils.getLocalizedString("Featured", "Featured")} · ${String(position + 1).padStart(2, "0")} / ${String(STATE.slideshow.totalItems).padStart(2, "0")}` + : LocalizationUtils.getLocalizedString("Featured", "Featured"), + ); + + const specs = this.createSpecTable(item); + content.append(eyebrow, logoContainer, plotContainer); + if (specs) content.appendChild(specs); + content.appendChild(buttonContainer); + } else { + content.append( + logoContainer, + infoContainer, + genreElement, + plotContainer, + buttonContainer, + ); + } + + slide.append(backdropContainer); + + if (isPlateLayout()) { + slide.appendChild(SlideUtils.createElement("div", { className: "plate" })); + } + + slide.append(gradientOverlay, featuredContent, content); + + return { slide, videoId, hasLocalTrailer, trailerContainer }; + }, + + createSpecTable(item) { + const table = SlideUtils.createElement("dl", { className: "plate-specs" }); + + const rows = []; + const add = (label, value) => { + if (value !== null && value !== undefined && value !== "") { + rows.push([label, value]); + } + }; + + if (typeof item.CommunityRating === "number") { + add( + LocalizationUtils.getLocalizedString("Rating", "Rating"), + item.CommunityRating.toFixed(1), + ); + } + if (typeof item.CriticRating === "number") { + add( + LocalizationUtils.getLocalizedString("Critics", "Critics"), + `${item.CriticRating.toFixed(0)}%`, + ); + } + if (typeof item.PremiereDate === "string") { + const year = new Date(item.PremiereDate).getFullYear(); + if (!Number.isNaN(year)) { + add(LocalizationUtils.getLocalizedString("Year", "Year"), String(year)); + } + } + + if (item.ChildCount) { + const seasonText = + item.ChildCount <= 1 + ? LocalizationUtils.getLocalizedString("Season", "Season") + : LocalizationUtils.getLocalizedString( + "TypeOptionPluralSeason", + "Seasons", + ); + add(seasonText, String(item.ChildCount)); + } else if (typeof item.RunTimeTicks === "number" && item.RunTimeTicks > 0) { + const totalMinutes = Math.round(item.RunTimeTicks / 600000000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + add( + LocalizationUtils.getLocalizedString("Runtime", "Runtime"), + hours ? `${hours}h ${minutes}m` : `${minutes}m`, + ); + } + + if (Array.isArray(item.Genres) && item.Genres.length) { + add( + LocalizationUtils.getLocalizedString("Genres", "Genre"), + item.Genres.slice(0, 2).join(", "), + ); + } + if (typeof item.OfficialRating === "string" && item.OfficialRating) { + add( + LocalizationUtils.getLocalizedString("ParentalRating", "Rated"), + item.OfficialRating, + ); + } + + rows.length = Math.min(rows.length, CONFIG.plateSpecRows); + + rows.forEach(([label, value], index) => { + const row = SlideUtils.createElement("div", { + className: index === rows.length - 1 ? "spec-row is-last" : "spec-row", + }); + row.appendChild( + SlideUtils.createElement("dt", { className: "spec-label" }, label), + ); + row.appendChild( + SlideUtils.createElement("dd", { className: "spec-value" }, value), + ); + table.appendChild(row); + }); + + for (let i = rows.length; i < CONFIG.plateSpecRows; i++) { + const filler = SlideUtils.createElement("div", { + className: "spec-row is-placeholder", + "aria-hidden": "true", + }); + filler.appendChild( + SlideUtils.createElement("dt", { className: "spec-label" }, " "), + ); + filler.appendChild( + SlideUtils.createElement("dd", { className: "spec-value" }, " "), + ); + table.appendChild(filler); + } + + return table; + }, + + createSpecLine(item) { + const line = SlideUtils.createElement("div", { className: "spec-line" }); + + const parts = []; + const push = (kind, value, html) => { + if (value) parts.push([kind, value, Boolean(html)]); + }; + + if (typeof item.PremiereDate === "string") { + const year = new Date(item.PremiereDate).getFullYear(); + if (!Number.isNaN(year)) push("year", String(year)); + } + if (typeof item.CommunityRating === "number") { + push( + "rating", + `` + + item.CommunityRating.toFixed(1), + true, + ); + } + if (typeof item.CriticRating === "number") { + + const tomato = + item.CriticRating < 60 + ? CONFIG.IMAGE_SVG.rottenTomato + : CONFIG.IMAGE_SVG.freshTomato; + push("critics", `${tomato}${item.CriticRating.toFixed(0)}%`, true); + } + + if (item.ChildCount) { + const seasonText = + item.ChildCount <= 1 + ? LocalizationUtils.getLocalizedString("Season", "Season") + : LocalizationUtils.getLocalizedString( + "TypeOptionPluralSeason", + "Seasons", + ); + push("runtime", `${item.ChildCount} ${seasonText}`); + } else if (typeof item.RunTimeTicks === "number" && item.RunTimeTicks > 0) { + + const totalMinutes = Math.round(item.RunTimeTicks / 600000000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + push("runtime", hours ? `${hours}h ${minutes}m` : `${minutes}m`); + } + + if (Array.isArray(item.Genres) && item.Genres.length) { + push("genre", item.Genres.slice(0, 2).join(" · ")); + } + + parts.forEach(([kind, text, isHtml]) => { + const attrs = { className: `spec-item spec-${kind}` }; + if (isHtml) attrs.innerHTML = text; + line.appendChild( + SlideUtils.createElement("span", attrs, isHtml ? null : text), + ); + }); + + if (typeof item.OfficialRating === "string" && item.OfficialRating) { + line.appendChild( + SlideUtils.createElement( + "span", + { className: "spec-cert" }, + item.OfficialRating, + ), + ); + } + + return line; }, - /** - * Creates the rating information element - * @param {Object} item - Item data - * @returns {HTMLElement} Rating information element - */ createRatingInfo(item) { const { CommunityRating: communityRating, @@ -1589,15 +2366,17 @@ const SlideCreator = { className: "misc-info", }); + const fields = []; + if (typeof communityRating === "number") { - const container = SlideUtils.createElement("div", { - className: "star-rating-container", - innerHTML: `${communityRating.toFixed( - 1, - )}`, - }); - miscInfo.appendChild(container); - miscInfo.appendChild(SlideUtils.createSeparator()); + fields.push( + SlideUtils.createElement("div", { + className: "star-rating-container", + innerHTML: `${communityRating.toFixed( + 1, + )}`, + }), + ); } if (typeof criticRating === "number") { @@ -1605,72 +2384,84 @@ const SlideCreator = { criticRating < 60 ? CONFIG.IMAGE_SVG.rottenTomato : CONFIG.IMAGE_SVG.freshTomato; - const container = SlideUtils.createElement("div", { - className: "critic-rating", - innerHTML: `${svgIcon}${criticRating.toFixed(0)}%`, - }); - miscInfo.appendChild(container); - miscInfo.appendChild(SlideUtils.createSeparator()); + fields.push( + SlideUtils.createElement("div", { + className: "critic-rating", + innerHTML: `${svgIcon}${criticRating.toFixed(0)}%`, + }), + ); } - if (typeof premiereDate === "string" && !isNaN(new Date(premiereDate))) { - const container = SlideUtils.createElement("div", { - className: "date", - innerHTML: new Date(premiereDate).getFullYear(), - }); - miscInfo.appendChild(container); - miscInfo.appendChild(SlideUtils.createSeparator()); - } - - if (typeof ageRating === "string") { - const container = SlideUtils.createElement("div", { - className: "age-rating mediaInfoOfficialRating", - rating: ageRating, - ariaLabel: `Content rated ${ageRating}`, - title: `Rating: ${ageRating}`, - innerHTML: ageRating, - }); - miscInfo.appendChild(container); - miscInfo.appendChild(SlideUtils.createSeparator()); - } - - if (seasonCount !== undefined || runtime !== undefined) { - const container = SlideUtils.createElement("div", { - className: "runTime", - }); - if (seasonCount) { - const seasonText = - seasonCount <= 1 - ? LocalizationUtils.getLocalizedString("Season", "Season") - : LocalizationUtils.getLocalizedString( - "TypeOptionPluralSeason", - "Seasons", - ); - container.innerHTML = `${seasonCount} ${seasonText}`; - } else { - const milliseconds = runtime / 10000; - const currentTime = new Date(); - const endTime = new Date(currentTime.getTime() + milliseconds); - const options = { hour: "2-digit", minute: "2-digit" }; - const formattedEndTime = endTime.toLocaleTimeString([], options); - const endsAtText = LocalizationUtils.getLocalizedString( - "EndsAtValue", - "Ends at {0}", - formattedEndTime, + if (typeof premiereDate === "string") { + const year = new Date(premiereDate).getFullYear(); + if (!Number.isNaN(year)) { + fields.push( + SlideUtils.createElement( + "div", + { className: "date" }, + String(year), + ), ); - container.innerText = endsAtText; } - miscInfo.appendChild(container); } + if (typeof ageRating === "string" && ageRating) { + fields.push( + SlideUtils.createElement( + "div", + { + className: "age-rating mediaInfoOfficialRating", + + "aria-label": `Content rated ${ageRating}`, + title: `Rating: ${ageRating}`, + }, + ageRating, + ), + ); + } + + if (seasonCount) { + const seasonText = + seasonCount <= 1 + ? LocalizationUtils.getLocalizedString("Season", "Season") + : LocalizationUtils.getLocalizedString( + "TypeOptionPluralSeason", + "Seasons", + ); + fields.push( + SlideUtils.createElement( + "div", + { className: "runTime" }, + `${seasonCount} ${seasonText}`, + ), + ); + } else if (typeof runtime === "number" && runtime > 0) { + const endTime = new Date(Date.now() + runtime / 10000); + const formattedEndTime = endTime.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + fields.push( + SlideUtils.createElement( + "div", + { className: "runTime" }, + LocalizationUtils.getLocalizedString( + "EndsAtValue", + "Ends at {0}", + formattedEndTime, + ), + ), + ); + } + + fields.forEach((field, index) => { + if (index > 0) miscInfo.appendChild(SlideUtils.createSeparator()); + miscInfo.appendChild(field); + }); + return miscInfo; }, - /** - * Creates a play button for an item - * @param {string} itemId - Item ID - * @returns {HTMLElement} Play button element - */ createPlayButton(itemId) { const playText = LocalizationUtils.getLocalizedString("Play", "Play"); return SlideUtils.createElement("button", { @@ -1687,11 +2478,6 @@ const SlideCreator = { }); }, - /** - * Creates a detail button for an item - * @param {string} itemId - Item ID - * @returns {HTMLElement} Detail button element - */ createDetailButton(itemId) { return SlideUtils.createElement("button", { className: "detailButton detail-button", @@ -1710,12 +2496,6 @@ const SlideCreator = { }); }, - /** - * Creates a favorite button for an item - * @param {string} itemId - Item ID - * @returns {HTMLElement} Favorite button element - */ - createFavoriteButton(item) { const isFavorite = item.UserData && item.UserData.IsFavorite === true; @@ -1732,38 +2512,6 @@ const SlideCreator = { return button; }, - /** - * Creates a placeholder slide for loading - * @param {string} itemId - Item ID to load - * @returns {HTMLElement} Placeholder slide element - */ - createLoadingPlaceholder(itemId) { - const placeholder = SlideUtils.createElement("a", { - className: "slide placeholder", - "data-item-id": itemId, - style: { - display: "none", - opacity: "0", - transition: `opacity ${CONFIG.fadeTransitionDuration}ms ease-in-out`, - }, - }); - - const loadingIndicator = SlideUtils.createLoadingIndicator(); - placeholder.appendChild(loadingIndicator); - - return placeholder; - }, - - /** - * Creates a slide for an item and adds it to the container - * @param {string} itemId - Item ID - * @returns {Promise} Created slide element - */ - /** - * Creates a slide for an item and adds it to the container - * @param {string} itemId - Item ID - * @returns {Promise} Created slide element - */ async createSlideForItemId(itemId) { try { if (STATE.slideshow.createdSlides[itemId]) { @@ -1771,17 +2519,65 @@ const SlideCreator = { } const container = SlideUtils.getOrCreateSlidesContainer(); - const item = await ApiUtils.fetchItemDetails(itemId); - const { slide, videoId, trailerContainer } = this.createSlideElement( + const item = STATE.slideshow.loadedItems[itemId]; + if (!item || !item.Id) { + console.warn(`Slideshow: no item data for ${itemId}, skipping slide.`); + return null; + } + + const slideParts = this.createSlideElement( item, item.Type === "Movie" ? "Movie" : "TV Show", ); + if (!slideParts) return null; + + const { slide, videoId, hasLocalTrailer, trailerContainer } = slideParts; container.appendChild(slide); STATE.slideshow.createdSlides[itemId] = true; - if (videoId) { + STATE.slideshow.slideVideoIds[itemId] = videoId || null; + + if (hasLocalTrailer) { + const trailerUrl = await ApiUtils.fetchLocalTrailerUrl(itemId); + + if (trailerUrl && document.getElementById(`trailer-${itemId}`)) { + const video = SlideUtils.createElement("video", { + className: "local-trailer", + src: trailerUrl, + + muted: "", + playsinline: "", + preload: "metadata", + disablepictureinpicture: "", + tabIndex: "-1", + }); + video.muted = true; + + video.addEventListener("playing", () => + SlideshowManager.onTrailerPlaying(itemId, trailerContainer), + ); + video.addEventListener("ended", () => + SlideshowManager.onTrailerEnded(itemId, trailerContainer), + ); + video.addEventListener("error", () => + SlideshowManager.onPlayerError( + { data: "local" }, + itemId, + trailerContainer, + ), + ); + + const host = document.getElementById(`yt-player-${itemId}`); + host?.replaceChildren(video); + STATE.slideshow.players[itemId] = createLocalPlayer(video); + STATE.slideshow.slideVideoIds[itemId] = `local:${itemId}`; + } else { + + STATE.slideshow.slideVideoIds[itemId] = null; + } + } else if (videoId) { const startTime = await ApiUtils.getSkipSegments(videoId); loadYouTubeAPI().then((YT) => { @@ -1811,6 +2607,9 @@ const SlideCreator = { trailerContainer, ), onReady: (e) => e.target.mute(), + + onError: (e) => + SlideshowManager.onPlayerError(e, itemId, trailerContainer), }, }, ); @@ -1825,54 +2624,164 @@ const SlideCreator = { }, }; -/** - * Manages slideshow functionality - */ const SlideshowManager = { + createPaginationDots() { - let dotsContainer = document.querySelector(".dots-container"); - if (!dotsContainer) { - dotsContainer = document.createElement("div"); - dotsContainer.className = "dots-container"; - document.getElementById("slides-container").appendChild(dotsContainer); - } - - for (let i = 0; i < 5; i++) { - const dot = document.createElement("span"); - dot.className = "dot"; - dot.setAttribute("data-index", i); - dotsContainer.appendChild(dot); - } - this.updateDots(); - }, - - /** - * Updates active dot based on current slide - * Maps current slide to one of the 5 dots - */ - updateDots() { const container = SlideUtils.getOrCreateSlidesContainer(); - const dots = container.querySelectorAll(".dot"); - const currentIndex = STATE.slideshow.currentSlideIndex; - const totalItems = STATE.slideshow.totalItems; - const numDots = dots.length; - let activeDotIndex; + let dotsContainer = container.querySelector(".dots-container"); + if (!dotsContainer) { + dotsContainer = SlideUtils.createElement("div", { + className: "dots-container", + }); + container.appendChild(dotsContainer); + } - if (totalItems <= numDots) { - activeDotIndex = currentIndex; - } else { - activeDotIndex = Math.floor( - (currentIndex % numDots) * (numDots / numDots), + dotsContainer.replaceChildren(); + + const count = Math.min(STATE.slideshow.totalItems, CONFIG.maxDots); + + dotsContainer.style.display = count > 1 ? "" : "none"; + + for (let i = 0; i < count; i++) { + dotsContainer.appendChild( + SlideUtils.createElement("span", { className: "dot", "data-index": i }), ); } + this.updateDots(); + }, + + updateDots() { + const container = SlideUtils.getOrCreateSlidesContainer(); + const dots = container.querySelectorAll(".dot"); + const numDots = dots.length; + if (!numDots) return; + + const currentIndex = STATE.slideshow.currentSlideIndex; + const totalItems = STATE.slideshow.totalItems; + + const activeDotIndex = + totalItems <= numDots + ? currentIndex + : Math.min( + numDots - 1, + Math.floor((currentIndex / totalItems) * numDots), + ); + dots.forEach((dot, index) => { - if (index === activeDotIndex) { - dot.classList.add("active"); - } else { - dot.classList.remove("active"); - } + dot.classList.toggle("active", index === activeDotIndex); + }); + }, + + restartProgress(slide) { + if (!isMarqueeLayout()) return; + + const bar = slide?.querySelector(".spec-progress"); + if (!bar) return; + + bar.style.transition = "none"; + bar.style.width = "0%"; + void bar.offsetWidth; + + const running = + !STATE.slideshow.isPaused && + !STATE.slideshow.hoverHeld && + !STATE.slideshow.isVideoPlaying; + + if (!running) return; + + bar.style.transition = `width ${CONFIG.shuffleInterval}ms linear`; + bar.style.width = "100%"; + }, + + setProgressRunning(running) { + if (!isMarqueeLayout()) return; + + const bar = document.querySelector(".slide.active .spec-progress"); + if (!bar) return; + + if (!running) { + + const current = getComputedStyle(bar).width; + bar.style.transition = "none"; + bar.style.width = current; + return; + } + + const container = bar.parentElement; + if (!container) return; + const done = bar.getBoundingClientRect().width; + const total = container.getBoundingClientRect().width || 1; + const remaining = Math.max( + 0, + CONFIG.shuffleInterval * (1 - done / total), + ); + bar.style.transition = `width ${remaining}ms linear`; + bar.style.width = "100%"; + }, + + repositionChrome() { + const container = document.getElementById("slides-container"); + if (!container) return; + + const slide = + container.querySelector(".slide.active") || + container.querySelector(".slide"); + if (slide) this.positionDots(slide, container); + }, + + watchContentHeight(container) { + if (typeof ResizeObserver === "undefined") return; + + const content = container.querySelector(".slide.active .slide-content"); + if (!content) return; + + if (!this.contentObserver) { + this.contentObserver = new ResizeObserver(() => this.repositionChrome()); + } + this.contentObserver.disconnect(); + this.contentObserver.observe(content); + }, + + positionDots(slide, container) { + if (!isPlateLayout() && !isMarqueeLayout()) return; + + const dots = container.querySelector(".dots-container"); + const buttons = slide?.querySelector(".button-container"); + if (!dots || !buttons) return; + + const content = slide?.querySelector(".slide-content"); + const containerTop = container.getBoundingClientRect().top; + const anchor = Math.max( + buttons.getBoundingClientRect().bottom, + content ? content.getBoundingClientRect().bottom : 0, + ); + + const dotsHeight = dots.getBoundingClientRect().height || 6; + const rows = document.querySelector(".homeSectionsContainer"); + const rowsTop = rows + ? rows.getBoundingClientRect().top - containerTop + : Infinity; + + const anchorTop = anchor - containerTop; + const slack = rowsTop - anchorTop - dotsHeight; + const gap = Math.max( + 2, + Math.min( + Math.round(container.clientHeight * 0.022), + Number.isFinite(slack) ? Math.floor(slack / 2) : Infinity, + ), + ); + + const top = Math.round(anchorTop + gap); + dots.style.top = `${top}px`; + dots.style.bottom = "auto"; + + container.querySelectorAll(".arrow").forEach((arrow) => { + arrow.style.top = `${top - Math.round(arrow.offsetHeight / 2) + 3}px`; + arrow.style.bottom = "auto"; + arrow.style.transform = "none"; }); }, @@ -1899,108 +2808,88 @@ const SlideshowManager = { } }, - /** - * Updates current slide to the specified index - * @param {number} index - Slide index to display - */ - async updateCurrentSlide(index) { if (STATE.slideshow.isTransitioning) return; if (STATE.slideshow.slideInterval) STATE.slideshow.slideInterval.stop(); - - const prevItemId = - STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex]; - if (prevItemId && STATE.slideshow.players[prevItemId]) { - try { - if ( - typeof STATE.slideshow.players[prevItemId].pauseVideo === "function" - ) { - STATE.slideshow.players[prevItemId].pauseVideo(); - if ( - typeof STATE.slideshow.players[prevItemId].seekTo === "function" - ) { - STATE.slideshow.players[prevItemId].seekTo(0); - } - } - STATE.slideshow.isVideoPlaying = false; - - const prevSlide = document.querySelector( - `.slide[data-item-id="${prevItemId}"]`, - ); - if (prevSlide) { - prevSlide - .querySelector(".video-container") - ?.classList.remove("active"); - prevSlide.querySelector(".backdrop")?.classList.remove("with-video"); - prevSlide - .querySelector(".plot-container") - ?.classList.remove("with-video"); - } - } catch (e) {} - } + this.clearSlideTimers(); STATE.slideshow.isTransitioning = true; const container = SlideUtils.getOrCreateSlidesContainer(); index = Math.max(0, Math.min(index, STATE.slideshow.totalItems - 1)); const currentItemId = STATE.slideshow.itemIds[index]; + this.resetTrailerState(currentItemId); + let currentSlide = document.querySelector( `.slide[data-item-id="${currentItemId}"]`, ); if (!currentSlide) currentSlide = await SlideCreator.createSlideForItemId(currentItemId); + if (!currentSlide) { + console.warn(`Slideshow: could not build slide for ${currentItemId}.`); + STATE.slideshow.isTransitioning = false; + STATE.slideshow.currentSlideIndex = index; + if (!STATE.slideshow.isPaused && STATE.slideshow.slideInterval) { + STATE.slideshow.slideInterval.restart(); + } + return; + } + const prevVisible = container.querySelector(".slide.active"); if (prevVisible) prevVisible.classList.remove("active"); currentSlide.classList.add("active"); + if (!STATE.slideshow.firstSlideShown) { + STATE.slideshow.firstSlideShown = true; + mark("first-slide-visible"); + + const firstBackdrop = currentSlide.querySelector(".backdrop"); + if (firstBackdrop) { + if (firstBackdrop.complete) mark("first-backdrop-painted"); + else + firstBackdrop.addEventListener( + "load", + () => mark("first-backdrop-painted"), + { once: true }, + ); + } + } if (CONFIG.slideAnimationEnabled) { currentSlide.querySelector(".backdrop")?.classList.add("animate"); currentSlide.querySelector(".logo")?.classList.add("animate"); } STATE.slideshow.currentSlideIndex = index; + this.persistSessionIndex(index); + + if (isPlateLayout()) { + container.style.setProperty( + "--plate", + currentSlide.style.getPropertyValue("--plate") || CONFIG.plateFallback, + ); + container.style.setProperty( + "--plate-accent", + currentSlide.style.getPropertyValue("--plate-accent") || + CONFIG.plateAccentFallback, + ); + } + this.updateDots(); + this.positionDots(currentSlide, container); + this.restartProgress(currentSlide); PageBackdrop.update(currentItemId); this.preloadAdjacentSlides(index); this.pruneSlideCache(); - const itemData = STATE.slideshow.loadedItems[currentItemId]; - const hasTrailerData = - CONFIG.enableTrailers && - itemData && - itemData.RemoteTrailers && - itemData.RemoteTrailers.length > 0; + const videoId = STATE.slideshow.slideVideoIds[currentItemId]; + const canPlayTrailer = CONFIG.enableTrailers && Boolean(videoId); - if (hasTrailerData) { - setTimeout(() => { - if ( - STATE.slideshow.currentSlideIndex === index && - !STATE.slideshow.isPaused && - VisibilityObserver.wasVisible - ) { - const player = STATE.slideshow.players[currentItemId]; - - if (player && typeof player.playVideo === "function") { - try { - if (STATE.slideshow.isMuted) { - player.mute(); - } else { - player.unMute(); - player.setVolume(50); - } - - player.seekTo(0); - player.playVideo(); - } catch (e) { - fallbackToTimer(); - } - } else { - fallbackToTimer(); - } - } - }, 3500); + if (canPlayTrailer) { + STATE.slideshow.trailerStartTimer = setTimeout(() => { + this.startTrailer(index, currentItemId, fallbackToTimer); + }, CONFIG.trailerStartDelayMs); } else { fallbackToTimer(); } @@ -2011,48 +2900,116 @@ const SlideshowManager = { } } + const logoImage = currentSlide.querySelector(".logo"); + if (logoImage && !logoImage.complete) { + logoImage.addEventListener( + "load", + () => this.positionDots(currentSlide, container), + { once: true }, + ); + } + setTimeout(() => { STATE.slideshow.isTransitioning = false; + this.positionDots(currentSlide, container); + this.watchContentHeight(container); if (prevVisible && CONFIG.slideAnimationEnabled) { prevVisible.querySelector(".backdrop")?.classList.remove("animate"); prevVisible.querySelector(".logo")?.classList.remove("animate"); } }, CONFIG.fadeTransitionDuration); - setTimeout(() => { - if (STATE.slideshow.isTransitioning) { - console.warn("Forcing transition unlock"); - STATE.slideshow.isTransitioning = false; - } - }, 2000); }, - /** - * Upgrades the image quality for all images in a slide - * @param {HTMLElement} slide - The slide element containing images to upgrade - */ + startTrailer(index, itemId, resumeTimer) { - upgradeSlideImageQuality(slide) { - if (!slide) return; + if (STATE.slideshow.currentSlideIndex !== index) return; - const images = slide.querySelectorAll("img.low-quality"); - images.forEach((img) => { - const highQualityUrl = img.getAttribute("data-high-quality"); - if (highQualityUrl && img.src !== highQualityUrl) { - addThrottledRequest(highQualityUrl, () => { - img.src = highQualityUrl; - img.classList.remove("low-quality"); - img.classList.add("high-quality"); - }); + if (STATE.slideshow.isPaused || !VisibilityObserver.wasVisible) { + + return; + } + + const player = STATE.slideshow.players[itemId]; + if (!player || typeof player.playVideo !== "function") { + resumeTimer(); + return; + } + + try { + if (STATE.slideshow.isMuted) { + player.mute(); + } else { + player.unMute(); + player.setVolume(CONFIG.trailerVolume); } + player.seekTo(0); + player.playVideo(); + } catch (error) { + console.warn("Trailer failed to start:", error); + resumeTimer(); + return; + } + + clearTimeout(STATE.slideshow.trailerWatchdog); + STATE.slideshow.trailerWatchdog = setTimeout(() => { + if ( + STATE.slideshow.currentSlideIndex === index && + !STATE.slideshow.isVideoPlaying + ) { + console.warn( + `Trailer for ${itemId} did not start within ${CONFIG.trailerPlaybackWatchdogMs}ms; resuming slideshow.`, + ); + resumeTimer(); + } + }, CONFIG.trailerPlaybackWatchdogMs); + }, + + clearSlideTimers() { + clearTimeout(STATE.slideshow.trailerStartTimer); + clearTimeout(STATE.slideshow.trailerWatchdog); + STATE.slideshow.trailerStartTimer = null; + STATE.slideshow.trailerWatchdog = null; + }, + + resetTrailerState(keepItemId) { + STATE.slideshow.isVideoPlaying = false; + + if (keepItemId) { + const incoming = document.querySelector( + `.slide[data-item-id="${keepItemId}"]`, + ); + incoming?.querySelector(".video-container")?.classList.remove("active"); + incoming?.querySelector(".backdrop")?.classList.remove("with-video"); + incoming + ?.querySelector(".plot-container") + ?.classList.remove("with-video"); + } + + Object.keys(STATE.slideshow.players).forEach((itemId) => { + if (itemId === keepItemId) return; + const player = STATE.slideshow.players[itemId]; + try { + if (player && typeof player.pauseVideo === "function") { + player.pauseVideo(); + if (typeof player.seekTo === "function") player.seekTo(0); + } + } catch (error) { + + } + }); + + document.querySelectorAll("#slides-container .slide").forEach((slide) => { + if (slide.getAttribute("data-item-id") === keepItemId) return; + slide.querySelector(".video-container")?.classList.remove("active"); + slide.querySelector(".backdrop")?.classList.remove("with-video"); + slide.querySelector(".plot-container")?.classList.remove("with-video"); }); }, - /** - * Preloads adjacent slides for smoother transitions - * @param {number} currentIndex - Current slide index - */ async preloadAdjacentSlides(currentIndex) { const totalItems = STATE.slideshow.totalItems; + if (!totalItems) return; + const preloadCount = CONFIG.preloadCount; const nextIndex = (currentIndex + 1) % totalItems; @@ -2084,19 +3041,20 @@ const SlideshowManager = { this.updateCurrentSlide(prevIndex); }, - /** - * Prunes the slide cache to prevent memory bloat - * Removes slides that are outside the viewing range - */ pruneSlideCache() { const currentIndex = STATE.slideshow.currentSlideIndex; + const totalItems = STATE.slideshow.totalItems; const keepRange = 2; Object.keys(STATE.slideshow.createdSlides).forEach((itemId) => { const index = STATE.slideshow.itemIds.indexOf(itemId); if (index === -1) return; - const distance = Math.abs(index - currentIndex); + const linear = Math.abs(index - currentIndex); + const distance = totalItems + ? Math.min(linear, totalItems - linear) + : linear; + if (distance > keepRange) { if (STATE.slideshow.players[itemId]) { try { @@ -2109,57 +3067,164 @@ const SlideshowManager = { delete STATE.slideshow.players[itemId]; } - delete STATE.slideshow.loadedItems[itemId]; - const slide = document.querySelector( `.slide[data-item-id="${itemId}"]`, ); if (slide) slide.remove(); delete STATE.slideshow.createdSlides[itemId]; - - console.log(`Pruned slide ${itemId} at distance ${distance} from view`); + delete STATE.slideshow.slideVideoIds[itemId]; } }); }, togglePause() { STATE.slideshow.isPaused = !STATE.slideshow.isPaused; - const pauseButton = document.querySelector(".pause-button"); + + const paused = STATE.slideshow.isPaused; + + document + .getElementById("slides-container") + ?.classList.toggle("is-paused", paused); const currentId = STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex]; const player = STATE.slideshow.players[currentId]; - if (STATE.slideshow.isPaused) { - if (STATE.slideshow.slideInterval) STATE.slideshow.slideInterval.stop(); + if (paused) { + STATE.slideshow.slideInterval?.pause(); + this.clearSlideTimers(); + } else { + STATE.slideshow.slideInterval?.resume(); + } + this.setProgressRunning(!paused); - pauseButton.innerHTML = 'play_arrow'; - const playLabel = LocalizationUtils.getLocalizedString("Play", "Play"); - pauseButton.setAttribute("aria-label", playLabel); - pauseButton.setAttribute("title", playLabel); + const pauseButton = document.querySelector(".pause-button"); + if (pauseButton) { + const label = paused + ? LocalizationUtils.getLocalizedString("Play", "Play") + : LocalizationUtils.getLocalizedString("ButtonPause", "Pause"); + + pauseButton.innerHTML = ``; + pauseButton.setAttribute("aria-label", label); + pauseButton.setAttribute("title", label); + } + + if (paused) { if (player && typeof player.pauseVideo === "function") { player.pauseVideo(); } - } else { - if (STATE.slideshow.slideInterval) STATE.slideshow.slideInterval.start(); - pauseButton.innerHTML = 'pause'; - const pauseLabel = LocalizationUtils.getLocalizedString( - "ButtonPause", - "Pause", - ); - pauseButton.setAttribute("aria-label", pauseLabel); - pauseButton.setAttribute("title", pauseLabel); - - if (player && typeof player.playVideo === "function") { - player.playVideo(); - } + } else if (player && typeof player.playVideo === "function") { + player.playVideo(); } }, - /** - * Initializes touch events for swiping - */ + SESSION_ORDER_KEY: "slideshowpure_order", + + applySessionOrder(items) { + + if (!CONFIG.rememberOrderForSession) { + return SlideUtils.mixTypesInHead( + SlideUtils.shuffleArray(items), + CONFIG.mixTypesInFirst, + ); + } + + try { + const stored = JSON.parse( + sessionStorage.getItem(this.SESSION_ORDER_KEY) || "null", + ); + + if (Array.isArray(stored?.ids) && stored.ids.length) { + const byId = new Map(items.map((item) => [item.Id, item])); + const ordered = stored.ids + .map((id) => byId.get(id)) + .filter(Boolean); + + const known = new Set(ordered.map((item) => item.Id)); + const added = items.filter((item) => !known.has(item.Id)); + const result = ordered.concat(added); + + if (result.length) { + STATE.slideshow.resumeIndex = Math.min( + Math.max(0, stored.index || 0), + result.length - 1, + ); + this.persistSessionOrder(result, STATE.slideshow.resumeIndex); + return result; + } + } + } catch (error) { + + } + + const shuffled = SlideUtils.mixTypesInHead( + SlideUtils.shuffleArray(items), + CONFIG.mixTypesInFirst, + ); + this.persistSessionOrder(shuffled, 0); + return shuffled; + }, + + persistSessionOrder(items, index) { + if (!CONFIG.rememberOrderForSession) return; + try { + sessionStorage.setItem( + this.SESSION_ORDER_KEY, + JSON.stringify({ ids: items.map((item) => item.Id), index }), + ); + } catch (error) { + + } + }, + + persistSessionIndex(index) { + if (!CONFIG.rememberOrderForSession) return; + try { + const stored = JSON.parse( + sessionStorage.getItem(this.SESSION_ORDER_KEY) || "null", + ); + if (!stored?.ids) return; + stored.index = index; + sessionStorage.setItem(this.SESSION_ORDER_KEY, JSON.stringify(stored)); + } catch (error) { + + } + }, + + initHoverPause() { + if (!CONFIG.pauseOnHover) return; + + const container = SlideUtils.getOrCreateSlidesContainer(); + + container.addEventListener("pointerenter", (event) => { + if (event.pointerType === "touch") return; + STATE.slideshow.hoverHeld = true; + + STATE.slideshow.slideInterval?.pause(); + this.clearSlideTimers(); + this.setProgressRunning(false); + }); + + container.addEventListener("pointerleave", (event) => { + if (event.pointerType === "touch") return; + if (!STATE.slideshow.hoverHeld) return; + STATE.slideshow.hoverHeld = false; + + if ( + !STATE.slideshow.isPaused && + !STATE.slideshow.isVideoPlaying && + VisibilityObserver.wasVisible + ) { + STATE.slideshow.slideInterval?.resume(); + + this.setProgressRunning(true); + } + }); + }, + initTouchEvents() { const container = SlideUtils.getOrCreateSlidesContainer(); let touchStartX = 0; @@ -2183,11 +3248,6 @@ const SlideshowManager = { ); }, - /** - * Handles swipe gestures - * @param {number} startX - Starting X position - * @param {number} endX - Ending X position - */ handleSwipe(startX, endX) { const diff = endX - startX; @@ -2202,120 +3262,198 @@ const SlideshowManager = { } }, - /** - * Initializes keyboard event listeners - */ initKeyboardEvents() { - document.addEventListener("keydown", (e) => { - if (!STATE.slideshow.containerFocused) { + const container = SlideUtils.getOrCreateSlidesContainer(); + + container.setAttribute("tabindex", "-1"); + container.addEventListener("mousedown", () => { + container.focus({ preventScroll: true }); + }); + + document.addEventListener("focusin", (event) => { + STATE.slideshow.containerFocused = container.contains(event.target); + }); + document.addEventListener("focusout", (event) => { + if (!container.contains(event.relatedTarget)) { + STATE.slideshow.containerFocused = false; + } + }); + + document.addEventListener("keydown", (event) => { + if (!STATE.slideshow.containerFocused) return; + if (!VisibilityObserver.wasVisible) return; + + const active = document.activeElement; + if ( + active && + (active.tagName === "INPUT" || + active.tagName === "TEXTAREA" || + active.isContentEditable) + ) { return; } - switch (e.key) { - case "ArrowRight": - if (focusElement.classList.contains("detail-button")) { - focusElement.previousElementSibling.focus(); - } else { - SlideshowManager.nextSlide(); - } - e.preventDefault(); - break; + const buttonRow = container.querySelector(".slide.active .button-container"); + const focusedButton = + buttonRow && buttonRow.contains(active) ? active : null; - case "ArrowLeft": - if (focusElement.classList.contains("play-button")) { - focusElement.nextElementSibling.focus(); - } else { - SlideshowManager.prevSlide(); - } - e.preventDefault(); + switch (event.key) { + case "ArrowRight": { + const next = focusedButton?.nextElementSibling; + if (next) next.focus(); + else this.nextSlide(); + event.preventDefault(); break; + } + + case "ArrowLeft": { + const previous = focusedButton?.previousElementSibling; + if (previous) previous.focus(); + else this.prevSlide(); + event.preventDefault(); + break; + } case " ": this.togglePause(); - e.preventDefault(); + event.preventDefault(); break; case "Enter": - focusElement.click(); - e.preventDefault(); + + if (!focusedButton && active === container) { + container.querySelector(".slide.active .play-button")?.click(); + event.preventDefault(); + } + break; + + case "Escape": + container.blur(); + STATE.slideshow.containerFocused = false; break; } }); - - const container = SlideUtils.getOrCreateSlidesContainer(); - - container.addEventListener("focus", () => { - STATE.slideshow.containerFocused = true; - }); - - container.addEventListener("blur", () => { - STATE.slideshow.containerFocused = false; - }); }, - onPlayerStateChange(event, itemId, container) { + setTrailerVisible(itemId, trailerContainer, on) { const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`); - if (!slide) return; - const backdrop = slide.querySelector(".backdrop"); - const plotContainer = slide.querySelector(".plot-container"); + trailerContainer?.classList.toggle("active", on); + slide?.querySelector(".backdrop")?.classList.toggle("with-video", on); + slide?.querySelector(".plot-container")?.classList.toggle("with-video", on); + }, - if (event.data === YT.PlayerState.PLAYING) { - STATE.slideshow.isVideoPlaying = true; - if (container) container.classList.add("active"); - if (backdrop) backdrop.classList.add("with-video"); - if (plotContainer) plotContainer.classList.add("with-video"); - if (STATE.slideshow.slideInterval) STATE.slideshow.slideInterval.stop(); - } else if (event.data === YT.PlayerState.ENDED) { - STATE.slideshow.isVideoPlaying = false; - if (container) container.classList.remove("active"); - if (backdrop) backdrop.classList.remove("with-video"); - if (plotContainer) plotContainer.classList.remove("with-video"); - if (STATE.slideshow.slideInterval) - STATE.slideshow.slideInterval.restart(); - this.nextSlide(); + onTrailerPlaying(itemId, trailerContainer) { + + const currentItemId = + STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex]; + if (itemId !== currentItemId) { + try { + STATE.slideshow.players[itemId]?.pauseVideo(); + } catch (error) { + + } + this.setTrailerVisible(itemId, trailerContainer, false); + return; + } + + clearTimeout(STATE.slideshow.trailerWatchdog); + STATE.slideshow.trailerWatchdog = null; + + STATE.slideshow.isVideoPlaying = true; + this.setTrailerVisible(itemId, trailerContainer, true); + STATE.slideshow.slideInterval?.stop(); + }, + + onTrailerEnded(itemId, trailerContainer) { + STATE.slideshow.isVideoPlaying = false; + this.setTrailerVisible(itemId, trailerContainer, false); + this.nextSlide(); + }, + + onPlayerStateChange(event, itemId, trailerContainer) { + if (!document.querySelector(`.slide[data-item-id="${itemId}"]`)) return; + + const YTRef = window.YT; + if (!YTRef || !YTRef.PlayerState) return; + + if (event.data === YTRef.PlayerState.PLAYING) { + this.onTrailerPlaying(itemId, trailerContainer); + } else if (event.data === YTRef.PlayerState.ENDED) { + this.onTrailerEnded(itemId, trailerContainer); } else if ( - event.data === YT.PlayerState.PAUSED || - event.data === YT.PlayerState.CUED + event.data === YTRef.PlayerState.PAUSED || + event.data === YTRef.PlayerState.CUED ) { STATE.slideshow.isVideoPlaying = false; } }, - /** - * Loads slideshow data and initializes the slideshow - */ + onPlayerError(event, itemId, trailerContainer) { + console.warn( + `Trailer for ${itemId} failed with YouTube error ${event?.data}; continuing without it.`, + ); + + clearTimeout(STATE.slideshow.trailerWatchdog); + STATE.slideshow.trailerWatchdog = null; + STATE.slideshow.isVideoPlaying = false; + + const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`); + trailerContainer?.classList.remove("active"); + slide?.querySelector(".backdrop")?.classList.remove("with-video"); + slide?.querySelector(".plot-container")?.classList.remove("with-video"); + + STATE.slideshow.slideVideoIds[itemId] = null; + + if (!STATE.slideshow.isPaused && STATE.slideshow.slideInterval) { + STATE.slideshow.slideInterval.restart(); + } + }, + async loadSlideshowData() { try { STATE.slideshow.isLoading = true; - let itemIds = await ApiUtils.fetchItemIdsFromList(); + mark("items-fetch-start"); - if (itemIds.length === 0) { - itemIds = await ApiUtils.fetchItemIdsFromServer(); - } + const listedIds = await ApiUtils.fetchItemIdsFromList(); + let items = listedIds.length + ? await ApiUtils.fetchItemsByIds(listedIds) + : await ApiUtils.fetchItemsFromServer(); + mark("items-fetched"); - itemIds = SlideUtils.shuffleArray(itemIds); + items = this.applySessionOrder(items); + STATE.slideshow.loadedItems = {}; + items.forEach((item) => { + STATE.slideshow.loadedItems[item.Id] = item; + }); + + const itemIds = items.map((item) => item.Id); STATE.slideshow.itemIds = itemIds; STATE.slideshow.totalItems = itemIds.length; + if (!itemIds.length) { + console.warn( + "Slideshow: no items with a Logo image were returned; nothing to show.", + ); + SlideUtils.getOrCreateSlidesContainer().style.display = "none"; + return; + } + this.createPaginationDots(); STATE.slideshow.slideInterval = new SlideTimer(() => { - if (!STATE.slideshow.isPaused && !STATE.slideshow.isVideoPlaying) { + if ( + !STATE.slideshow.isPaused && + !STATE.slideshow.isVideoPlaying && + VisibilityObserver.wasVisible + ) { this.nextSlide(); } }, CONFIG.shuffleInterval); - STATE.slideshow.slideInterval.stop(); - await this.updateCurrentSlide(0); - - STATE.slideshow.slideInterval = new SlideTimer(() => { - if (!STATE.slideshow.isPaused && VisibilityObserver.wasVisible) { - this.nextSlide(); - } - }, CONFIG.shuffleInterval); + await this.updateCurrentSlide(STATE.slideshow.resumeIndex || 0); } catch (error) { console.error("Error loading slideshow data:", error); } finally { @@ -2324,46 +3462,419 @@ const SlideshowManager = { }, }; -/** - * Initializes arrow navigation elements - */ +const LayoutSync = { + attached: false, + published: {}, + + publish(name, value) { + if (this.published[name] === value) return false; + this.published[name] = value; + document.documentElement.style.setProperty(name, value); + return true; + }, + + update() { + const page = document.querySelector(".page"); + if (!page) return; + + const offset = Math.max( + 0, + Math.round( + page.getBoundingClientRect().top + document.scrollingElement.scrollTop, + ), + ); + + let moved = this.publish("--slideshow-page-offset", `${offset}px`); + + const rows = document.querySelector(".homeSectionsContainer"); + const stage = document.getElementById("slides-container"); + if (rows && stage) { + const rowsTop = Math.round( + rows.getBoundingClientRect().top - stage.getBoundingClientRect().top, + ); + if (rowsTop > 0) { + moved = this.publish("--slideshow-rows-top", `${rowsTop}px`) || moved; + } + } + + if (moved) SlideshowManager.repositionChrome(); + }, + + init() { + this.update(); + if (this.attached) return; + this.attached = true; + + window.addEventListener("resize", () => this.update(), { passive: true }); + window.addEventListener("hashchange", () => this.update()); + + document.fonts?.ready?.then(() => this.update()).catch(() => {}); + + const ready = () => + document.querySelector(".page") && + document.querySelector(".homeSectionsContainer"); + + if (ready()) { + this.observeRows(); + return; + } + + const observer = new MutationObserver(() => { + if (!ready()) return; + this.update(); + this.observeRows(); + observer.disconnect(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + setTimeout(() => observer.disconnect(), 30000); + }, + + observeRows() { + if (this.rowsObserver || typeof ResizeObserver === "undefined") return; + + const rows = document.querySelector(".homeSectionsContainer"); + if (!rows) return; + + this.rowsObserver = new ResizeObserver(() => this.update()); + this.rowsObserver.observe(rows); + + const page = document.querySelector(".page"); + if (page) this.rowsObserver.observe(page); + }, +}; + +const SettingsPanel = { + element: null, + controls: new Map(), + isOpen: false, + pausedByPanel: false, + + FIELDS: [ + { + key: "layout", + label: "Layout", + type: "choice", + options: [ + ["plate", "Plate"], + ["marquee", "Marquee"], + ["classic", "Classic"], + ], + }, + { + + key: "contentMode", + label: "Display", + type: "choice", + locks: ["maxMovies", "maxSeries"], + options: [ + ["all", "Everything"], + ["movies", "Movies"], + ["series", "TV shows"], + ["even", "Even mix"], + ], + read: () => { + const movies = CONFIG.maxMovies; + const series = CONFIG.maxSeries; + if (!movies && !series) return "all"; + if (movies && !series) return "movies"; + if (!movies && series) return "series"; + + return "even"; + }, + write: (value) => { + const total = CONFIG.maxItems; + const half = Math.max(1, Math.round(total / 2)); + if (value === "movies") return { maxMovies: total, maxSeries: 0 }; + if (value === "series") return { maxMovies: 0, maxSeries: total }; + if (value === "even") return { maxMovies: half, maxSeries: half }; + return { maxMovies: 0, maxSeries: 0 }; + }, + }, + { + key: "shuffleInterval", + label: "Slide duration", + type: "range", + min: 3000, + max: 60000, + step: 1000, + format: (value) => `${Math.round(value / 1000)}s`, + }, + { key: "slideAnimationEnabled", label: "Motion effects", type: "switch" }, + { key: "pauseOnHover", label: "Pause on hover", type: "switch" }, + { key: "enableTrailers", label: "Trailers", type: "switch" }, + { + key: "trailerVolume", + label: "Trailer volume", + type: "range", + min: 0, + max: 100, + step: 5, + format: (value) => `${value}%`, + }, + { + key: "rememberOrderForSession", + label: "Resume where I left off", + type: "switch", + }, + { key: "respectDataSaver", label: "Respect data saver", type: "switch" }, + ], + + build() { + if (this.element) return this.element; + + const panel = SlideUtils.createElement("div", { + className: "ss-settings", + role: "dialog", + "aria-label": "Slideshow settings", + "aria-hidden": "true", + }); + + const header = SlideUtils.createElement("div", { + className: "ss-settings-head", + innerHTML: "

Slideshow

", + }); + header.append( + SlideUtils.createElement("button", { + className: "ss-settings-close", + type: "button", + innerHTML: 'close', + "aria-label": "Close settings", + onclick: () => this.close(), + }), + ); + panel.append(header); + + const body = SlideUtils.createElement("div", { className: "ss-settings-body" }); + this.FIELDS.forEach((field) => body.append(this.buildRow(field))); + panel.append(body); + + const footer = SlideUtils.createElement("div", { className: "ss-settings-foot" }); + footer.append( + SlideUtils.createElement("button", { + className: "ss-settings-reset", + type: "button", + textContent: "Reset to server defaults", + onclick: () => this.reset(), + }), + ); + panel.append(footer); + + panel.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key === "Escape") this.close(); + }); + + document.body.append(panel); + this.element = panel; + return panel; + }, + + buildRow(field) { + const locked = (field.locks || [field.key]).some((key) => + lockedConfigKeys.has(key), + ); + const row = SlideUtils.createElement("div", { + className: `ss-set-row${locked ? " is-locked" : ""}`, + }); + const label = SlideUtils.createElement("span", { + className: "ss-set-label", + textContent: field.label, + }); + row.append(label); + + if (field.type === "switch") { + const input = SlideUtils.createElement("input", { + type: "checkbox", + className: "ss-switch", + disabled: locked, + onchange: (event) => this.commit(field.key, event.target.checked), + }); + row.append(input); + this.controls.set(field.key, input); + } + + if (field.type === "range") { + const readout = SlideUtils.createElement("span", { + className: "ss-set-value", + }); + const input = SlideUtils.createElement("input", { + type: "range", + className: "ss-range", + min: String(field.min), + max: String(field.max), + step: String(field.step), + disabled: locked, + + oninput: (event) => { + readout.textContent = field.format(Number(event.target.value)); + }, + onchange: (event) => this.commit(field.key, Number(event.target.value)), + }); + row.append(readout, input); + this.controls.set(field.key, input); + input.dataset.readout = "1"; + input._readout = readout; + input._format = field.format; + } + + if (field.type === "choice") { + const group = SlideUtils.createElement("div", { className: "ss-choice" }); + const buttons = field.options.map(([value, text]) => { + const button = SlideUtils.createElement("button", { + className: "ss-choice-btn", + type: "button", + textContent: text, + disabled: locked, + onclick: () => this.commit(field.key, value), + }); + button.dataset.value = value; + group.append(button); + return button; + }); + row.classList.add("is-stacked"); + row.append(group); + this.controls.set(field.key, { group, buttons }); + } + + if (locked) { + row.append( + SlideUtils.createElement("span", { + className: "ss-set-note", + textContent: "Set by your server administrator", + }), + ); + } + return row; + }, + + commit(key, value) { + const field = this.FIELDS.find((entry) => entry.key === key); + setConfig(field?.write ? field.write(value) : { [key]: value }); + + this.sync(); + }, + + sync() { + this.controls.forEach((control, key) => { + const field = this.FIELDS.find((entry) => entry.key === key); + const value = field?.read ? field.read() : CONFIG[key]; + + if (control instanceof HTMLInputElement) { + if (control.type === "checkbox") { + control.checked = Boolean(value); + } else { + control.value = String(value); + if (control._readout) { + control._readout.textContent = control._format(Number(value)); + } + } + return; + } + + control.buttons.forEach((button) => { + const active = button.dataset.value === String(value); + button.classList.toggle("is-active", active); + button.setAttribute("aria-pressed", active ? "true" : "false"); + }); + }); + + const volume = this.controls.get("trailerVolume"); + if (volume instanceof HTMLInputElement) { + const off = !CONFIG.enableTrailers; + volume.disabled = off || lockedConfigKeys.has("trailerVolume"); + volume.closest(".ss-set-row")?.classList.toggle("is-inactive", off); + } + }, + + reset() { + clearStoredConfig(); + applyConfig(CONFIG_SERVER_DEFAULTS, { trusted: true, label: "reset" }); + resetSlideshowState(); + bootstrap(); + this.sync(); + }, + + open() { + this.build(); + this.sync(); + this.isOpen = true; + this.element.classList.add("is-open"); + + document + .getElementById("slides-container") + ?.classList.add("settings-open"); + this.element.setAttribute("aria-hidden", "false"); + + if (!STATE.slideshow.isPaused) { + STATE.slideshow.slideInterval?.pause(); + this.pausedByPanel = true; + } + + this.element.querySelector(".ss-settings-close")?.focus(); + document.addEventListener("pointerdown", this.onOutsidePointer, true); + }, + + close() { + if (!this.element) return; + this.isOpen = false; + this.element.classList.remove("is-open"); + document + .getElementById("slides-container") + ?.classList.remove("settings-open"); + this.element.setAttribute("aria-hidden", "true"); + + if (this.pausedByPanel) { + if (!STATE.slideshow.isPaused) STATE.slideshow.slideInterval?.resume(); + this.pausedByPanel = false; + } + document.removeEventListener("pointerdown", this.onOutsidePointer, true); + document.querySelector(".ss-settings-toggle")?.focus(); + }, + + toggle() { + this.isOpen ? this.close() : this.open(); + }, + + onOutsidePointer: (event) => { + const panel = SettingsPanel.element; + if (!panel || !SettingsPanel.isOpen) return; + if (panel.contains(event.target)) return; + if (event.target.closest?.(".ss-settings-toggle")) return; + SettingsPanel.close(); + }, +}; + const initArrowNavigation = () => { const container = SlideUtils.getOrCreateSlidesContainer(); const leftArrow = SlideUtils.createElement("div", { className: "arrow left-arrow", - innerHTML: 'chevron_left', + innerHTML: '', + role: "button", tabIndex: "0", + "aria-label": LocalizationUtils.getLocalizedString("Previous", "Previous"), onclick: (e) => { e.preventDefault(); e.stopPropagation(); SlideshowManager.prevSlide(); }, - style: { - opacity: "0", - transition: "opacity 0.3s ease", - display: "none", - }, }); const rightArrow = SlideUtils.createElement("div", { className: "arrow right-arrow", - innerHTML: 'chevron_right', + innerHTML: '', + role: "button", tabIndex: "0", + "aria-label": LocalizationUtils.getLocalizedString("Next", "Next"), onclick: (e) => { e.preventDefault(); e.stopPropagation(); SlideshowManager.nextSlide(); }, - style: { - opacity: "0", - transition: "opacity 0.3s ease", - display: "none", - }, }); const volumeBtn = SlideUtils.createElement("div", { - className: "volume-toggle", + className: "volume-toggle ss-chrome", innerHTML: 'volume_off', onclick: (e) => { e.preventDefault(); @@ -2373,7 +3884,7 @@ const initArrowNavigation = () => { }); const pauseButton = SlideUtils.createElement("div", { - className: "pause-button", + className: "pause-button ss-chrome", innerHTML: 'pause', tabIndex: "0", "aria-label": LocalizationUtils.getLocalizedString("ButtonPause", "Pause"), @@ -2385,54 +3896,36 @@ const initArrowNavigation = () => { }, }); - container.append(leftArrow, rightArrow, volumeBtn, pauseButton); - - const showArrows = () => { - leftArrow.style.display = "block"; - rightArrow.style.display = "block"; - - void leftArrow.offsetWidth; - void rightArrow.offsetWidth; - - leftArrow.style.opacity = "1"; - rightArrow.style.opacity = "1"; - }; - - const hideArrows = () => { - leftArrow.style.opacity = "0"; - rightArrow.style.opacity = "0"; - - setTimeout(() => { - if (leftArrow.style.opacity === "0") { - leftArrow.style.display = "none"; - rightArrow.style.display = "none"; - } - }, 300); - }; - - container.addEventListener("mouseenter", showArrows); - - container.addEventListener("mouseleave", hideArrows); - - let arrowTimeout; - container.addEventListener( - "touchstart", - () => { - if (arrowTimeout) { - clearTimeout(arrowTimeout); - } - - showArrows(); - - arrowTimeout = setTimeout(hideArrows, 2000); + const settingsBtn = SlideUtils.createElement("div", { + className: "ss-settings-toggle ss-chrome", + innerHTML: 'settings', + role: "button", + tabIndex: "0", + "aria-label": "Slideshow settings", + title: "Slideshow settings", + onclick: (e) => { + e.preventDefault(); + e.stopPropagation(); + SettingsPanel.toggle(); }, - { passive: true }, - ); + onkeydown: (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + e.stopPropagation(); + SettingsPanel.toggle(); + }, + }); + + container.append(leftArrow, rightArrow, volumeBtn, pauseButton, settingsBtn); }; -/** - * Initialize the slideshow - */ +const isTouchLayout = () => + window.matchMedia(`(max-width: ${CONFIG.touchLayoutMaxWidth}px)`).matches; + +const isPlateLayout = () => CONFIG.layout === "plate"; + +const isMarqueeLayout = () => CONFIG.layout === "marquee"; + const slidesInit = async () => { if (STATE.slideshow.hasInitialized) { console.log("⚠️ Slideshow already initialized, skipping"); @@ -2440,94 +3933,25 @@ const slidesInit = async () => { } STATE.slideshow.hasInitialized = true; - /** - * Initialize IntersectionObserver for lazy loading images - */ - const initLazyLoading = () => { - const imageObserver = new IntersectionObserver( - (entries, observer) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - const image = entry.target; - const highQualityUrl = image.getAttribute("data-high-quality"); - - if ( - highQualityUrl && - image.closest(".slide").style.opacity === "1" - ) { - requestQueue.push({ - url: highQualityUrl, - callback: () => { - image.src = highQualityUrl; - image.classList.remove("low-quality"); - image.classList.add("high-quality"); - }, - }); - - if (requestQueue.length === 1) { - processNextRequest(); - } - } - - observer.unobserve(image); - } - }); - }, - { - rootMargin: "50px", - threshold: 0.1, - }, - ); - - const observeSlideImages = () => { - const slides = document.querySelectorAll(".slide"); - slides.forEach((slide) => { - const images = slide.querySelectorAll("img.low-quality"); - images.forEach((image) => { - imageObserver.observe(image); - }); - }); - }; - - const slideObserver = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.addedNodes) { - mutation.addedNodes.forEach((node) => { - if (node.classList && node.classList.contains("slide")) { - const images = node.querySelectorAll("img.low-quality"); - images.forEach((image) => { - imageObserver.observe(image); - }); - } - }); - } - }); - }); - - const container = SlideUtils.getOrCreateSlidesContainer(); - slideObserver.observe(container, { childList: true }); - - observeSlideImages(); - - return imageObserver; - }; - - const lazyLoadObserver = initLazyLoading(); - try { console.log("🌟 Initializing Enhanced Jellyfin Slideshow"); await SlideshowManager.loadSlideshowData(); + LayoutSync.init(); + SlideshowManager.initTouchEvents(); - - SlideshowManager.initKeyboardEvents(); - + SlideshowManager.initHoverPause(); initArrowNavigation(); - initPageVisibilityHandler(); - - VisibilityObserver.init(); + if (!STATE.slideshow.listenersAttached) { + STATE.slideshow.listenersAttached = true; + SlideshowManager.initKeyboardEvents(); + initPageVisibilityHandler(); + VisibilityObserver.init(); + } else { + VisibilityObserver.updateVisibility(); + } console.log("✅ Enhanced Jellyfin Slideshow initialized successfully"); } catch (error) { @@ -2536,9 +3960,6 @@ const slidesInit = async () => { } }; -/** - * Initialize page visibility handling to pause when tab is inactive - */ const initPageVisibilityHandler = () => { let wasVideoPlayingBeforeHide = false; @@ -2600,6 +4021,7 @@ window.slideshowPure = { STATE, SlideUtils, ApiUtils, + BlurHash, SlideCreator, SlideshowManager, VisibilityObserver, @@ -2613,8 +4035,13 @@ window.slideshowPure = { prevSlide: () => { SlideshowManager.prevSlide(); }, + + reset: () => resetSlideshowState(), + bootstrap: () => bootstrap(), + + setConfig, + clearConfig: clearStoredConfig, + applyConfig, + configRules: CONFIG_RULES, + settings: SettingsPanel, }; - -initLoadingScreen(); - -startLoginStatusWatcher(); From 03c4cc0ade9d9404d0c04a442c14e0b829b5dc25 Mon Sep 17 00:00:00 2001 From: MakD Date: Fri, 28 Aug 2026 15:06:57 +0530 Subject: [PATCH 2/6] Add multi-choice library selection and filter support in settings - Introduced multi-choice options for selecting libraries and trailer libraries in the settings panel. - Implemented functionality to parse and apply filters from a list, allowing dynamic item fetching based on specified criteria. - Updated relevant API calls to accommodate new filtering capabilities. --- README.md | 83 +++++++++-- slideshowpure.css | 44 ++++++ slideshowpure.js | 367 ++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 459 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index b4ee151..9972520 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Here I present my version with some code improvements, loading optimizations, an index.html - 1. Navigate to your `jellyfin-web` folder and search for the file index.html. (You can use any code editor — just remember to open it with administrator privileges.) + 1. Navigate to your `jellyfin-web` folder and search for the file index.html. (You can use any code editor, just remember to open it with administrator privileges.) 2. Search for `` 3. Just before the ``, plug the below code @@ -48,8 +48,8 @@ Here I present my version with some code improvements, loading optimizations, an rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo+Narrow:ital,wght@0,400..700;1,400..700&family=IBM+Plex+Mono:wght@400;600&display=swap" /> - - + + ``` @@ -57,11 +57,11 @@ Here I present my version with some code improvements, loading optimizations, an Upgrading from v5 -Replace your two old lines with the block above. Three things are different: +Swap your two old lines for the block above. Three things changed: -- **Fonts.** v6 sets its type in **Archivo Narrow** and **IBM Plex Mono**. The bar still works without the font links — it falls back to Noto Sans and your system monospace — but it will not look the way it is meant to. -- **`async` → `defer`.** `defer` waits for the page to finish parsing and keeps script order predictable; `async` fires whenever the download happens to land. -- **`@latest` → `@6.0.0`.** A pinned version cannot change under you when a new release goes out. Use `@6` instead if you would rather pick up 6.x updates automatically. +- **Fonts.** v6 uses Archivo Narrow and IBM Plex Mono. It still runs without the font links, it just falls back to Noto Sans and whatever your system uses for monospace, and it will not look right. +- **`async` became `defer`.** `defer` waits for the page to finish parsing and runs scripts in order. `async` fires whenever the download happens to land, which is less predictable. +- **`@latest` became `@6`.** `@latest` follows the newest tag, so a future v7 would land on your server without warning. `@6` keeps you on the 6.x line and still picks up fixes. If you want it frozen, pin an exact version like `@6.1.0`. @@ -74,11 +74,11 @@ No worries this got you covered. ## Steps 1. Create a `list.txt` file inside your `avatars` folder. -2. In line 1 give your list a name. -3. Starting line 2, paste the item IDs you want to be showcased, one ID per line. For Example : +2. Paste the item IDs you want to be showcased, one ID per line. +3. If you want to give the list a name, put a `#` in front of it so the bar skips that line. ``` -Awesome Playlist Name +# Awesome Playlist Name ItemID1 ItemID2 ItemID3 @@ -87,6 +87,69 @@ ItemID5 ``` The next time it loads, it will display these items. +## Filters + +Pasting IDs works, but the list goes stale the moment you add something new to your library. If you would rather it kept itself up to date, use a filter line instead of an ID: + +``` +# Christmas +tag:Christmas +genre:Family +``` + +Whatever matches gets pulled in when the page loads, so you set it up once and leave it alone. Handy for seasonal stuff you would otherwise have to rebuild every year. + +Keys you can use: + +| Key | Matches on | +| --- | --- | +| `genre:` | Genre | +| `tag:` | Tag | +| `studio:` | Studio | +| `year:` | Release year | +| `person:` | Anyone in the cast or crew | +| `rating:` | Official rating, so PG-13, TV-MA and friends | + +A few things worth knowing: + +- Commas mean "or". `genre:Action, Comedy` gets you both. +- Every line is its own filter and the results get added together. Two lines will not narrow each other down, so there is no way to ask for action films that are also from 2024. +- Feel free to mix filter lines and item IDs in the same file. +- Watch your spelling. If the bar does not recognise a line it skips it and tells you why in the browser console (F12), along with the keys it was expecting. +- If nothing at all matches, you get the usual random items rather than an empty bar. + +# Settings + +Click the gear on the bar and pick what you want. Whatever you choose is saved in your own browser, so everyone on the server can set it up how they like without stepping on each other. Nothing to install and no files to edit. + +Running the server and want to set the defaults for everybody, or stop people changing something? You can do that from `index.html`. + +
+ + Server-wide defaults + +Drop this in above the script tag you added earlier: + +```html + +``` + +It has to sit before the `