From c1fa76f0468364acb2a2ca66613beb972dee4013 Mon Sep 17 00:00:00 2001 From: Russell Luther Date: Sat, 1 Aug 2026 12:48:55 -0400 Subject: [PATCH] Sync the page backdrop with the featured slide The background behind the home page is Jellyfin's own backdrop layer, and it has no connection to what the bar is showing. It sits on unrelated library art, and where the user has Jellyfin's backdrop rotator enabled it also cycles on its own timer, so the page behind the hero drifts while the hero itself is correct. PageBackdrop points that layer at the featured item and keeps it in step. It reuses the exact URL the active slide already requested through SlideCreator.buildImageUrl, so the browser serves the image from cache and no extra request is made. Two details worth calling out: Jellyfin's rotator adds its own .backdropImage siblings into the same container and cycles them, which paints over anything we set. While the bar owns the screen we keep our layer the only one and make sure it stays last. This is done with a MutationObserver on the container rather than a timer, so it reacts to the rotator instead of polling for it. It is scoped to the home page. VisibilityObserver already knows when the bar is hidden, so leaving the page hands the backdrop straight back to Jellyfin and no other page is affected. Defaults on, alongside slideAnimationEnabled and enableTrailers. Set CONFIG.syncPageBackdrop to false to leave the backdrop alone. --- slideshowpure.js | 161 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/slideshowpure.js b/slideshowpure.js index d99818a..10a20fa 100644 --- a/slideshowpure.js +++ b/slideshowpure.js @@ -36,6 +36,7 @@ const CONFIG = { "described audio", "vertical", ], + syncPageBackdrop: true, youtubeApiLoadTimeoutMs: 8000, }; @@ -1133,6 +1134,7 @@ const VisibilityObserver = { STATE.slideshow.players = {}; container.querySelectorAll(".slide").forEach((slide) => slide.remove()); STATE.slideshow.createdSlides = {}; + PageBackdrop.clear(); } this.wasVisible = isVisible; @@ -1159,6 +1161,163 @@ const VisibilityObserver = { }, }; +/** + * 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) { + container = SlideUtils.createElement("div", { + className: "backdropContainer", + }); + document.body.insertBefore(container, document.body.firstChild); + } + 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) { + layer = SlideUtils.createElement("div", { + className: `backdropImage ${this.LAYER_CLASS}`, + }); + container.appendChild(layer); + } + 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) { + const layers = container.querySelectorAll(".backdropImage"); + layers.forEach((layer) => { + if (!layer.classList.contains(this.LAYER_CLASS)) { + layer.remove(); + } + }); + + const ours = container.querySelector(`.${this.LAYER_CLASS}`); + if (ours && ours !== container.lastElementChild) { + container.appendChild(ours); + } + }, + + /** + * 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); + } finally { + this.isWriting = false; + } + }); + + this.observer.observe(container, { childList: true }); + }, + + stopObserver() { + if (!this.observer) return; + this.observer.disconnect(); + 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( + item, + "Backdrop", + 0, + STATE.jellyfinData.serverAddress, + 60, + ); + if (!src) return; + + const container = this.getOrCreateContainer(); + + this.isWriting = true; + try { + const layer = this.getOrCreateLayer(container); + + 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); + } finally { + this.isWriting = false; + } + + document + .querySelector(".backgroundContainer") + ?.classList.add("withBackdrop"); + this.startObserver(container); + }, + + /** + * Hands the backdrop back to Jellyfin + */ + clear() { + this.stopObserver(); + this.currentItemId = null; + + document.querySelector(`.${this.LAYER_CLASS}`)?.remove(); + document + .querySelector(".backgroundContainer") + ?.classList.remove("withBackdrop"); + }, +}; + /** * Slideshow UI creation and management */ @@ -1803,6 +1962,7 @@ const SlideshowManager = { STATE.slideshow.currentSlideIndex = index; this.updateDots(); + PageBackdrop.update(currentItemId); this.preloadAdjacentSlides(index); this.pruneSlideCache(); @@ -2443,6 +2603,7 @@ window.slideshowPure = { SlideCreator, SlideshowManager, VisibilityObserver, + PageBackdrop, initSlideshowData: () => { SlideshowManager.loadSlideshowData(); },