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.
This commit is contained in:
Russell Luther committed 2026-08-06 22:31:57 -04:00
1 parent b6a113ba45
commit c1fa76f046
1 file changed
+161
+161
View File
@@ -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();
},