/*
* Jellyfin Slideshow by M0RPH3US v6.1.3
*/
const CONFIG = {
IMAGE_SVG: {
freshTomato:
'image/svg+xml ',
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,
// 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,
// library display names the bar may draw from, e.g. ["Movies","4K Movies"]. Empty = all.
libraries: [],
// library display names allowed to autoplay trailers. Empty = wherever trailers are otherwise enabled.
trailerLibraries: [],
// 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,
// true|false. Also play trailers on the small/touch layout. Off by default; only useful if the phone layout has a landscape frame.
allowTrailersOnTouch: false,
// name fragments that demote a trailer (sign language, vertical crops, etc).
trailerAlternateCutTerms: [
"sign language",
"asl trailer",
"audio description",
"audio described",
"described audio",
"vertical",
],
// 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,
// true|false. Show the uploader's default captions on YouTube trailers. Off by default: the trailer fills the bar behind the home rows, so captions land on top of them.
trailerCaptions: false,
// 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,
};
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",
"libraries",
"trailerLibraries",
]);
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.includes("shuffleInterval") && STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.interval = CONFIG.shuffleInterval;
}
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,
appName: null,
appVersion: null,
deviceName: null,
deviceId: null,
accessToken: null,
serverAddress: null,
views: null,
},
slideshow: {
hasInitialized: false,
isBootstrapping: false,
signOutWatcherAttached: false,
listenersAttached: false,
isTransitioning: false,
isPaused: false,
currentSlideIndex: 0,
containerFocused: false,
slideInterval: null,
itemIds: [],
loadedItems: {},
createdSlides: {},
slideVideoIds: {},
trailerStartTimer: null,
trailerWatchdog: null,
trailerLibraryIds: null,
firstSlideShown: false,
hoverHeld: false,
resumeIndex: 0,
startedFromStoredCredentials: false,
credentialsRejected: false,
homeRenderWatched: false,
totalItems: 0,
isLoading: false,
players: {},
ytPromise: null,
isMuted: true,
isVideoPlaying: false,
},
};
// YouTube loads its caption module once playback starts, so this runs on ready and again on play.
const hideTrailerCaptions = (player) => {
if (CONFIG.trailerCaptions) return;
try {
player.unloadModule("captions");
player.unloadModule("cc");
} catch (err) {
// player not ready or no caption module: nothing to hide
}
};
const loadYouTubeAPI = () => {
if (STATE.slideshow.ytPromise) return STATE.slideshow.ytPromise;
STATE.slideshow.ytPromise = new Promise((resolve) => {
if (window.YT && window.YT.Player) {
resolve(window.YT);
return;
}
let timeout;
let settled = false;
const previousReady = window.onYouTubeIframeAPIReady;
const finish = (YT = null) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(YT && YT.Player ? YT : null);
};
window.onYouTubeIframeAPIReady = () => {
if (typeof previousReady === "function") {
previousReady();
}
finish(window.YT);
};
let tag = document.querySelector('script[src*="youtube.com/iframe_api"]');
if (!tag) {
tag = document.createElement("script");
tag.src = "https://www.youtube.com/iframe_api";
tag.async = true;
tag.onerror = () => {
console.warn(
"YouTube iframe API failed to load; continuing without trailers.",
);
finish();
};
const firstScriptTag = document.getElementsByTagName("script")[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
}
timeout = setTimeout(() => {
console.warn(
"Timed out loading YouTube iframe API; continuing without trailers.",
);
finish();
}, CONFIG.youtubeApiLoadTimeoutMs);
});
return STATE.slideshow.ytPromise;
};
const mark = (name) => {
try {
performance.mark(`sspure:${name}`);
} catch (error) {
}
};
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;
}
const observer = new MutationObserver(() => {
if (!alreadyRendered()) return;
mark("jellyfin-home-rows");
observer.disconnect();
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 30000);
};
const isUserLoggedIn = () => {
try {
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);
return false;
}
};
const hasUsableCredentials = () => {
try {
const apiClient = window.ApiClient;
return Boolean(
apiClient &&
typeof apiClient.accessToken === "function" &&
apiClient.accessToken() &&
apiClient.serverAddress() &&
apiClient.getCurrentUserId(),
);
} catch (error) {
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;
}
};
const initLocalization = async () => {
try {
const locale = await LocalizationUtils.getCurrentLocale();
await LocalizationUtils.loadTranslations(locale);
console.log("โ
Localization initialized");
} catch (error) {
console.error("Error initializing localization:", error);
}
};
const initLoadingScreen = () => {
if (!CONFIG.showLoadingScreen) return;
const currentPath = window.location.href.toLowerCase();
const isHomePage =
currentPath.includes("/web/#/home.html") ||
currentPath.includes("/web/#/home") ||
currentPath.includes("/web/index.html#/home.html") ||
currentPath.endsWith("/web/");
if (!isHomePage) return;
const loadingDiv = document.createElement("div");
loadingDiv.className = "bar-loading";
loadingDiv.id = "page-loader";
loadingDiv.innerHTML = `
`;
document.body.appendChild(loadingDiv);
requestAnimationFrame(() => {
document.querySelector(".bar-loading h1 div").style.opacity = "1";
});
const progressBar = document.getElementById("progress-bar");
const unfilledBar = document.getElementById("unfilled-bar");
let progress = 0;
let lastIncrement = 5;
const progressInterval = setInterval(() => {
if (progress < 95) {
lastIncrement = Math.max(0.5, lastIncrement * 0.98);
const randomFactor = 0.8 + Math.random() * 0.4;
const increment = lastIncrement * randomFactor;
progress += increment;
progress = Math.min(progress, 95);
progressBar.style.width = `${progress}%`;
unfilledBar.style.width = `${100 - progress}%`;
}
}, 150);
const checkInterval = setInterval(() => {
const loginFormLoaded = document.querySelector(".manualLoginForm");
const activeTab = document.querySelector(".pageTabContent.is-active");
if (loginFormLoaded) {
finishLoading();
return;
}
if (activeTab) {
const tabIndex = activeTab.getAttribute("data-index");
if (tabIndex === "0") {
const homeSections = document.querySelector(".homeSectionsContainer");
const slidesContainer = document.querySelector("#slides-container");
if (homeSections && slidesContainer) {
finishLoading();
}
} else {
if (
activeTab.children.length > 0 ||
activeTab.innerText.trim().length > 0
) {
finishLoading();
}
}
}
}, 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%";
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);
};
const resetSlideshowState = () => {
console.log("๐ Resetting slideshow state...");
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
destroyAllPlayers();
PageBackdrop.clear();
const container = document.getElementById("slides-container");
if (container) {
container.remove();
}
STATE.slideshow.hasInitialized = false;
STATE.slideshow.isTransitioning = false;
STATE.slideshow.isPaused = false;
STATE.slideshow.currentSlideIndex = 0;
STATE.slideshow.containerFocused = false;
STATE.slideshow.slideInterval = null;
STATE.slideshow.itemIds = [];
STATE.slideshow.loadedItems = {};
STATE.slideshow.createdSlides = {};
STATE.slideshow.totalItems = 0;
STATE.slideshow.isVideoPlaying = false;
STATE.slideshow.trailerLibraryIds = null;
STATE.jellyfinData.views = null;
};
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);
}
}
});
STATE.slideshow.players = {};
STATE.slideshow.isVideoPlaying = false;
};
const waitForSignIn = () => {
if (isUserLoggedIn()) return Promise.resolve();
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;
}
await initLocalization();
mark("localization-ready");
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();
}
} catch (error) {
console.warn("Slideshow: not starting โ", error.message);
} finally {
STATE.slideshow.isBootstrapping = false;
}
};
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();
const SlideUtils = {
shuffleArray(array) {
const newArray = [...array];
for (let i = newArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
}
return newArray;
},
mixTypesInHead(items, windowSize) {
if (!windowSize || items.length <= windowSize) return items;
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;
},
createSeparator() {
const separator = document.createElement("i");
separator.className = "material-icons fiber_manual_record separator-icon";
return separator;
},
createElement(tag, attributes = {}, content = null) {
const element = document.createElement(tag);
Object.entries(attributes).forEach(([key, value]) => {
if (key === "style" && typeof value === "object") {
Object.entries(value).forEach(([prop, val]) => {
element.style[prop] = val;
});
} else if (key === "className") {
element.className = value;
} else if (key === "innerHTML") {
element.innerHTML = 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);
}
});
if (content) {
if (typeof content === "string") {
element.textContent = content;
} else {
element.appendChild(content);
}
}
return 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;
},
buildGenres(genresArray) {
if (!Array.isArray(genresArray) || genresArray.length === 0) return null;
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 fragment;
},
};
const isDataSaverOn = () => {
if (!CONFIG.respectDataSaver) return false;
try {
return Boolean(navigator.connection?.saveData);
} catch (error) {
return false;
}
};
const LIST_FILTER_PARAMS = {
genre: "Genres",
tag: "Tags",
studio: "Studios",
year: "Years",
person: "Person",
rating: "OfficialRatings",
};
const EMPTY_LIST = Object.freeze({ ids: [], filters: [] });
const parseListFilter = (line) => {
const colon = line.indexOf(":");
if (colon < 1) return null;
const key = line.slice(0, colon).trim().toLowerCase();
const param = LIST_FILTER_PARAMS[key];
if (!param) return null;
const value = line
.slice(colon + 1)
.split(",")
.map((part) => part.trim())
.filter(Boolean)
.join("|");
if (!value) return null;
return { key, param, value };
};
const trailersAllowedFor = (item) => {
const allowed = STATE.slideshow.trailerLibraryIds;
if (!allowed || !allowed.size) return true;
if (!item.LibraryId) return true;
return allowed.has(item.LibraryId);
};
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;
},
};
const LocalizationUtils = {
translations: {},
locale: null,
isLoading: {},
cachedLocale: null,
chunkUrlCache: {},
async getCurrentLocale() {
if (this.cachedLocale) {
return this.cachedLocale;
}
let locale = document.documentElement.getAttribute("lang");
if (!locale) {
const navLang = navigator.language || navigator.userLanguage;
locale = navLang || "en-us";
}
locale = locale.toLowerCase();
this.cachedLocale = locale;
return locale;
},
findTranslationChunkUrl(locale) {
const localePrefix = locale.split("-")[0];
if (this.chunkUrlCache[localePrefix]) {
return this.chunkUrlCache[localePrefix];
}
if (window.performance && window.performance.getEntriesByType) {
try {
const resources = window.performance.getEntriesByType("resource");
for (const resource of resources) {
const url = resource.name || resource.url;
if (
url &&
url.includes(`${localePrefix}-json`) &&
url.includes(".chunk.js")
) {
this.chunkUrlCache[localePrefix] = url;
return url;
}
}
} catch (e) {
console.warn("Error checking performance entries:", e);
}
}
this.chunkUrlCache[localePrefix] = null;
return null;
},
async loadTranslations(locale) {
if (this.translations[locale]) return;
if (this.isLoading[locale]) {
await this.isLoading[locale];
return;
}
const loadPromise = (async () => {
try {
const chunkUrl = this.findTranslationChunkUrl(locale);
if (!chunkUrl) {
return;
}
const response = await fetch(chunkUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch translations: ${response.statusText}`,
);
}
const chunkText = await response.text();
const replaceEscaped = (text) =>
text
.replace(/\\"/g, '"')
.replace(/\\n/g, "\n")
.replace(/\\\\/g, "\\")
.replace(/\\'/g, "'");
try {
const START = /^(.*)JSON\.parse\(['"]/gms;
const END = /['"]?\)?\s*}?(\r\n|\r|\n)?}?]?\)?;(\r\n|\r|\n)?$/gms;
const jsonString = replaceEscaped(
chunkText.replace(START, "").replace(END, ""),
);
this.translations[locale] = JSON.parse(jsonString);
return;
} catch (e) {
console.error("Failed to parse JSON from standard extraction.");
}
let jsonMatch = chunkText.match(/JSON\.parse\(['"](.*?)['"]\)/);
if (jsonMatch) {
try {
const jsonString = replaceEscaped(jsonMatch[1]);
this.translations[locale] = JSON.parse(jsonString);
return;
} catch (e) {
console.error("Failed to parse JSON from direct extraction.");
}
}
const jsonStart = chunkText.indexOf("{");
const jsonEnd = chunkText.lastIndexOf("}") + 1;
if (jsonStart !== -1 && jsonEnd > jsonStart) {
const jsonString = chunkText.substring(jsonStart, jsonEnd);
try {
this.translations[locale] = JSON.parse(jsonString);
return;
} catch (e) {
console.error("Failed to parse JSON from chunk:", e);
}
}
} catch (error) {
console.error("Error loading translations:", error);
} finally {
delete this.isLoading[locale];
}
})();
this.isLoading[locale] = loadPromise;
await loadPromise;
},
getLocalizedString(key, fallback, ...args) {
const locale = this.cachedLocale || "en-us";
let translated = this.translations[locale]?.[key] || fallback;
if (args.length > 0) {
for (let i = 0; i < args.length; i++) {
translated = translated.replace(new RegExp(`\\{${i}\\}`, "g"), args[i]);
}
}
return translated;
},
};
const ApiUtils = {
itemFieldsQuery() {
return [
"fields=Overview,Genres,Taglines,RemoteTrailers,ChildCount,LocalTrailerCount",
"enableUserData=true",
"enableImageTypes=Backdrop,Logo,Primary",
"enableTotalRecordCount=false",
].join("&");
},
async fetchItemsByIds(ids) {
if (!ids.length) return [];
try {
const response = await fetch(
`${STATE.jellyfinData.serverAddress}/Items` +
`?Ids=${ids.map(encodeURIComponent).join(",")}` +
`&Recursive=true&${this.itemFieldsQuery()}`,
{ headers: this.getAuthHeaders() },
);
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
STATE.slideshow.credentialsRejected = true;
}
console.warn(`Slideshow: could not load listed items (${response.status}).`);
return [];
}
const { Items: items = [] } = await response.json();
return items;
} catch (error) {
console.error("Error fetching listed items:", error);
return [];
}
},
async getSkipSegments(videoId) {
if (!CONFIG.enableSponsorBlock) return 0;
try {
const categories = '["intro"]';
const response = await fetch(
`https://sponsor.ajay.app/api/skipSegments?videoID=${videoId}&categories=${categories}`,
);
if (response.status === 200) {
const segments = await response.json();
const introSegment = segments.find((s) => s.segment[0] < 5);
if (introSegment) {
console.log(
`[SponsorBlock] Skipping intro for ${videoId}. Start at: ${introSegment.segment[1]}`,
);
return Math.ceil(introSegment.segment[1]);
}
}
return 0;
} catch (error) {
return 0;
}
},
async fetchListEntries() {
try {
const listFileName = `${STATE.jellyfinData.serverAddress}/web/avatars/list.txt?userId=${STATE.jellyfinData.userId}`;
const response = await fetch(listFileName);
if (!response.ok) {
if (response.status !== 404) {
console.warn(
`Slideshow: list.txt returned ${response.status}; using library items.`,
);
}
return EMPTY_LIST;
}
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 EMPTY_LIST;
}
const text = await response.text();
if (/^\s* line.trim())
.filter((line) => line && !line.startsWith("#"))
.forEach((line) => {
if (/^[0-9a-f-]{32,36}$/i.test(line)) {
ids.push(line);
return;
}
const filter = parseListFilter(line);
if (filter) {
filters.push(filter);
return;
}
console.warn(
`Slideshow: ignoring list.txt line "${line}". Expected an item id ` +
`or one of ${Object.keys(LIST_FILTER_PARAMS).join(", ")} ` +
`followed by ":". Prefix a line with # to comment it out.`,
);
});
return { ids, filters };
} catch (error) {
console.error("Error fetching list.txt:", error);
return EMPTY_LIST;
}
},
async fetchItemsByFilter(filter) {
try {
const response = await fetch(
`${STATE.jellyfinData.serverAddress}/Items` +
`?IncludeItemTypes=Movie,Series&Recursive=true` +
`&${filter.param}=${encodeURIComponent(filter.value)}` +
`&SortBy=Random&Limit=${CONFIG.maxItems}` +
`&${this.itemFieldsQuery()}`,
{ headers: this.getAuthHeaders() },
);
if (!response.ok) {
console.warn(
`Slideshow: ${filter.key}:${filter.value} failed (${response.status}).`,
);
return [];
}
const { Items: items = [] } = await response.json();
if (!items.length) {
console.warn(
`Slideshow: ${filter.key}:${filter.value} matched nothing.`,
);
}
return items;
} catch (error) {
console.error(`Error resolving ${filter.key}:${filter.value}:`, error);
return [];
}
},
async fetchListedItems(entries) {
const [byId, ...byFilter] = await Promise.all([
entries.ids.length ? this.fetchItemsByIds(entries.ids) : [],
...entries.filters.map((filter) => this.fetchItemsByFilter(filter)),
]);
const seen = new Set();
const merged = [];
byId.concat(...byFilter).forEach((item) => {
if (!item || seen.has(item.Id)) return;
seen.add(item.Id);
merged.push(item);
});
return merged;
},
async fetchItemPage(types, limit, library = null) {
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()}` +
(library ? `&ParentId=${encodeURIComponent(library.Id)}` : ""),
{
headers: this.getAuthHeaders(),
},
);
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
STATE.slideshow.credentialsRejected = true;
console.warn(
"Slideshow: stored credentials rejected; waiting for ApiClient.",
);
return [];
}
console.error(
`Failed to fetch items: ${response.status} ${response.statusText}`,
);
return [];
}
const { Items: items = [] } = await response.json();
const withLogos = items.filter((item) => item.ImageTags && item.ImageTags.Logo);
if (library) withLogos.forEach((item) => (item.LibraryId = library.Id));
return withLogos;
},
async fetchAcrossLibraries(types, limit, libraries) {
if (!libraries.length) return await this.fetchItemPage(types, limit);
const pages = await Promise.all(
libraries.map((library) => this.fetchItemPage(types, limit, library)),
);
const merged = [];
const longest = Math.max(0, ...pages.map((page) => page.length));
for (let i = 0; i < longest; i++) {
pages.forEach((page) => {
if (i < page.length) merged.push(page[i]);
});
}
return merged.slice(0, limit);
},
async fetchViews() {
if (STATE.jellyfinData.views) return STATE.jellyfinData.views;
try {
const response = await fetch(
`${STATE.jellyfinData.serverAddress}/Users/${STATE.jellyfinData.userId}/Views`,
{ headers: this.getAuthHeaders() },
);
if (!response.ok) {
console.warn(
`Slideshow: could not list libraries (${response.status}); using all.`,
);
return [];
}
const { Items: views = [] } = await response.json();
STATE.jellyfinData.views = views;
return views;
} catch (error) {
console.error("Error fetching libraries:", error);
return [];
}
},
async resolveLibraries(names) {
if (!names.length) return [];
const views = await this.fetchViews();
if (!views.length) return [];
const matched = [];
names.forEach((name) => {
const wanted = String(name).trim().toLowerCase();
const view = views.find((v) => (v.Name || "").toLowerCase() === wanted);
if (view) matched.push(view);
else {
console.warn(
`Slideshow: no library named "${name}". Known libraries:`,
views.map((v) => v.Name).join(", "),
);
}
});
return matched;
},
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);
let libraries = await this.resolveLibraries(CONFIG.libraries);
const trailerLibraries = await this.resolveLibraries(
CONFIG.trailerLibraries,
);
STATE.slideshow.trailerLibraryIds = new Set(
trailerLibraries.map((view) => view.Id),
);
if (!libraries.length && trailerLibraries.length) {
libraries = await this.fetchViews();
}
if (!movieQuota && !seriesQuota) {
return await this.fetchAcrossLibraries(
"Movie,Series",
CONFIG.maxItems,
libraries,
);
}
const [movies, series] = await Promise.all([
movieQuota
? this.fetchAcrossLibraries("Movie", movieQuota, libraries)
: [],
seriesQuota
? this.fetchAcrossLibraries("Series", seriesQuota, libraries)
: [],
]);
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.fetchAcrossLibraries(
"Movie,Series",
CONFIG.maxItems,
libraries,
);
} catch (error) {
console.error("Error fetching items:", error);
return [];
}
},
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}"`,
};
},
async playItem(itemId) {
try {
const sessionId = await this.getSessionId();
if (!sessionId) {
console.error("Session ID not found.");
return false;
}
const playUrl = `${STATE.jellyfinData.serverAddress}/Sessions/${sessionId}/Playing?playCommand=PlayNow&itemIds=${itemId}`;
const playResponse = await fetch(playUrl, {
method: "POST",
headers: this.getAuthHeaders(),
});
if (!playResponse.ok) {
throw new Error(
`Failed to send play command: ${playResponse.statusText}`,
);
}
console.log("Play command sent successfully to session:", sessionId);
return true;
} catch (error) {
console.error("Error sending play command:", error);
return false;
}
},
async getSessionId() {
try {
const response = await fetch(
`${
STATE.jellyfinData.serverAddress
}/Sessions?deviceId=${encodeURIComponent(STATE.jellyfinData.deviceId)}`,
{
headers: this.getAuthHeaders(),
},
);
if (!response.ok) {
throw new Error(`Failed to fetch session data: ${response.statusText}`);
}
const sessions = await response.json();
if (!sessions || sessions.length === 0) {
console.warn(
"No sessions found for deviceId:",
STATE.jellyfinData.deviceId,
);
return null;
}
return sessions[0].Id;
} catch (error) {
console.error("Error fetching session data:", error);
return null;
}
},
async toggleFavorite(itemId, button) {
try {
const isFavorite = button.classList.contains("favorited");
const url = `${STATE.jellyfinData.serverAddress}/UserFavoriteItems/${itemId}`;
const method = isFavorite ? "DELETE" : "POST";
const response = await fetch(url, {
method,
headers: {
...ApiUtils.getAuthHeaders(),
"Content-Type": "application/json",
},
});
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 SlideTimer {
constructor(callback, interval) {
this.callback = callback;
this.interval = interval;
this.timerId = null;
this.resumeId = null;
this.startedAt = null;
this.remaining = interval;
this.start();
}
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;
}
start() {
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;
}
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.startedAt = Date.now();
this.remaining = this.interval;
this.timerId = setInterval(this.callback, this.interval);
this.callback();
}, this.remaining);
return this;
}
restart() {
return this.stop().start();
}
}
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 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 =
onHome &&
(activeTab ? activeTab.getAttribute("data-index") === "0" : true);
const display = isVisible ? "block" : "none";
if (display !== this.lastDisplay) {
container.style.display = display;
this.lastDisplay = display;
}
if (isVisible === this.wasVisible) return;
if (isVisible) {
SlideshowManager.updateCurrentSlide(STATE.slideshow.currentSlideIndex);
} 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();
}
this.wasVisible = isVisible;
},
handleClick(event) {
const target = event.target;
if (
target.closest(".emby-tab-button") ||
target.closest(".pageTabButton") ||
target.closest(".navMenuOption")
) {
VisibilityObserver.scheduleUpdate();
}
},
init() {
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.scheduleUpdate());
this.updateVisibility();
},
};
const PageBackdrop = {
LAYER_CLASS: "slideshow-page-backdrop",
observer: null,
isWriting: false,
currentItemId: null,
surface: null,
prevInlineImage: null,
addedWithBackdrop: false,
getSurface() {
const bg = document.querySelector(".backgroundContainer");
if (!bg) return null;
if (document.querySelector(".backdropContainer .backdropImage")) return null;
return bg;
},
updateSurface(surface, itemId, src) {
if (this.surface !== surface) {
this.surface = surface;
this.prevInlineImage = surface.style.backgroundImage || "";
this.addedWithBackdrop = !surface.classList.contains("withBackdrop");
}
if (this.currentItemId === itemId) return;
surface.style.backgroundImage = `url("${src.replace(/"/g, "%22")}")`;
surface.classList.add("withBackdrop");
this.currentItemId = itemId;
},
restoreSurface() {
if (!this.surface) return;
if (this.prevInlineImage) {
this.surface.style.backgroundImage = this.prevInlineImage;
} else {
this.surface.style.removeProperty("background-image");
}
if (this.addedWithBackdrop) {
this.surface.classList.remove("withBackdrop");
}
this.surface = null;
this.prevInlineImage = null;
this.addedWithBackdrop = false;
},
getOrCreateContainer() {
let container = document.querySelector(".backdropContainer");
if (!container) {
container = SlideUtils.createElement("div", {
className: "backdropContainer",
});
document.body.insertBefore(container, document.body.firstChild);
}
return container;
},
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;
},
removeRotatorLayers(container) {
const layers = container.querySelectorAll(".backdropImage");
layers.forEach((layer) => {
if (!layer.classList.contains(this.LAYER_CLASS)) {
layer.remove();
}
});
const slideshowLayer = container.querySelector(`.${this.LAYER_CLASS}`);
if (slideshowLayer && slideshowLayer !== container.lastElementChild) {
container.appendChild(slideshowLayer);
}
},
startObserver(container) {
if (this.observer) return;
this.observer = new MutationObserver(() => {
if (this.isWriting) return;
this.isWriting = true;
try {
this.removeRotatorLayers(container);
} finally {
this.isWriting = false;
}
});
this.observer.observe(container, { childList: true });
},
stopObserver() {
if (!this.observer) return;
this.observer.disconnect();
this.observer = null;
},
update(itemId) {
if (!CONFIG.syncPageBackdrop) return;
const item = STATE.slideshow.loadedItems[itemId];
if (!item) return;
const src = SlideCreator.buildImageUrl(
item,
"Backdrop",
0,
STATE.jellyfinData.serverAddress,
60,
);
if (!src) return;
const surface = this.getSurface();
if (surface) {
this.updateSurface(surface, itemId, 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");
void layer.offsetWidth;
layer.classList.add("backdropImageFadeIn");
this.currentItemId = itemId;
}
this.removeRotatorLayers(container);
} finally {
this.isWriting = false;
}
document
.querySelector(".backgroundContainer")
?.classList.add("withBackdrop");
this.startObserver(container);
},
clear() {
this.stopObserver();
this.currentItemId = null;
if (this.surface) {
this.restoreSurface();
return;
}
const slideshowLayer = document.querySelector(`.${this.LAYER_CLASS}`);
if (!slideshowLayer) return;
slideshowLayer.remove();
document
.querySelector(".backgroundContainer")
?.classList.remove("withBackdrop");
},
};
const SlideCreator = {
buildImageUrl(item, imageType, index, serverAddress, quality) {
const itemId = item.Id;
let tag = null;
if (imageType === "Backdrop") {
if (
item.BackdropImageTags &&
Array.isArray(item.BackdropImageTags) &&
item.BackdropImageTags.length > 0
) {
const backdropIndex = index !== undefined ? index : 0;
if (backdropIndex < item.BackdropImageTags.length) {
tag = item.BackdropImageTags[backdropIndex];
}
}
if (!tag && item.ImageTags && item.ImageTags.Backdrop) {
tag = item.ImageTags.Backdrop;
}
} else {
if (item.ImageTags && item.ImageTags[imageType]) {
tag = item.ImageTags[imageType];
}
}
let baseUrl;
if (index !== undefined) {
baseUrl = `${serverAddress}/Items/${itemId}/Images/${imageType}/${index}`;
} else {
baseUrl = `${serverAddress}/Items/${itemId}/Images/${imageType}`;
}
if (tag) {
const qualityParam = quality !== undefined ? `&quality=${quality}` : "";
return `${baseUrl}?tag=${tag}${qualityParam}`;
} else {
const qualityParam = quality !== undefined ? quality : 90;
return `${baseUrl}?quality=${qualityParam}`;
}
},
selectTrailerVideoId(remoteTrailers) {
if (!Array.isArray(remoteTrailers) || remoteTrailers.length === 0) {
return null;
}
const rankName = (name) => {
const text = (name || "").toLowerCase();
const isAlternateCut = CONFIG.trailerAlternateCutTerms.some((term) =>
text.includes(term),
);
let rank;
if (text.includes("official trailer")) rank = 5;
else if (text.includes("final trailer") || text.includes("main trailer"))
rank = 4;
else if (text.includes("trailer")) rank = 3;
else if (text.includes("teaser")) rank = 2;
else rank = 1;
return isAlternateCut ? rank - 0.5 : rank;
};
let best = null;
for (const trailer of remoteTrailers) {
let videoId = null;
try {
const urlObj = new URL(trailer.Url);
const host = urlObj.hostname.replace(/^www\./, "");
if (host === "youtu.be") {
videoId = urlObj.pathname.split("/")[1] || null;
} else if (
(host === "youtube.com" || host === "m.youtube.com") &&
urlObj.pathname.startsWith("/embed/")
) {
videoId = urlObj.pathname.split("/")[2] || null;
} else {
videoId = urlObj.searchParams.get("v");
}
} catch (e) {}
if (!videoId) continue;
const rank = rankName(trailer.Name);
if (!best || rank > best.rank) {
best = { videoId, rank };
}
}
return best ? best.videoId : null;
},
createSlideElement(item, title) {
if (!item || !item.Id) {
console.error("Invalid item data:", item);
return null;
}
const itemId = item.Id;
const serverAddress = STATE.jellyfinData.serverAddress;
const slide = SlideUtils.createElement("div", {
className: "slide",
"data-item-id": itemId,
});
let videoId = null;
let hasLocalTrailer = false;
let trailerContainer = null;
if (
CONFIG.enableTrailers &&
(CONFIG.allowTrailersOnTouch || !isTouchLayout()) &&
!isDataSaverOn() &&
trailersAllowedFor(item)
) {
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}`,
});
const playerDiv = SlideUtils.createElement("div", {
className: "video-player",
id: `yt-player-${item.Id}`,
});
trailerContainer.appendChild(playerDiv);
slide.appendChild(trailerContainer);
}
}
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: "",
"aria-hidden": "true",
loading: "eager",
});
const backdropOverlay = SlideUtils.createElement("div", {
className: "backdrop-overlay",
});
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", {
className: "logo high-quality",
src: this.buildImageUrl(item, "Logo", undefined, serverAddress, 40),
alt: item.Name,
loading: "eager",
});
const logoContainer = SlideUtils.createElement("div", {
className: "logo-container",
});
logoContainer.appendChild(logo);
const featuredContent = SlideUtils.createElement(
"div",
{
className: "featured-content",
},
title,
);
const plotElement = SlideUtils.createElement(
"div",
{ className: "plot" },
item.Overview || "",
);
const plotContainer = SlideUtils.createElement("div", {
className: "plot-container",
});
plotContainer.appendChild(plotElement);
const posterContainer = SlideUtils.createElement("div", {
className: "poster-container",
});
if (item.ImageTags && item.ImageTags.Primary) {
const poster = SlideUtils.createElement("img", {
className: "poster high-quality",
src: this.buildImageUrl(item, "Primary", undefined, serverAddress, 70),
alt: item.Name,
loading: "eager",
});
posterContainer.appendChild(poster);
} else {
posterContainer.style.display = "none";
}
const taglineText =
Array.isArray(item.Taglines) && item.Taglines.length
? item.Taglines[0]
: "";
const taglineElement = SlideUtils.createElement(
"div",
{ className: "tagline" },
taglineText,
);
if (!taglineText) taglineElement.style.display = "none";
const gradientOverlay = SlideUtils.createElement("div", {
className: "gradient-overlay",
});
const infoContainer = SlideUtils.createElement("div", {
className: "info-container",
});
const ratingInfo = this.createRatingInfo(item);
infoContainer.appendChild(ratingInfo);
const genreElement = SlideUtils.createElement("div", {
className: "genre",
});
const genres = SlideUtils.buildGenres(item.Genres);
if (genres) genreElement.appendChild(genres);
else genreElement.style.display = "none";
const buttonContainer = SlideUtils.createElement("div", {
className: "button-container",
});
const playButton = this.createPlayButton(itemId);
const detailButton = this.createDetailButton(itemId);
const favoriteButton = this.createFavoriteButton(item);
detailButton.appendChild(
SlideUtils.createElement(
"span",
{ className: "action-label" },
LocalizationUtils.getLocalizedString("Details", "Details"),
),
);
favoriteButton.appendChild(
SlideUtils.createElement(
"span",
{ className: "action-label" },
LocalizationUtils.getLocalizedString("Favorite", "Favorite"),
),
);
if (isMarqueeLayout() || isPlateLayout()) {
buttonContainer.append(playButton, detailButton, favoriteButton);
} else {
buttonContainer.append(detailButton, playButton, favoriteButton);
}
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(
posterContainer,
logoContainer,
taglineElement,
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;
},
createRatingInfo(item) {
const {
CommunityRating: communityRating,
CriticRating: criticRating,
OfficialRating: ageRating,
PremiereDate: premiereDate,
RunTimeTicks: runtime,
ChildCount: seasonCount,
} = item;
const miscInfo = SlideUtils.createElement("div", {
className: "misc-info",
});
const fields = [];
if (typeof communityRating === "number") {
fields.push(
SlideUtils.createElement("div", {
className: "star-rating-container",
innerHTML: `${communityRating.toFixed(
1,
)}`,
}),
);
}
if (typeof criticRating === "number") {
const svgIcon =
criticRating < 60
? CONFIG.IMAGE_SVG.rottenTomato
: CONFIG.IMAGE_SVG.freshTomato;
fields.push(
SlideUtils.createElement("div", {
className: "critic-rating",
innerHTML: `${svgIcon}${criticRating.toFixed(0)}%`,
}),
);
}
if (typeof premiereDate === "string") {
const year = new Date(premiereDate).getFullYear();
if (!Number.isNaN(year)) {
fields.push(
SlideUtils.createElement(
"div",
{ className: "date" },
String(year),
),
);
}
}
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;
},
createPlayButton(itemId) {
const button = SlideUtils.createElement("button", {
className: "detailButton btnPlay play-button",
tabIndex: "0",
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
ApiUtils.playItem(itemId);
},
});
button.appendChild(
SlideUtils.createElement(
"span",
{ className: "play-text" },
LocalizationUtils.getLocalizedString("Play", "Play"),
),
);
return button;
},
createDetailButton(itemId) {
return SlideUtils.createElement("button", {
className: "detailButton detail-button",
tabIndex: "0",
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
if (window.Emby && window.Emby.Page) {
Emby.Page.show(
`/details?id=${itemId}&serverId=${STATE.jellyfinData.serverId}`,
);
} else {
window.location.href = `#/details?id=${itemId}&serverId=${STATE.jellyfinData.serverId}`;
}
},
});
},
createFavoriteButton(item) {
const isFavorite = item.UserData && item.UserData.IsFavorite === true;
const button = SlideUtils.createElement("button", {
className: `favorite-button ${isFavorite ? "favorited" : ""}`,
tabIndex: "0",
onclick: async (e) => {
e.preventDefault();
e.stopPropagation();
await ApiUtils.toggleFavorite(item.Id, button);
},
});
return button;
},
async createSlideForItemId(itemId) {
try {
if (STATE.slideshow.createdSlides[itemId]) {
return document.querySelector(`.slide[data-item-id="${itemId}"]`);
}
const container = SlideUtils.getOrCreateSlidesContainer();
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;
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}`);
if (host) {
host.textContent = "";
host.appendChild(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) => {
if (!YT) return;
if (!document.getElementById(`trailer-${itemId}`)) return;
STATE.slideshow.players[itemId] = new YT.Player(
`yt-player-${itemId}`,
{
videoId: videoId,
playerVars: {
autoplay: 0,
controls: 0,
disablekb: 1,
fs: 0,
iv_load_policy: 3,
modestbranding: 1,
rel: 0,
showinfo: 0,
start: startTime,
},
events: {
onStateChange: (e) => {
if (e.data === YT.PlayerState.PLAYING) hideTrailerCaptions(e.target);
SlideshowManager.onPlayerStateChange(
e,
itemId,
trailerContainer,
);
},
onReady: (e) => {
e.target.mute();
hideTrailerCaptions(e.target);
},
onError: (e) =>
SlideshowManager.onPlayerError(e, itemId, trailerContainer),
},
},
);
});
}
return slide;
} catch (error) {
console.error("Error creating slide for item:", error, itemId);
return null;
}
},
};
const SlideshowManager = {
createPaginationDots() {
const container = SlideUtils.getOrCreateSlidesContainer();
let dotsContainer = container.querySelector(".dots-container");
if (!dotsContainer) {
dotsContainer = SlideUtils.createElement("div", {
className: "dots-container",
});
container.appendChild(dotsContainer);
}
dotsContainer.textContent = "";
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) => {
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";
});
},
toggleMute() {
STATE.slideshow.isMuted = !STATE.slideshow.isMuted;
const btnIcon = document.querySelector(".volume-toggle i");
if (btnIcon)
btnIcon.textContent = STATE.slideshow.isMuted
? "volume_off"
: "volume_up";
const currentId =
STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex];
const player = STATE.slideshow.players[currentId];
if (player && typeof player.setVolume === "function") {
if (STATE.slideshow.isMuted) {
player.mute();
} else {
player.unMute();
player.setVolume(50);
}
}
},
async updateCurrentSlide(index) {
if (STATE.slideshow.isTransitioning) return;
if (STATE.slideshow.slideInterval) STATE.slideshow.slideInterval.stop();
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 videoId = STATE.slideshow.slideVideoIds[currentItemId];
const canPlayTrailer = CONFIG.enableTrailers && Boolean(videoId);
if (canPlayTrailer) {
STATE.slideshow.trailerStartTimer = setTimeout(() => {
this.startTrailer(index, currentItemId, fallbackToTimer);
}, CONFIG.trailerStartDelayMs);
}
fallbackToTimer();
function fallbackToTimer() {
if (!STATE.slideshow.isPaused && STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.start();
}
}
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);
},
startTrailer(index, itemId, resumeTimer) {
if (STATE.slideshow.currentSlideIndex !== index) return;
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");
});
},
async preloadAdjacentSlides(currentIndex) {
const totalItems = STATE.slideshow.totalItems;
if (!totalItems) return;
const preloadCount = CONFIG.preloadCount;
const nextIndex = (currentIndex + 1) % totalItems;
const itemId = STATE.slideshow.itemIds[nextIndex];
await SlideCreator.createSlideForItemId(itemId);
if (preloadCount > 1) {
const prevIndex = (currentIndex - 1 + totalItems) % totalItems;
const prevItemId = STATE.slideshow.itemIds[prevIndex];
SlideCreator.createSlideForItemId(prevItemId);
}
},
nextSlide() {
const currentIndex = STATE.slideshow.currentSlideIndex;
const totalItems = STATE.slideshow.totalItems;
const nextIndex = (currentIndex + 1) % totalItems;
this.updateCurrentSlide(nextIndex);
},
prevSlide() {
const currentIndex = STATE.slideshow.currentSlideIndex;
const totalItems = STATE.slideshow.totalItems;
const prevIndex = (currentIndex - 1 + totalItems) % totalItems;
this.updateCurrentSlide(prevIndex);
},
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 linear = Math.abs(index - currentIndex);
const distance = totalItems
? Math.min(linear, totalItems - linear)
: linear;
if (distance > keepRange) {
if (STATE.slideshow.players[itemId]) {
try {
if (typeof STATE.slideshow.players[itemId].destroy === "function") {
STATE.slideshow.players[itemId].destroy();
}
} catch (e) {
console.warn("Error destroying player:", e);
}
delete STATE.slideshow.players[itemId];
}
const slide = document.querySelector(
`.slide[data-item-id="${itemId}"]`,
);
if (slide) slide.remove();
delete STATE.slideshow.createdSlides[itemId];
delete STATE.slideshow.slideVideoIds[itemId];
}
});
},
togglePause() {
STATE.slideshow.isPaused = !STATE.slideshow.isPaused;
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 (paused) {
STATE.slideshow.slideInterval?.pause();
this.clearSlideTimers();
} else {
STATE.slideshow.slideInterval?.resume();
}
this.setProgressRunning(!paused);
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 (player && typeof player.playVideo === "function") {
player.playVideo();
}
},
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;
let touchEndX = 0;
container.addEventListener(
"touchstart",
(e) => {
touchStartX = e.changedTouches[0].screenX;
},
{ passive: true },
);
container.addEventListener(
"touchend",
(e) => {
touchEndX = e.changedTouches[0].screenX;
this.handleSwipe(touchStartX, touchEndX);
},
{ passive: true },
);
},
handleSwipe(startX, endX) {
const diff = endX - startX;
if (Math.abs(diff) < CONFIG.minSwipeDistance) {
return;
}
if (diff > 0) {
this.prevSlide();
} else {
this.nextSlide();
}
},
initKeyboardEvents() {
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;
}
const buttonRow = container.querySelector(".slide.active .button-container");
const focusedButton =
buttonRow && buttonRow.contains(active) ? active : null;
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();
event.preventDefault();
break;
case "Enter":
if (!focusedButton && active === container) {
container.querySelector(".slide.active .play-button")?.click();
event.preventDefault();
}
break;
case "Escape":
container.blur();
STATE.slideshow.containerFocused = false;
break;
}
});
},
setTrailerVisible(itemId, trailerContainer, on) {
const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`);
trailerContainer?.classList.toggle("active", on);
slide?.querySelector(".backdrop")?.classList.toggle("with-video", on);
slide?.querySelector(".plot-container")?.classList.toggle("with-video", on);
},
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();
this.setProgressRunning(false);
},
onTrailerEnded(itemId, trailerContainer) {
STATE.slideshow.isVideoPlaying = false;
this.setTrailerVisible(itemId, trailerContainer, false);
this.nextSlide();
},
resumeAfterTrailer(itemId) {
const currentItemId =
STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex];
if (itemId !== currentItemId) return;
if (
STATE.slideshow.isPaused ||
STATE.slideshow.hoverHeld ||
document.hidden
) {
return;
}
const timer = STATE.slideshow.slideInterval;
if (!timer || timer.timerId || timer.resumeId) return;
timer.start();
this.restartProgress(
document.querySelector(`.slide[data-item-id="${itemId}"]`),
);
},
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 === YTRef.PlayerState.PAUSED ||
event.data === YTRef.PlayerState.CUED
) {
const currentItemId =
STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex];
if (itemId !== currentItemId) return;
STATE.slideshow.isVideoPlaying = false;
this.resumeAfterTrailer(itemId);
}
},
onPlayerError(event, itemId, trailerContainer) {
console.warn(
`Trailer for ${itemId} failed with YouTube error ${event?.data}; continuing without it.`,
);
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;
const currentItemId =
STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex];
if (itemId !== currentItemId) return;
clearTimeout(STATE.slideshow.trailerWatchdog);
STATE.slideshow.trailerWatchdog = null;
STATE.slideshow.isVideoPlaying = false;
this.resumeAfterTrailer(itemId);
},
async loadSlideshowData() {
try {
STATE.slideshow.isLoading = true;
mark("items-fetch-start");
const listed = await ApiUtils.fetchListEntries();
let items =
listed.ids.length || listed.filters.length
? await ApiUtils.fetchListedItems(listed)
: await ApiUtils.fetchItemsFromServer();
if (!items.length && (listed.ids.length || listed.filters.length)) {
console.warn(
"Slideshow: list.txt matched no items; using library items.",
);
items = await ApiUtils.fetchItemsFromServer();
}
mark("items-fetched");
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 &&
VisibilityObserver.wasVisible
) {
this.nextSlide();
}
}, CONFIG.shuffleInterval);
STATE.slideshow.slideInterval.stop();
await this.updateCurrentSlide(STATE.slideshow.resumeIndex || 0);
} catch (error) {
console.error("Error loading slideshow data:", error);
} finally {
STATE.slideshow.isLoading = false;
}
},
};
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: "libraries",
label: "Libraries",
type: "multichoice",
},
{
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: "syncPageBackdrop", label: "Sync page backdrop", type: "switch" },
{ key: "pauseOnHover", label: "Pause on hover", type: "switch" },
{ key: "enableTrailers", label: "Trailers", type: "switch" },
{
key: "allowTrailersOnTouch",
label: "Trailers on phones",
type: "switch",
},
{
key: "trailerVolume",
label: "Trailer volume",
type: "range",
min: 0,
max: 100,
step: 5,
format: (value) => `${value}%`,
},
{ key: "trailerCaptions", label: "Trailer captions", type: "switch" },
{
key: "trailerLibraries",
label: "Trailers from",
type: "multichoice",
},
{
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 (field.type === "multichoice") {
const group = SlideUtils.createElement("div", { className: "ss-multi" });
const boxes = new Map();
row.classList.add("is-stacked");
row.append(group);
this.controls.set(field.key, { group, boxes });
this.populateMultichoice(field, group, boxes, locked);
}
if (locked) {
row.append(
SlideUtils.createElement("span", {
className: "ss-set-note",
textContent: "Set by your server administrator",
}),
);
}
return row;
},
async populateMultichoice(field, group, boxes, locked) {
if (boxes.size) return;
const views = await ApiUtils.fetchViews();
if (!views.length) {
group.append(
SlideUtils.createElement("span", {
className: "ss-set-note",
textContent: "Could not read your libraries.",
}),
);
return;
}
views.forEach((view) => {
const item = SlideUtils.createElement("label", {
className: "ss-multi-item",
});
const input = SlideUtils.createElement("input", {
type: "checkbox",
className: "ss-multi-box",
disabled: locked,
onchange: () => this.commitMultichoice(field.key),
});
item.append(
input,
SlideUtils.createElement("span", { textContent: view.Name }),
);
group.append(item);
boxes.set(view.Name, input);
});
this.sync();
},
commitMultichoice(key) {
const control = this.controls.get(key);
const names = [...control.boxes]
.filter(([, input]) => input.checked)
.map(([name]) => name);
if (!names.length) {
this.sync();
return;
}
this.commit(key, names.length === control.boxes.size ? [] : names);
},
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;
}
if (control.boxes) {
const chosen = new Set((value || []).map(String));
control.boxes.forEach((input, name) => {
input.checked = chosen.size === 0 || chosen.has(name);
});
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);
}
const trailerLibs = this.controls.get("trailerLibraries");
if (trailerLibs?.boxes) {
const off = !CONFIG.enableTrailers;
const locked = lockedConfigKeys.has("trailerLibraries");
trailerLibs.boxes.forEach((input) => {
input.disabled = off || locked;
});
trailerLibs.group
.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: ' ',
role: "button",
tabIndex: "0",
"aria-label": LocalizationUtils.getLocalizedString("Previous", "Previous"),
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
SlideshowManager.prevSlide();
},
});
const rightArrow = SlideUtils.createElement("div", {
className: "arrow right-arrow",
innerHTML: ' ',
role: "button",
tabIndex: "0",
"aria-label": LocalizationUtils.getLocalizedString("Next", "Next"),
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
SlideshowManager.nextSlide();
},
});
const volumeBtn = SlideUtils.createElement("div", {
className: "volume-toggle ss-chrome",
innerHTML: 'volume_off ',
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
SlideshowManager.toggleMute();
},
});
const pauseButton = SlideUtils.createElement("div", {
className: "pause-button ss-chrome",
innerHTML: 'pause ',
tabIndex: "0",
"aria-label": LocalizationUtils.getLocalizedString("ButtonPause", "Pause"),
title: LocalizationUtils.getLocalizedString("ButtonPause", "Pause"),
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
SlideshowManager.togglePause();
},
});
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();
},
onkeydown: (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
SettingsPanel.toggle();
},
});
container.append(leftArrow, rightArrow, volumeBtn, pauseButton, settingsBtn);
};
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");
return;
}
STATE.slideshow.hasInitialized = true;
try {
console.log("๐ Initializing Enhanced Jellyfin Slideshow");
await SlideshowManager.loadSlideshowData();
LayoutSync.init();
SlideshowManager.initTouchEvents();
SlideshowManager.initHoverPause();
initArrowNavigation();
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) {
console.error("Error initializing slideshow:", error);
STATE.slideshow.hasInitialized = false;
}
};
const initPageVisibilityHandler = () => {
let wasVideoPlayingBeforeHide = false;
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
console.log("Tab inactive - pausing slideshow and videos");
wasVideoPlayingBeforeHide = STATE.slideshow.isVideoPlaying;
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.pause();
}
SlideshowManager.setProgressRunning(false);
const currentItemId =
STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex];
if (currentItemId && STATE.slideshow.players[currentItemId]) {
const player = STATE.slideshow.players[currentItemId];
if (typeof player.pauseVideo === "function") {
try {
player.pauseVideo();
STATE.slideshow.isVideoPlaying = false;
} catch (e) {
console.warn("Error pausing video on tab hide:", e);
}
}
}
} else {
console.log("Tab active - resuming slideshow");
if (!STATE.slideshow.isPaused && VisibilityObserver.wasVisible) {
const currentItemId =
STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex];
if (
wasVideoPlayingBeforeHide &&
currentItemId &&
STATE.slideshow.players[currentItemId]
) {
const player = STATE.slideshow.players[currentItemId];
if (typeof player.playVideo === "function") {
try {
player.playVideo();
STATE.slideshow.isVideoPlaying = true;
} catch (e) {
console.warn("Error resuming video on tab show:", e);
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.resume();
}
SlideshowManager.setProgressRunning(true);
}
}
} else {
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.resume();
}
SlideshowManager.setProgressRunning(true);
}
wasVideoPlayingBeforeHide = false;
}
}
});
};
window.slideshowPure = {
CONFIG,
STATE,
SlideUtils,
ApiUtils,
BlurHash,
SlideCreator,
SlideshowManager,
VisibilityObserver,
PageBackdrop,
initSlideshowData: () => {
SlideshowManager.loadSlideshowData();
},
nextSlide: () => {
SlideshowManager.nextSlide();
},
prevSlide: () => {
SlideshowManager.prevSlide();
},
reset: () => resetSlideshowState(),
bootstrap: () => bootstrap(),
setConfig,
clearConfig: clearStoredConfig,
applyConfig,
configRules: CONFIG_RULES,
settings: SettingsPanel,
};