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(); },