Files

3866 lines
127 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Jellyfin Slideshow by M0RPH3US v4.0.1
* Modified by CodeDevMLH
*
* New features:
* - optional Trailer background video support
* - option to make video backdrops full width
* - SponsorBlock support to skip intro/outro segments
* - option to always show arrows
* - option to disable/enable keyboard controls
* - option to show/hide trailer button if trailer as backdrop is disabled (opens in a modal)
* - option to wait for trailer to end before loading next slide
* - option to set a maximum for the pagination dots (will turn into a counter style if exceeded)
* - option to disable loading screen
* - option to put collection (boxsets) IDs into the slideshow to display their items
* - option to enable client-side settings (allow users to override settings locally on their device)
* - option to enable seasonal content (only show items that are relevant to the current season/holiday)
* - option to prefer local trailers (from the media item) over online sources
* - options to sort the content by various criteria (PremiereDate, ProductionYear, Random, Original order, etc.)
*/
//Core Module Configuration
const CONFIG = {
IMAGE_SVG: {
freshTomato:
'<svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="18" viewBox="0 0 138.75 141.25" width="18" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata id="metadata3396"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><g id="layer1" fill="#f93208"><path id="path3412" d="m20.154 40.829c-28.149 27.622-13.657 61.011-5.734 71.931 35.254 41.954 92.792 25.339 111.89-5.9071 4.7608-8.2027 22.554-53.467-23.976-78.009z"/><path id="path3471" d="m39.613 39.265 4.7778-8.8607 28.406-5.0384 11.119 9.2082z"/></g><g id="layer2"><path id="path3437" d="m39.436 8.5696 8.9682-5.2826 6.7569 15.479c3.7925-6.3226 13.79-16.316 24.939-4.6684-4.7281 1.2636-7.5161 3.8553-7.7397 8.4768 15.145-4.1697 31.343 3.2127 33.539 9.0911-10.951-4.314-27.695 10.377-41.771 2.334 0.009 15.045-12.617 16.636-19.902 17.076 2.077-4.996 5.591-9.994 1.474-14.987-7.618 8.171-13.874 10.668-33.17 4.668 4.876-1.679 14.843-11.39 24.448-11.425-6.775-2.467-12.29-2.087-17.814-1.475 2.917-3.961 12.149-15.197 28.625-8.476z" fill="#02902e"/></g></svg>',
rottenTomato:
'<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" viewBox="0 0 145 140" width="20" height="18"><path fill="#0fc755" d="M47.4 35.342c-13.607-7.935-12.32-25.203 2.097-31.88 26.124-6.531 29.117 13.78 22.652 30.412-6.542 24.11 18.095 23.662 19.925 10.067 3.605-18.412 19.394-26.695 31.67-16.359 12.598 12.135 7.074 36.581-17.827 34.187-16.03-1.545-19.552 19.585.839 21.183 32.228 1.915 42.49 22.167 31.04 35.865-15.993 15.15-37.691-4.439-45.512-19.505-6.8-9.307-17.321.11-13.423 6.502 12.983 19.465 2.923 31.229-10.906 30.62-13.37-.85-20.96-9.06-13.214-29.15 3.897-12.481-8.595-15.386-16.57-5.45-11.707 19.61-28.865 13.68-33.976 4.19-3.243-7.621-2.921-25.846 24.119-23.696 16.688 4.137 11.776-12.561-.63-13.633-9.245-.443-30.501-7.304-22.86-24.54 7.34-11.056 24.958-11.768 33.348 6.293 3.037 4.232 8.361 11.042 18.037 5.033 3.51-5.197 1.21-13.9-8.809-20.135z"/></svg>',
},
shuffleInterval: 7000,
retryInterval: 500,
minSwipeDistance: 50,
loadingCheckInterval: 100,
maxPlotLength: 360,
maxMovies: 15,
maxTvShows: 15,
maxItems: 500,
preloadCount: 3,
fadeTransitionDuration: 500,
maxPaginationDots: 15,
slideAnimationEnabled: true,
enableVideoBackdrop: true,
useSponsorBlock: true,
preferLocalTrailers: false,
waitForTrailerToEnd: true,
startMuted: true,
fullWidthVideo: true,
enableMobileVideo: false,
showTrailerButton: true,
preferredVideoQuality: "Auto",
enableKeyboardControls: true,
alwaysShowArrows: false,
enableCustomMediaIds: true,
enableSeasonalContent: false,
customMediaIds: "",
enableLoadingScreen: true,
enableClientSideSettings: false,
sortBy: "Random",
sortOrder: "Ascending",
applyLimitsToCustomIds: false,
seasonalSections: "[]",
};
// State management
const STATE = {
jellyfinData: {
userId: null,
appName: null,
appVersion: null,
deviceName: null,
deviceId: null,
accessToken: null,
serverAddress: null,
},
slideshow: {
hasInitialized: false,
isTransitioning: false,
isPaused: false,
currentSlideIndex: 0,
focusedSlide: null,
containerFocused: false,
slideInterval: null,
itemIds: [],
loadedItems: {},
createdSlides: {},
totalItems: 0,
isLoading: false,
videoPlayers: {},
sponsorBlockInterval: null,
isMuted: CONFIG.startMuted,
customTrailerUrls: {},
ytPromise: null,
autoplayTimeouts: [],
},
};
// Request throttling system
const requestQueue = [];
let isProcessingQueue = false;
/**
* Process the next request in the queue with throttling
*/
const processNextRequest = () => {
if (requestQueue.length === 0) {
isProcessingQueue = false;
return;
}
isProcessingQueue = true;
const { url, callback } = requestQueue.shift();
fetch(url)
.then((response) => {
if (response.ok) {
return response;
}
throw new Error(`Failed to fetch: ${response.status}`);
})
.then(callback)
.catch((error) => {
console.error("Error in throttled request:", error);
})
.finally(() => {
setTimeout(processNextRequest, 100);
});
};
/**
* Add a request to the throttled queue
* @param {string} url - URL to fetch
* @param {Function} callback - Callback to run on successful fetch
*/
const addThrottledRequest = (url, callback) => {
requestQueue.push({ url, callback });
if (!isProcessingQueue) {
processNextRequest();
}
};
/**
* Checks if the user is currently logged in
* @returns {boolean} True if logged in, false otherwise
*/
const isUserLoggedIn = () => {
try {
return (
window.ApiClient &&
window.ApiClient._currentUser &&
window.ApiClient._currentUser.Id &&
window.ApiClient._serverInfo &&
window.ApiClient._serverInfo.AccessToken
);
} catch (error) {
console.error("Error checking login status:", error);
return false;
}
};
/**
* Initializes Jellyfin data from ApiClient
* @param {Function} callback - Function to call once data is initialized
*/
const initJellyfinData = (callback) => {
if (!window.ApiClient) {
console.warn("⏳ window.ApiClient is not available yet. Retrying...");
setTimeout(() => initJellyfinData(callback), CONFIG.retryInterval);
return;
}
try {
const apiClient = window.ApiClient;
STATE.jellyfinData = {
userId: apiClient.getCurrentUserId() || "Not Found",
appName: apiClient._appName || "Not Found",
appVersion: apiClient._appVersion || "Not Found",
deviceName: apiClient._deviceName || "Not Found",
deviceId: apiClient._deviceId || "Not Found",
accessToken: apiClient._serverInfo.AccessToken || "Not Found",
serverId: apiClient._serverInfo.Id || "Not Found",
serverAddress: apiClient._serverAddress || "Not Found",
};
if (callback && typeof callback === "function") {
callback();
}
} catch (error) {
console.error("Error initializing Jellyfin data:", error);
setTimeout(() => initJellyfinData(callback), CONFIG.retryInterval);
}
};
/**
* Initializes localization by loading translation chunks
*/
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);
}
};
/**
* Creates and displays loading screen
*/
const initLoadingScreen = () => {
const currentPath = window.location.href.toLowerCase().replace(window.location.origin, "");
const isHomePage =
currentPath.includes("/web/#/home.html") ||
currentPath.includes("/web/#/home") ||
currentPath.includes("/web/index.html#/home.html") ||
currentPath === "/web/index.html#/home" ||
currentPath.endsWith("/web/");
if (!isHomePage) return;
// Check LocalStorage for cached preference to avoid flash
const cachedSetting = localStorage.getItem('mediaBarEnhanced-enableLoadingScreen');
if (cachedSetting === 'false') {
return;
}
const loadingDiv = document.createElement("div");
loadingDiv.className = "bar-loading";
loadingDiv.id = "page-loader";
loadingDiv.innerHTML = `
<div class="loader-content">
<h1>
<div class="splashLogo"></div>
</h1>
<div class="progress-container">
<div class="progress-bar" id="progress-bar"></div>
<div class="progress-gap" id="progress-gap"></div>
<div class="unfilled-bar" id="unfilled-bar"></div>
</div>
</div>
`;
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);
const finishLoading = () => {
clearInterval(progressInterval);
clearInterval(checkInterval);
progressBar.style.transition = "width 300ms ease-in-out";
progressBar.style.width = "100%";
unfilledBar.style.width = "0%";
progressBar.addEventListener("transitionend", () => {
requestAnimationFrame(() => {
const loader = document.querySelector(".bar-loading");
if (loader) {
loader.style.opacity = "0";
setTimeout(() => {
loader.remove();
}, 300);
}
});
});
};
};
/**
* Resets the slideshow state completely
*/
const resetSlideshowState = () => {
console.log("🔄 Resetting slideshow state...");
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
// Destroy all video players
if (STATE.slideshow.videoPlayers) {
Object.values(STATE.slideshow.videoPlayers).forEach(player => {
if (player && typeof player.destroy === 'function') {
player.destroy();
}
});
STATE.slideshow.videoPlayers = {};
}
if (STATE.slideshow.sponsorBlockInterval) {
clearInterval(STATE.slideshow.sponsorBlockInterval);
STATE.slideshow.sponsorBlockInterval = null;
}
const container = document.getElementById("slides-container");
if (container) {
while (container.firstChild) {
container.removeChild(container.firstChild);
}
}
STATE.slideshow.hasInitialized = false;
STATE.slideshow.isTransitioning = false;
STATE.slideshow.isPaused = false;
STATE.slideshow.currentSlideIndex = 0;
STATE.slideshow.focusedSlide = null;
STATE.slideshow.containerFocused = false;
STATE.slideshow.slideInterval = null;
STATE.slideshow.itemIds = [];
STATE.slideshow.loadedItems = {};
STATE.slideshow.createdSlides = {};
STATE.slideshow.customTrailerUrls = {};
STATE.slideshow.totalItems = 0;
STATE.slideshow.isLoading = false;
};
/**
* Watches for login status changes
*/
const startLoginStatusWatcher = () => {
let wasLoggedIn = false;
setInterval(() => {
const isLoggedIn = isUserLoggedIn();
if (isLoggedIn !== wasLoggedIn) {
if (isLoggedIn) {
console.log("👤 User logged in. Initializing slideshow...");
if (!STATE.slideshow.hasInitialized) {
waitForApiClientAndInitialize();
} else {
console.log("🔄 Slideshow already initialized, skipping");
}
} else {
console.log("👋 User logged out. Stopping slideshow...");
resetSlideshowState();
}
wasLoggedIn = isLoggedIn;
}
}, 2000);
};
/**
* Wait for ApiClient to initialize before starting the slideshow
*/
const waitForApiClientAndInitialize = () => {
if (window.slideshowCheckInterval) {
clearInterval(window.slideshowCheckInterval);
}
window.slideshowCheckInterval = setInterval(() => {
if (!window.ApiClient) {
console.log("⏳ ApiClient not available yet. Waiting...");
return;
}
if (
window.ApiClient._currentUser &&
window.ApiClient._currentUser.Id &&
window.ApiClient._serverInfo &&
window.ApiClient._serverInfo.AccessToken
) {
console.log(
"🔓 User is fully logged in. Starting slideshow initialization..."
);
clearInterval(window.slideshowCheckInterval);
if (!STATE.slideshow.hasInitialized) {
initJellyfinData(async () => {
console.log("✅ Jellyfin API client initialized successfully");
await initLocalization();
await fetchPluginConfig();
slidesInit();
});
} else {
console.log("🔄 Slideshow already initialized, skipping");
}
} else {
console.log(
"🔒 Authentication incomplete. Waiting for complete login..."
);
}
}, CONFIG.retryInterval);
};
const fetchPluginConfig = async () => {
try {
const response = await fetch('/MediaBarEnhanced/Config');
if (response.ok) {
const pluginConfig = await response.json();
if (pluginConfig) {
for (const key in pluginConfig) {
const camelKey = key.charAt(0).toLowerCase() + key.slice(1);
if (CONFIG.hasOwnProperty(camelKey)) {
CONFIG[camelKey] = pluginConfig[key];
}
}
STATE.slideshow.isMuted = CONFIG.startMuted;
if (!CONFIG.enableLoadingScreen) {
const loader = document.querySelector(".bar-loading");
if (loader) {
loader.remove();
}
}
// Sync to LocalStorage for next load
localStorage.setItem('mediaBarEnhanced-enableLoadingScreen', CONFIG.enableLoadingScreen);
console.log("✅ MediaBarEnhanced config loaded", CONFIG);
}
}
} catch (e) {
console.error("Failed to load MediaBarEnhanced config", e);
}
};
waitForApiClientAndInitialize();
/**
* Utility functions for slide creation and management
*/
const SlideUtils = {
/**
* Sorts items based on configuration
* @param {Array<Object>} items - Array of item objects
* @param {string} sortBy - Sort criteria
* @param {string} sortOrder - Sort order 'Ascending' or 'Descending'
* @returns {Array<Object>} Sorted array of items
*/
sortItems(items, sortBy, sortOrder) {
if (sortBy === 'Random' || sortBy === 'Original') {
return items;
}
const simpleCompare = (a, b) => {
if (a < b) return -1;
if (a > b) return 1;
return 0;
};
const sorted = [...items].sort((a, b) => {
let valA, valB;
switch (sortBy) {
case 'PremiereDate':
valA = new Date(a.PremiereDate).getTime();
valB = new Date(b.PremiereDate).getTime();
break;
case 'ProductionYear':
valA = a.ProductionYear || 0;
valB = b.ProductionYear || 0;
break;
case 'CriticRating':
valA = a.CriticRating || 0;
valB = b.CriticRating || 0;
break;
case 'CommunityRating':
valA = a.CommunityRating || 0;
valB = b.CommunityRating || 0;
break;
case 'Runtime':
valA = a.RunTimeTicks || 0;
valB = b.RunTimeTicks || 0;
break;
case 'Name':
valA = (a.Name || '').toLowerCase();
valB = (b.Name || '').toLowerCase();
break;
default:
return 0;
}
return simpleCompare(valA, valB);
});
if (sortOrder === 'Descending') {
sorted.reverse();
}
return sorted;
},
/**
* Shuffles array elements randomly
* @param {Array} array - Array to shuffle
* @returns {Array} Shuffled array
*/
shuffleArray(array) {
const newArray = [...array];
for (let i = newArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
}
return newArray;
},
/**
* Truncates text to specified length and adds ellipsis
* @param {HTMLElement} element - Element containing text to truncate
* @param {number} maxLength - Maximum length before truncation
*/
truncateText(element, maxLength) {
if (!element) return;
const text = element.innerText || element.textContent;
if (text && text.length > maxLength) {
element.innerText = text.substring(0, maxLength) + "...";
}
},
/**
* Creates a separator icon element
* @returns {HTMLElement} Separator element
*/
createSeparator() {
const separator = document.createElement("i");
separator.className = "material-icons fiber_manual_record separator-icon"; //material-icons radio_button_off
return separator;
},
/**
* Creates a DOM element with attributes and properties
* @param {string} tag - Element tag name
* @param {Object} attributes - Element attributes
* @param {string|HTMLElement} [content] - Element content
* @returns {HTMLElement} Created element
*/
createElement(tag, attributes = {}, content = null) {
const element = document.createElement(tag);
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 === "onclick" && typeof value === "function") {
element.addEventListener("click", value);
} else {
element.setAttribute(key, value);
}
});
if (content) {
if (typeof content === "string") {
element.textContent = content;
} else {
element.appendChild(content);
}
}
return element;
},
/**
* Find or create the slides container
* @returns {HTMLElement} Slides container element
*/
getOrCreateSlidesContainer() {
let container = document.getElementById("slides-container");
if (!container) {
container = this.createElement("div", {
id: "slides-container",
className: "noautofocus",
tabIndex: "-1"
});
document.body.appendChild(container);
}
return container;
},
/**
* Formats genres into a readable string
* @param {Array} genresArray - Array of genre strings
* @returns {string} Formatted genres string
*/
parseGenres(genresArray) {
if (Array.isArray(genresArray) && genresArray.length > 0) {
return genresArray.slice(0, 3).join(this.createSeparator().outerHTML);
}
return "No Genre Available";
},
/**
* Creates a loading indicator
* @returns {HTMLElement} Loading indicator element
*/
createLoadingIndicator() {
const loadingIndicator = this.createElement("div", {
className: "slide-loading-indicator",
innerHTML: `
<div class="spinner">
<div class="bounce1"></div>
<div class="bounce2"></div>
<div class="bounce3"></div>
</div>
`,
});
return loadingIndicator;
},
/**
* Loads the YouTube IFrame API if not already loaded
* @returns {Promise<void>}
*/
loadYouTubeIframeAPI() {
if (STATE.slideshow.ytPromise) return STATE.slideshow.ytPromise;
STATE.slideshow.ytPromise = new Promise((resolve) => {
if (window.YT && window.YT.Player) {
resolve(window.YT);
return;
}
window.onYouTubeIframeAPIReady = () => resolve(window.YT);
if (!document.querySelector('script[src*="youtube.com/iframe_api"]')) {
const tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api";
const firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
}
});
return STATE.slideshow.ytPromise;
},
/**
* Opens a modal video player
* @param {string} url - Video URL
*/
openVideoModal(url) {
const existingModal = document.getElementById('video-modal-overlay');
if (existingModal) existingModal.remove();
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
STATE.slideshow.isPaused = true;
const overlay = this.createElement('div', {
id: 'video-modal-overlay'
});
const closeModal = () => {
overlay.remove();
STATE.slideshow.isPaused = false;
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.start();
}
};
const closeButton = this.createElement('button', {
className: 'modal-close-button',
innerHTML: '<i class="material-icons">close</i>',
onclick: closeModal
});
const contentContainer = this.createElement('div', {
className: 'video-modal-content'
});
let videoId = null;
let isYoutube = false;
try {
const urlObj = new URL(url);
if (urlObj.hostname.includes('youtube.com') || urlObj.hostname.includes('youtu.be')) {
isYoutube = true;
videoId = urlObj.searchParams.get('v');
if (!videoId && urlObj.hostname.includes('youtu.be')) {
videoId = urlObj.pathname.substring(1);
}
}
} catch (e) {
console.warn("Invalid URL for modal:", url);
}
if (isYoutube && videoId) {
const playerDiv = this.createElement('div', { id: 'modal-yt-player' });
contentContainer.appendChild(playerDiv);
overlay.append(closeButton, contentContainer);
document.body.appendChild(overlay);
this.loadYouTubeIframeAPI().then(() => {
new YT.Player('modal-yt-player', {
height: '100%',
width: '100%',
videoId: videoId,
playerVars: {
autoplay: 1,
controls: 1,
iv_load_policy: 3,
rel: 0,
playsinline: 1,
origin: window.location.origin,
widget_referrer: window.location.href,
enablejsapi: 1
}
});
});
} else {
const video = this.createElement('video', {
src: url,
controls: true,
autoplay: true,
className: 'video-modal-player'
});
contentContainer.appendChild(video);
overlay.append(closeButton, contentContainer);
document.body.appendChild(overlay);
}
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
closeModal();
}
});
},
};
/**
* Localization utilities for fetching and using Jellyfin translations
*/
const LocalizationUtils = {
translations: {},
locale: null,
isLoading: {},
cachedLocale: null,
chunkUrlCache: {},
/**
* Gets the current locale from user preference, server config, or HTML tag
* @returns {Promise<string>} Locale code (e.g., "de", "en-us")
*/
async getCurrentLocale() {
if (this.cachedLocale) {
return this.cachedLocale;
}
let locale = null;
try {
if (window.ApiClient && typeof window.ApiClient.deviceId === 'function') {
const deviceId = window.ApiClient.deviceId();
if (deviceId) {
const deviceKey = `${deviceId}-language`;
locale = localStorage.getItem(deviceKey).toLowerCase();
}
}
if (!locale) {
locale = localStorage.getItem("language").toLowerCase();
}
} catch (e) {
console.warn("Could not access localStorage for language:", e);
}
if (!locale) {
const langAttr = document.documentElement.getAttribute("lang");
if (langAttr) {
locale = langAttr.toLowerCase();
}
}
if (window.ApiClient && STATE.jellyfinData?.accessToken) {
try {
const userId = window.ApiClient.getCurrentUserId();
if (userId) {
const userUrl = window.ApiClient.getUrl(`Users/${userId}`);
const userResponse = await fetch(userUrl, {
headers: ApiUtils.getAuthHeaders(),
});
if (userResponse.ok) {
const userData = await userResponse.json();
if (userData.Configuration?.AudioLanguagePreference) {
locale = userData.Configuration.AudioLanguagePreference.toLowerCase();
}
}
}
} catch (error) {
console.warn("Could not fetch user audio language preference:", error);
}
}
if (!locale && window.ApiClient && STATE.jellyfinData?.accessToken) {
try {
const configUrl = window.ApiClient.getUrl('System/Configuration');
const configResponse = await fetch(configUrl, {
headers: ApiUtils.getAuthHeaders(),
});
if (configResponse.ok) {
const configData = await configResponse.json();
if (configData.PreferredMetadataLanguage) {
locale = configData.PreferredMetadataLanguage.toLowerCase();
if (configData.MetadataCountryCode) {
locale = `${locale}-${configData.MetadataCountryCode.toLowerCase()}`;
}
}
}
} catch (error) {
console.warn("Could not fetch server metadata language preference:", error);
}
}
if (!locale) {
const navLang = navigator.language || navigator.userLanguage;
locale = navLang ? navLang.toLowerCase() : "en-us";
}
// Convert 3-letter country codes to 2-letter if necessary
if (locale.length === 3) {
const countriesData = await window.ApiClient.getCountries();
const countryData = Object.values(countriesData).find(countryData => countryData.ThreeLetterISORegionName === locale.toUpperCase());
if (countryData) {
locale = countryData.TwoLetterISORegionName.toLowerCase();
}
}
this.cachedLocale = locale;
return locale;
},
/**
* Finds the translation chunk URL from performance entries
* @param {string} locale - Locale code
* @returns {string|null} URL to translation chunk or null
*/
findTranslationChunkUrl(locale) {
const localePrefix = locale.split('-')[0];
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;
},
/**
* Fetches and loads translations from the chunk JSON
* @param {string} locale - Locale code
* @returns {Promise<void>}
*/
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}`);
}
/**
* @example
* Standard version
* ```js
* "use strict";
* (self.webpackChunk = self.webpackChunk || []).push([[62634], {
* 30985: function(e) {
* e.exports = JSON.parse('{"Absolute":"..."}')
* }
* }]);
* ```
*
* Minified version
* ```js
* "use strict";(self.webpackChunk=self.webpackChunk||[]).push([[24072],{60715:function(e){e.exports=JSON.parse('{"Absolute":"..."}')}}]);
* ```
*/
const chunkText = await response.text();
const replaceEscaped = (text) =>
text.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\\\/g, '\\').replace(/\\'/g, "'");
// 1. Try to remove start and end wrappers first
try {
// Matches from start of file to the beginning of JSON.parse('
const START = /^(.*)JSON\.parse\(['"]/gms;
// Matches from the end of the JSON string to the end of the file
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.');
// Try alternative extraction below
}
// 2. Try to extract only the JSON string directly
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.');
// Try direct extraction
}
}
// 3. Fallback: extract everything between the first { and the last }
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;
},
/**
* Gets a localized string (synchronous - translations must be loaded first)
* @param {string} key - Localization key (e.g., "EndsAtValue", "Play")
* @param {string} fallback - Fallback English string
* @param {...any} args - Optional arguments for placeholders (e.g., {0}, {1})
* @returns {string} Localized string or fallback
*/
getLocalizedString(key, fallback, ...args) {
const locale = this.cachedLocale || 'en-us';
let translated = this.translations[locale]?.[key] || fallback;
if (args.length > 0) {
for (let i = 0; i < args.length; i++) {
translated = translated.replace(new RegExp(`\\{${i}\\}`, 'g'), args[i]);
}
}
return translated;
}
};
/**
* API utilities for fetching data from Jellyfin server
*/
const ApiUtils = {
/**
* Fetches details for a specific item by ID
* @param {string} itemId - Item ID
* @returns {Promise<Object>} Item details
*/
async fetchItemDetails(itemId) {
try {
if (STATE.slideshow.loadedItems[itemId]) {
return STATE.slideshow.loadedItems[itemId];
}
const response = await fetch(
// `${STATE.jellyfinData.serverAddress}/Items/${itemId}`,
`${STATE.jellyfinData.serverAddress}/Items/${itemId}?Fields=Overview,RemoteTrailers,Genres,CommunityRating,CriticRating,OfficialRating,PremiereDate,ProductionYear,MediaSources,RunTimeTicks,LocalTrailerCount`,
{
headers: this.getAuthHeaders(),
}
);
if (!response.ok) {
throw new Error(`Failed to fetch item details: ${response.statusText}`);
}
const itemData = await response.json();
STATE.slideshow.loadedItems[itemId] = itemData;
return itemData;
} catch (error) {
console.error(`Error fetching details for item ${itemId}:`, error);
return null;
}
},
/**
* Fetch item IDs from the list file
* @returns {Promise<Array>} Array of item IDs
*/
// MARK: LIST FILE
async fetchItemIdsFromList() {
try {
const listFileName = `${STATE.jellyfinData.serverAddress}/web/avatars/list.txt?userId=${STATE.jellyfinData.userId}`;
const response = await fetch(listFileName);
if (!response.ok) {
console.warn("list.txt not found or inaccessible. Using random items.");
return [];
}
const text = await response.text();
return text
.split("\n")
.map((id) => id.trim())
.filter((id) => id)
.slice(1);
} catch (error) {
console.error("Error fetching list.txt:", error);
return [];
}
},
/**
* Fetches random items from the server
* @returns {Promise<Array>} Array of item objects
*/
async fetchItemIdsFromServer() {
try {
if (
!STATE.jellyfinData.accessToken ||
STATE.jellyfinData.accessToken === "Not Found"
) {
console.warn("Access token not available. Delaying API request...");
return [];
}
if (
!STATE.jellyfinData.serverAddress ||
STATE.jellyfinData.serverAddress === "Not Found"
) {
console.warn("Server address not available. Delaying API request...");
return [];
}
console.log("Fetching random items from server...");
let sortParams = `sortBy=${CONFIG.sortBy}`;
if (CONFIG.sortBy === 'Random' || CONFIG.sortBy === 'Original') {
sortParams = 'sortBy=Random';
} else {
sortParams += `&sortOrder=${CONFIG.sortOrder}`;
}
const response = await fetch(
`${STATE.jellyfinData.serverAddress}/Items?IncludeItemTypes=Movie,Series&Recursive=true&hasOverview=true&imageTypes=Logo,Backdrop&${sortParams}&isPlayed=False&enableUserData=true&Limit=${CONFIG.maxItems}&fields=Id`,
{
headers: this.getAuthHeaders(),
}
);
if (!response.ok) {
console.error(
`Failed to fetch items: ${response.status} ${response.statusText}`
);
return [];
}
const data = await response.json();
const items = data.Items || [];
console.log(
`Successfully fetched ${items.length} random items from server`
);
return items.map((item) => item.Id);
} catch (error) {
console.error("Error fetching item IDs:", error);
return [];
}
},
/**
* Get authentication headers for API requests
* @returns {Object} Headers object
*/
getAuthHeaders() {
return {
Authorization: `MediaBrowser Client="${STATE.jellyfinData.appName}", Device="${STATE.jellyfinData.deviceName}", DeviceId="${STATE.jellyfinData.deviceId}", Version="${STATE.jellyfinData.appVersion}", Token="${STATE.jellyfinData.accessToken}"`,
};
},
/**
* Send a command to play an item
* @param {string} itemId - Item ID to play
* @returns {Promise<boolean>} Success status
*/
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;
}
},
/**
* Gets current session ID
* @returns {Promise<string|null>} Session ID or null
*/
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;
}
},
//Favorites
async toggleFavorite(itemId, button) {
try {
const userId = STATE.jellyfinData.userId;
const isFavorite = button.classList.contains("favorited");
const url = `${STATE.jellyfinData.serverAddress}/Users/${userId}/FavoriteItems/${itemId}`;
const 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);
} catch (error) {
console.error("Error toggling favorite:", error);
}
},
/**
* Fetches SponsorBlock segments for a YouTube video
* @param {string} videoId - YouTube Video ID
* @returns {Promise<Object>} Object containing intro and outro segments
*/
async fetchSponsorBlockData(videoId) {
if (!CONFIG.useSponsorBlock) return { intro: null, outro: null };
try {
const response = await fetch(`https://sponsor.ajay.app/api/skipSegments?videoID=${videoId}&categories=["intro","outro"]`);
if (!response.ok) return { intro: null, outro: null };
const segments = await response.json();
let intro = null;
let outro = null;
segments.forEach(segment => {
if (segment.category === "intro" && Array.isArray(segment.segment)) {
intro = segment.segment;
} else if (segment.category === "outro" && Array.isArray(segment.segment)) {
outro = segment.segment;
}
});
return { intro, outro };
} catch (error) {
console.warn('Error fetching SponsorBlock data:', error);
return { intro: null, outro: null };
}
},
/**
* Searches for a Collection or Playlist by name
* @param {string} name - Name to search for
* @returns {Promise<string|null>} ID of the first match or null
*/
async findCollectionOrPlaylistByName(name) {
try {
const response = await fetch(
`${STATE.jellyfinData.serverAddress}/Items?IncludeItemTypes=BoxSet,Playlist&Recursive=true&searchTerm=${encodeURIComponent(name)}&Limit=1&fields=Id&userId=${STATE.jellyfinData.userId}`,
{
headers: this.getAuthHeaders(),
}
);
if (!response.ok) {
console.warn(`Failed to search for '${name}'`);
return null;
}
const data = await response.json();
if (data.Items && data.Items.length > 0) {
return data.Items[0].Id;
}
return null;
} catch (error) {
console.error(`Error searching for '${name}':`, error);
return null;
}
},
/**
* Fetches items belonging to a collection (BoxSet)
* @param {string} collectionId - ID of the collection
* @returns {Promise<Array>} Array of item IDs
*/
async fetchCollectionItems(collectionId) {
try {
const response = await fetch(
`${STATE.jellyfinData.serverAddress}/Items?ParentId=${collectionId}&Recursive=true&IncludeItemTypes=Movie,Series&fields=Id,Type&userId=${STATE.jellyfinData.userId}`,
{
headers: this.getAuthHeaders(),
}
);
if (!response.ok) {
console.warn(`Failed to fetch collection items for ${collectionId}`);
return [];
}
const data = await response.json();
const items = data.Items || [];
console.log(`Resolved collection ${collectionId} to ${items.length} items`);
return items.map(i => ({ Id: i.Id, Type: i.Type }));
} catch (error) {
console.error(`Error fetching collection items for ${collectionId}:`, error);
return [];
}
},
/**
* Fetches the first local trailer for an item
* @param {string} itemId - Item ID
* @returns {Promise<Object|null>} Trailer data object {id, url} or null
*/
async fetchLocalTrailer(itemId) {
try {
const response = await fetch(
`${STATE.jellyfinData.serverAddress}/Users/${STATE.jellyfinData.userId}/Items/${itemId}/LocalTrailers`,
{
headers: this.getAuthHeaders(),
}
);
if (!response.ok) {
return null;
}
const trailers = await response.json();
if (trailers && trailers.length > 0) {
const trailer = trailers[0];
const mediaSourceId = trailer.MediaSources && trailer.MediaSources[0] ? trailer.MediaSources[0].Id : trailer.Id;
// Return object with ID and URL
return {
id: trailer.Id,
url: `${STATE.jellyfinData.serverAddress}/Videos/${trailer.Id}/stream.mp4?Static=true&mediaSourceId=${mediaSourceId}&api_key=${STATE.jellyfinData.accessToken}`
};
}
return null;
} catch (error) {
console.error(`Error fetching local trailer for ${itemId}:`, error);
return null;
}
}
};
/**
* Class for managing slide timing
*/
class SlideTimer {
/**
* Creates a new slide timer
* @param {Function} callback - Function to call on interval
* @param {number} interval - Interval in milliseconds
*/
constructor(callback, interval) {
this.callback = callback;
this.interval = interval;
this.timerId = null;
this.start();
}
/**
* Stops the timer
* @returns {SlideTimer} This instance for chaining
*/
stop() {
if (this.timerId) {
clearInterval(this.timerId);
this.timerId = null;
}
return this;
}
/**
* Starts the timer
* @returns {SlideTimer} This instance for chaining
*/
start() {
if (!this.timerId) {
this.timerId = setInterval(this.callback, this.interval);
}
return this;
}
/**
* Restarts the timer
* @returns {SlideTimer} This instance for chaining
*/
restart() {
return this.stop().start();
}
}
/**
* Observer for handling slideshow visibility based on current page
*/
const VisibilityObserver = {
updateVisibility() {
const videoPlayer = document.querySelector('.videoPlayerContainer');
const trailerPlayer = document.querySelector('.youtubePlayerContainer');
// If a full screen video player is active, hide slideshow and stop playback
if ((videoPlayer && !videoPlayer.classList.contains('hide')) || (trailerPlayer && !trailerPlayer.classList.contains('hide'))) {
if (this._lastVisibleState !== 'player-active') {
this._lastVisibleState = 'player-active';
const container = document.getElementById("slides-container");
if (container) {
container.style.display = "none";
container.style.visibility = "hidden";
container.style.pointerEvents = "none";
}
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
SlideshowManager.stopAllPlayback();
}
return;
}
const activeTab = document.querySelector(".emby-tab-button-active");
const container = document.getElementById("slides-container");
if (!container) return;
const isVisible =
(window.location.hash === "#/home.html" ||
window.location.hash === "#/home") &&
activeTab &&
activeTab.getAttribute("data-index") === "0";
const newState = isVisible ? 'visible' : 'hidden';
// Only update DOM and trigger actions when state actually changes
if (this._lastVisibleState !== newState) {
this._lastVisibleState = newState;
container.style.display = isVisible ? "block" : "none";
container.style.visibility = isVisible ? "visible" : "hidden";
container.style.pointerEvents = isVisible ? "auto" : "none";
if (isVisible) {
if (STATE.slideshow.slideInterval && !STATE.slideshow.isPaused) {
STATE.slideshow.slideInterval.start();
SlideshowManager.resumeActivePlayback();
}
} else {
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
SlideshowManager.stopAllPlayback();
}
}
},
/**
* Initializes visibility observer
*/
init() {
const observer = new MutationObserver(() => this.updateVisibility());
// let debounceTimer = null;
// const observer = new MutationObserver(() => {
// if (debounceTimer) clearTimeout(debounceTimer);
// debounceTimer = setTimeout(() => this.updateVisibility(), 250);
// });
observer.observe(document.body, { childList: true, subtree: true });
document.body.addEventListener("click", () => this.updateVisibility());
window.addEventListener("hashchange", () => this.updateVisibility());
this.updateVisibility();
},
};
/**
* Slideshow UI creation and management
*/
const SlideCreator = {
/**
* Builds a tag-based image URL for cache-friendly image requests
* @param {Object} item - Item data containing ImageTags
* @param {string} imageType - Image type (Backdrop, Logo, Primary, etc.)
* @param {number} [index] - Image index (for Backdrop, Primary, etc.)
* @param {string} serverAddress - Server address
* @param {number} [quality] - Image quality (0-100). If tag is available, both tag and quality are used.
* @returns {string} Image URL with tag parameter (and quality if tag available), or quality-only fallback
*/
buildImageUrl(item, imageType, index, serverAddress, quality) {
const itemId = item.Id;
let tag = null;
// Handle Backdrop images
if (imageType === "Backdrop") {
// Check BackdropImageTags array first
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];
}
}
// Fallback to ImageTags.Backdrop if BackdropImageTags not available
if (!tag && item.ImageTags && item.ImageTags.Backdrop) {
tag = item.ImageTags.Backdrop;
}
} else {
// For other image types (Logo, Primary, etc.), use ImageTags
if (item.ImageTags && item.ImageTags[imageType]) {
tag = item.ImageTags[imageType];
}
}
// Build base URL path
let baseUrl;
if (index !== undefined) {
baseUrl = `${serverAddress}/Items/${itemId}/Images/${imageType}/${index}`;
} else {
baseUrl = `${serverAddress}/Items/${itemId}/Images/${imageType}`;
}
// Build URL with tag and quality if tag is available, otherwise quality-only fallback
if (tag) {
// Use both tag and quality for cacheable, quality-controlled images
const qualityParam = quality !== undefined ? `&quality=${quality}` : '';
return `${baseUrl}?tag=${tag}${qualityParam}`;
} else {
// Fallback to quality-only URL if no tag is available
const qualityParam = quality !== undefined ? quality : 90;
return `${baseUrl}?quality=${qualityParam}`;
}
},
/**
* Creates a slide element for an item
* @param {Object} item - Item data
* @param {string} title - Title type (Movie/TV Show)
* @returns {HTMLElement} Slide element
*/
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("a", {
className: "slide",
target: "_top",
rel: "noreferrer",
tabIndex: 0,
"data-item-id": itemId,
});
let backdrop;
let isVideo = false;
let trailerUrl = null;
// 1. Check for Remote/Local Trailers
// Priority: Custom Config URL > (PreferLocal -> Local) > Metadata RemoteTrailer
// 1a. Custom URL override
if (STATE.slideshow.customTrailerUrls && STATE.slideshow.customTrailerUrls[itemId]) {
const customValue = STATE.slideshow.customTrailerUrls[itemId];
// Check if the custom value is a Jellyfin Item ID (GUID)
const guidMatch = customValue.match(/^([0-9a-f]{32})$/i);
if (guidMatch) {
const videoId = guidMatch[1];
console.log(`Using custom local video ID for ${itemId}: ${videoId}`);
trailerUrl = {
id: videoId,
url: `${STATE.jellyfinData.serverAddress}/Videos/${videoId}/stream.mp4?Static=true&api_key=${STATE.jellyfinData.accessToken}`
};
} else {
// Assume it's a standard URL (YouTube, etc.)
trailerUrl = customValue;
console.log(`Using custom trailer URL for ${itemId}: ${trailerUrl}`);
}
}
// 1b. Check Local Trailer if preferred
else if (CONFIG.preferLocalTrailers && item.LocalTrailerCount > 0 && item.localTrailerUrl) {
trailerUrl = item.localTrailerUrl;
console.log(`Using local trailer for ${itemId}: ${trailerUrl}`);
}
// 1c. Fallback to Remote Trailer
else if (item.RemoteTrailers && item.RemoteTrailers.length > 0) {
trailerUrl = item.RemoteTrailers[0].Url;
}
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Client Setting Overrides
const enableVideo = MediaBarEnhancedSettingsManager.getSetting('videoBackdrops', CONFIG.enableVideoBackdrop);
const enableMobileVideo = MediaBarEnhancedSettingsManager.getSetting('mobileVideo', CONFIG.enableMobileVideo);
const shouldPlayVideo = enableVideo && (!isMobile || enableMobileVideo);
if (trailerUrl && shouldPlayVideo) {
let isYoutube = false;
let videoId = null;
try {
let urlToCheck = trailerUrl;
if (typeof trailerUrl === 'object' && trailerUrl.url) {
urlToCheck = trailerUrl.url;
}
const urlObjChecked = new URL(urlToCheck);
if (urlObjChecked.hostname.includes('youtube.com') || urlObjChecked.hostname.includes('youtu.be')) {
isYoutube = true;
videoId = urlObjChecked.searchParams.get('v');
if (!videoId && urlObjChecked.hostname.includes('youtu.be')) {
videoId = urlObjChecked.pathname.substring(1);
}
}
} catch (e) {
console.warn("Invalid trailer URL:", trailerUrl);
}
if (isYoutube && videoId) {
isVideo = true;
// Create container for YouTube API
const videoClass = CONFIG.fullWidthVideo ? "video-backdrop-full" : "video-backdrop-default";
backdrop = SlideUtils.createElement("div", {
className: `backdrop video-backdrop ${videoClass}`,
id: `youtube-player-${itemId}`
});
// Initialize YouTube Player
SlideUtils.loadYouTubeIframeAPI().then(() => {
// Fetch SponsorBlock data
ApiUtils.fetchSponsorBlockData(videoId).then(segments => {
const playerVars = {
autoplay: 0,
mute: STATE.slideshow.isMuted ? 1 : 0,
controls: 0,
disablekb: 1,
fs: 0,
iv_load_policy: 3,
rel: 0,
loop: 0,
playsinline: 1,
origin: window.location.origin,
widget_referrer: window.location.href,
enablejsapi: 1
};
// Determine video quality
let quality = 'hd1080';
if (CONFIG.preferredVideoQuality === 'Maximum') {
quality = 'highres';
} else if (CONFIG.preferredVideoQuality === '720p') {
quality = 'hd720';
} else if (CONFIG.preferredVideoQuality === '1080p') {
quality = 'hd1080';
} else { // Auto or fallback
// If screen is wider than 1920, prefer highres, otherwise 1080p
quality = window.screen.width > 1920 ? 'highres' : 'hd1080';
}
playerVars.suggestedQuality = quality;
// Apply SponsorBlock start/end times
if (segments.intro) {
playerVars.start = Math.ceil(segments.intro[1]);
console.info(`SponsorBlock intro detected for video ${videoId}: skipping to ${playerVars.start}s`);
}
if (segments.outro) {
playerVars.end = Math.floor(segments.outro[0]);
console.info(`SponsorBlock outro detected for video ${videoId}: ending at ${playerVars.end}s`);
}
STATE.slideshow.videoPlayers[itemId] = new YT.Player(`youtube-player-${itemId}`, {
height: '100%',
width: '100%',
videoId: videoId,
playerVars: playerVars,
events: {
'onReady': (event) => {
// Prevent iframe from stealing focus (critical for TV mode)
const iframe = event.target.getIframe();
if (iframe) {
iframe.setAttribute('tabindex', '-1');
iframe.setAttribute('inert', '');
// Preserve video-backdrop class on the iframe (YT API replaces the original div)
iframe.classList.add('backdrop', 'video-backdrop');
if (CONFIG.fullWidthVideo) {
iframe.classList.add('video-backdrop-full');
} else {
iframe.classList.add('video-backdrop-default');
}
}
// Store start/end time and videoId for later use
event.target._startTime = playerVars.start || 0;
event.target._endTime = playerVars.end || undefined;
event.target._videoId = videoId;
if (STATE.slideshow.isMuted) {
event.target.mute();
} else {
event.target.unMute();
event.target.setVolume(40);
}
if (typeof event.target.setPlaybackQuality === 'function') {
event.target.setPlaybackQuality(quality);
}
// Only play if this is the active slide and not paused
const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`);
const isVideoPlayerOpen = document.querySelector('.videoPlayerContainer') || document.querySelector('.youtubePlayerContainer');
const isActive = slide && slide.classList.contains('active');
const isHidden = document.hidden;
const isPaused = STATE.slideshow.isPaused;
const isPlayerOpen = isVideoPlayerOpen && !isVideoPlayerOpen.classList.contains('hide');
console.log(`[MBE-READY] onReady for ${itemId}: active=${isActive}, hidden=${isHidden}, paused=${isPaused}, playerOpen=${!!isPlayerOpen}`);
if (isActive && !isHidden && !isPaused && !isPlayerOpen) {
console.log(`[MBE-READY] → Playing video for ${itemId}`);
event.target.playVideo();
// Check if it actually started playing after a short delay (handling autoplay blocks)
const timeoutId = setTimeout(() => {
// Re-check conditions before processing fallback
const isVideoPlayerOpenNow = document.querySelector('.videoPlayerContainer') || document.querySelector('.youtubePlayerContainer');
if (document.hidden || (isVideoPlayerOpenNow && !isVideoPlayerOpenNow.classList.contains('hide')) || !slide.classList.contains('active')) {
console.log(`Navigation detected during autoplay check for ${itemId}, stopping video.`);
try {
event.target.stopVideo();
} catch (e) { console.warn("Error stopping video in timeout:", e); }
return;
}
if (event.target.getPlayerState() !== YT.PlayerState.PLAYING &&
event.target.getPlayerState() !== YT.PlayerState.BUFFERING) {
console.warn(`Autoplay blocked for ${itemId}, attempting muted fallback`);
event.target.mute();
event.target.playVideo();
}
}, 1000);
if (!STATE.slideshow.autoplayTimeouts) STATE.slideshow.autoplayTimeouts = [];
STATE.slideshow.autoplayTimeouts.push(timeoutId);
// Pause slideshow timer when video starts if configured
if (CONFIG.waitForTrailerToEnd && STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
}
},
'onStateChange': (event) => {
const stateNames = {[-1]: 'UNSTARTED', 0: 'ENDED', 1: 'PLAYING', 2: 'PAUSED', 3: 'BUFFERING', 5: 'CUED'};
console.log(`[MBE-STATE] ${itemId}: ${stateNames[event.data] || event.data}`);
if (event.data === YT.PlayerState.ENDED) {
SlideshowManager.nextSlide();
}
},
'onError': (event) => {
console.warn(`YouTube player error ${event.data} for video ${videoId}`);
// Fallback to next slide on error
if (CONFIG.waitForTrailerToEnd) {
SlideshowManager.nextSlide();
}
}
}
});
});
});
// 2. Check for local video trailers in MediaSources if yt is not available
} else if (!isYoutube) {
isVideo = true;
const videoAttributes = {
className: "backdrop video-backdrop",
src: (typeof trailerUrl === 'object' ? trailerUrl.url : trailerUrl),
autoplay: false,
preload: "auto",
loop: false,
disablePictureInPicture: true,
style: "object-fit: cover; object-position: center center; width: 100%; height: 100%; position: absolute; top: 0; left: 0; pointer-events: none;"
};
if (STATE.slideshow.isMuted) {
videoAttributes.muted = "";
}
backdrop = SlideUtils.createElement("video", videoAttributes);
if (!STATE.slideshow.isMuted) {
backdrop.volume = 0.4;
}
STATE.slideshow.videoPlayers[itemId] = backdrop;
backdrop.addEventListener('play', (event) => {
const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`);
if (!slide || !slide.classList.contains('active')) {
console.log(`Local video ${itemId} started playing but is not active, pausing.`);
event.target.pause();
event.target.currentTime = 0;
return;
}
if (CONFIG.waitForTrailerToEnd && STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
});
backdrop.addEventListener('ended', () => {
SlideshowManager.nextSlide();
});
backdrop.addEventListener('error', () => {
if (CONFIG.waitForTrailerToEnd) {
SlideshowManager.nextSlide();
}
});
}
}
if (!isVideo) {
backdrop = SlideUtils.createElement("img", {
className: "backdrop high-quality",
src: this.buildImageUrl(item, "Backdrop", 0, serverAddress, 60),
alt: LocalizationUtils.getLocalizedString('Backdrop', 'Backdrop'),
loading: "eager",
});
}
const backdropOverlay = SlideUtils.createElement("div", {
className: "backdrop-overlay",
});
const backdropContainer = SlideUtils.createElement("div", {
className: "backdrop-container" + (isVideo && CONFIG.fullWidthVideo ? " full-width-video" : ""),
});
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 plot = item.Overview || "No overview available";
const plotElement = SlideUtils.createElement(
"div",
{
className: "plot",
},
plot
);
SlideUtils.truncateText(plotElement, CONFIG.maxPlotLength);
const plotContainer = SlideUtils.createElement("div", {
className: "plot-container",
});
plotContainer.appendChild(plotElement);
const gradientOverlay = SlideUtils.createElement("div", {
className: "gradient-overlay" + (isVideo && CONFIG.fullWidthVideo ? " full-width-video" : ""),
});
const infoContainer = SlideUtils.createElement("div", {
className: "info-container",
});
const ratingInfo = this.createRatingInfo(item);
infoContainer.appendChild(ratingInfo);
const genreElement = SlideUtils.createElement("div", {
className: "genre",
innerHTML: SlideUtils.parseGenres(item.Genres)
});
const buttonContainer = SlideUtils.createElement("div", {
className: "button-container",
});
const playButton = this.createPlayButton(itemId);
const detailButton = this.createDetailButton(itemId);
const favoriteButton = this.createFavoriteButton(item);
if (trailerUrl && !isVideo && CONFIG.showTrailerButton) {
const trailerButton = this.createTrailerButton(trailerUrl);
buttonContainer.append(detailButton, playButton, trailerButton, favoriteButton);
} else {
buttonContainer.append(detailButton, playButton, favoriteButton);
}
slide.append(
logoContainer,
backdropContainer,
gradientOverlay,
featuredContent,
plotContainer,
infoContainer,
genreElement,
buttonContainer
);
return slide;
},
/**
* Creates the rating information element
* @param {Object} item - Item data
* @returns {HTMLElement} Rating information element
*/
createRatingInfo(item) {
const {
CommunityRating: communityRating,
CriticRating: criticRating,
OfficialRating: ageRating,
PremiereDate: premiereDate,
RunTimeTicks: runtime,
ChildCount: seasonCount,
} = item;
const miscInfo = SlideUtils.createElement("div", {
className: "misc-info",
});
// Community Rating Section (IMDb)
if (typeof communityRating === "number") {
const container = SlideUtils.createElement("div", {
className: "star-rating-container",
innerHTML: `<span class="material-icons community-rating-star star" aria-hidden="true"></span>${communityRating.toFixed(1)}`,
});
miscInfo.appendChild(container);
miscInfo.appendChild(SlideUtils.createSeparator());
}
// Critic Rating Section (Rotten Tomatoes)
if (typeof criticRating === "number") {
const svgIcon = criticRating < 60 ? CONFIG.IMAGE_SVG.rottenTomato : CONFIG.IMAGE_SVG.freshTomato;
const container = SlideUtils.createElement("div", {
className: "critic-rating",
innerHTML: `${svgIcon}${criticRating.toFixed(0)}%`,
})
miscInfo.appendChild(container);
miscInfo.appendChild(SlideUtils.createSeparator());
};
// Year Section
if (typeof premiereDate === "string" && !isNaN(new Date(premiereDate))) {
const container = SlideUtils.createElement("div", {
className: "date",
innerHTML: new Date(premiereDate).getFullYear(),
});
miscInfo.appendChild(container);
miscInfo.appendChild(SlideUtils.createSeparator());
};
// Age Rating Section
if (typeof ageRating === "string") {
const container = SlideUtils.createElement("div", {
className: "age-rating mediaInfoOfficialRating",
rating: ageRating,
ariaLabel: `Content rated ${ageRating}`,
title: `Rating: ${ageRating}`,
innerHTML: ageRating,
});
miscInfo.appendChild(container);
miscInfo.appendChild(SlideUtils.createSeparator());
};
// Runtime / Seasons Section
if (seasonCount !== undefined || runtime !== undefined) {
const container = SlideUtils.createElement("div", {
className: "runTime",
});
if (seasonCount) {
const seasonText = seasonCount <= 1 ? LocalizationUtils.getLocalizedString('Season', 'Season') : LocalizationUtils.getLocalizedString('TypeOptionPluralSeason', 'Seasons');
container.innerHTML = `${seasonCount} ${seasonText}`;
} else {
const milliseconds = runtime / 10000;
const currentTime = new Date();
const endTime = new Date(currentTime.getTime() + milliseconds);
const options = { hour: "2-digit", minute: "2-digit", hour12: false };
const formattedEndTime = endTime.toLocaleTimeString([], options);
const endsAtText = LocalizationUtils.getLocalizedString('EndsAtValue', 'Ends at {0}', formattedEndTime);
container.innerText = endsAtText;
}
miscInfo.appendChild(container);
}
return miscInfo;
},
/**
* Creates a play button for an item
* @param {string} itemId - Item ID
* @returns {HTMLElement} Play button element
*/
createPlayButton(itemId) {
const playText = LocalizationUtils.getLocalizedString('Play', 'Play');
return SlideUtils.createElement("button", {
className: "detailButton btnPlay play-button",
innerHTML: `
<span class="play-text">${playText}</span>
`,
tabIndex: "0",
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
ApiUtils.playItem(itemId);
},
});
},
/**
* Creates a detail button for an item
* @param {string} itemId - Item ID
* @returns {HTMLElement} Detail button element
*/
createDetailButton(itemId) {
return SlideUtils.createElement("button", {
className: "detailButton detail-button",
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}`;
}
},
});
},
/**
* Creates a favorite button for an item
* @param {string} itemId - Item ID
* @returns {HTMLElement} Favorite button element
*/
createFavoriteButton(item) {
const isFavorite = item.UserData && item.UserData.IsFavorite === true;
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;
},
/**
* Creates a trailer button
* @param {string|Object} trailerInfo - Trailer URL string or object {id, url}
* @returns {HTMLElement} Trailer button element
*/
createTrailerButton(trailerInfo) {
const trailerText = LocalizationUtils.getLocalizedString('Trailer', 'Trailer');
let url = trailerInfo;
let localTrailerId = null;
if (typeof trailerInfo === 'object' && trailerInfo !== null) {
url = trailerInfo.url;
localTrailerId = trailerInfo.id;
}
return SlideUtils.createElement("button", {
className: "detailButton trailer-button",
innerHTML: `<span class="material-icons">movie</span> <span class="trailer-text">${trailerText}</span>`,
tabIndex: "0",
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
if (localTrailerId) {
// Play local trailer using native player
ApiUtils.playItem(localTrailerId);
} else {
SlideUtils.openVideoModal(url);
}
},
});
},
/**
* Creates a placeholder slide for loading
* @param {string} itemId - Item ID to load
* @returns {HTMLElement} Placeholder slide element
*/
createLoadingPlaceholder(itemId) {
const placeholder = SlideUtils.createElement("a", {
className: "slide placeholder",
"data-item-id": itemId,
style: {
display: "none",
opacity: "0",
transition: `opacity ${CONFIG.fadeTransitionDuration}ms ease-in-out`,
},
});
const loadingIndicator = SlideUtils.createLoadingIndicator();
placeholder.appendChild(loadingIndicator);
return placeholder;
},
/**
* Creates a slide for an item and adds it to the container
* @param {string} itemId - Item ID
* @returns {Promise<HTMLElement>} Created slide element
*/
async createSlideForItemId(itemId) {
try {
if (STATE.slideshow.createdSlides[itemId]) {
return document.querySelector(`.slide[data-item-id="${itemId}"]`);
}
const container = SlideUtils.getOrCreateSlidesContainer();
const item = await ApiUtils.fetchItemDetails(itemId);
// Pre-fetch local trailer URL if needed
if (CONFIG.preferLocalTrailers && item.LocalTrailerCount > 0) {
item.localTrailerUrl = await ApiUtils.fetchLocalTrailer(itemId);
}
const slideElement = this.createSlideElement(
item,
item.Type === "Movie" ? "Movie" : "TV Show"
);
container.appendChild(slideElement);
STATE.slideshow.createdSlides[itemId] = true;
return slideElement;
} catch (error) {
console.error("Error creating slide for item:", error, itemId);
return null;
}
},
};
/**
* Manages slideshow functionality
*/
const SlideshowManager = {
createPaginationDots() {
let dotsContainer = document.querySelector(".dots-container");
if (!dotsContainer) {
dotsContainer = document.createElement("div");
dotsContainer.className = "dots-container";
document.getElementById("slides-container").appendChild(dotsContainer);
}
const totalItems = STATE.slideshow.totalItems || 0;
// Switch to counter style if too many items
if (totalItems > CONFIG.maxPaginationDots) {
const counter = document.createElement("span");
counter.className = "slide-counter";
counter.id = "slide-counter";
dotsContainer.appendChild(counter);
} else {
// Create dots for all items
for (let i = 0; i < totalItems; i++) {
const dot = document.createElement("span");
dot.className = "dot";
dot.setAttribute("data-index", i);
dotsContainer.appendChild(dot);
}
}
this.updateDots();
},
/**
* Updates active dot based on current slide
* Maps current slide to one of the 5 dots
*/
updateDots() {
const currentIndex = STATE.slideshow.currentSlideIndex;
const totalItems = STATE.slideshow.totalItems || 0;
// Handle Large List Counter
const counter = document.getElementById("slide-counter");
if (counter) {
counter.textContent = `${currentIndex + 1} / ${totalItems}`;
return;
}
// Handle Dots
const container = SlideUtils.getOrCreateSlidesContainer();
const dots = container.querySelectorAll(".dot");
// Fallback if dots exist but totalItems matched counter mode
if (dots.length === 0) return;
dots.forEach((dot, index) => {
if (index === currentIndex) {
dot.classList.add("active");
} else {
dot.classList.remove("active");
}
});
},
/**
* Updates current slide to the specified index
* @param {number} index - Slide index to display
*/
async updateCurrentSlide(index) {
if (STATE.slideshow.isTransitioning) {
return;
}
STATE.slideshow.isTransitioning = true;
let previousVisibleSlide;
try {
const container = SlideUtils.getOrCreateSlidesContainer();
const activeElement = document.activeElement;
let focusSelector = null;
if (container.contains(activeElement)) {
if (activeElement.classList.contains('play-button')) focusSelector = '.play-button';
else if (activeElement.classList.contains('detail-button')) focusSelector = '.detail-button';
else if (activeElement.classList.contains('favorite-button')) focusSelector = '.favorite-button';
else if (activeElement.classList.contains('trailer-button')) focusSelector = '.trailer-button';
}
const totalItems = STATE.slideshow.totalItems;
index = Math.max(0, Math.min(index, totalItems - 1));
const currentItemId = STATE.slideshow.itemIds[index];
let currentSlide = document.querySelector(
`.slide[data-item-id="${currentItemId}"]`
);
if (!currentSlide) {
currentSlide = await SlideCreator.createSlideForItemId(currentItemId);
this.upgradeSlideImageQuality(currentSlide);
if (!currentSlide) {
console.error(`Failed to create slide for item ${currentItemId}`);
STATE.slideshow.isTransitioning = false;
setTimeout(() => this.nextSlide(), 500);
return;
}
}
previousVisibleSlide = container.querySelector(".slide.active");
if (previousVisibleSlide) {
previousVisibleSlide.classList.remove("active");
}
currentSlide.classList.add("active");
// Restore focus for TV mode navigation continuity
requestAnimationFrame(() => {
if (focusSelector) {
const target = currentSlide.querySelector(focusSelector);
if (target) {
target.focus();
return;
}
}
// Always ensure container has focus in TV mode to keep keyboard navigation working
const isTvMode = (window.layoutManager && window.layoutManager.tv) ||
document.documentElement.classList.contains('layout-tv') ||
document.body.classList.contains('layout-tv');
if (isTvMode) {
container.focus({ preventScroll: true });
}
});
// Manage Video Playback: Stop others, Play current
this.pauseOtherVideos(currentItemId);
if (!STATE.slideshow.isPaused) {
this.playCurrentVideo(currentSlide, currentItemId);
} else {
// Check if new slide has video — Option B: un-pause for video slides
const videoBackdrop = currentSlide.querySelector('.video-backdrop');
if (videoBackdrop) {
STATE.slideshow.isPaused = false;
const pauseButton = document.querySelector('.pause-button');
if (pauseButton) {
pauseButton.innerHTML = '<i class="material-icons">pause</i>';
const pauseLabel = LocalizationUtils.getLocalizedString('ButtonPause', 'Pause');
pauseButton.setAttribute('aria-label', pauseLabel);
pauseButton.setAttribute('title', pauseLabel);
}
this.playCurrentVideo(currentSlide, currentItemId);
}
// Update mute button visibility
const muteButton = document.querySelector('.mute-button');
if (muteButton) {
muteButton.style.display = videoBackdrop ? 'block' : 'none';
}
}
const enableAnimations = MediaBarEnhancedSettingsManager.getSetting('slideAnimations', CONFIG.slideAnimationEnabled);
if (enableAnimations) {
const backdrop = currentSlide.querySelector(".backdrop");
if (backdrop && !backdrop.classList.contains("video-backdrop")) {
backdrop.classList.add("animate");
}
const logo = currentSlide.querySelector(".logo");
if (logo) logo.classList.add("animate");
}
STATE.slideshow.currentSlideIndex = index;
if (index === 0 || !previousVisibleSlide) {
const dotsContainer = container.querySelector(".dots-container");
if (dotsContainer) {
dotsContainer.style.opacity = "1";
}
}
setTimeout(() => {
const allSlides = container.querySelectorAll(".slide");
allSlides.forEach((slide) => {
if (slide !== currentSlide) {
slide.classList.remove("active");
}
});
}, CONFIG.fadeTransitionDuration);
this.preloadAdjacentSlides(index);
this.updateDots();
// Only restart interval if we are NOT waiting for a video to end
const hasVideo = currentSlide.querySelector('.video-backdrop') ||
(STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[currentItemId]);
if (STATE.slideshow.slideInterval && !STATE.slideshow.isPaused) {
if (CONFIG.waitForTrailerToEnd && hasVideo) {
STATE.slideshow.slideInterval.stop();
} else {
STATE.slideshow.slideInterval.restart();
}
}
this.pruneSlideCache();
} catch (error) {
console.error("Error updating current slide:", error);
} finally {
setTimeout(() => {
STATE.slideshow.isTransitioning = false;
if (previousVisibleSlide) {
const enableAnimations = MediaBarEnhancedSettingsManager.getSetting('slideAnimations', CONFIG.slideAnimationEnabled);
if (enableAnimations) {
const prevBackdrop = previousVisibleSlide.querySelector(".backdrop");
const prevLogo = previousVisibleSlide.querySelector(".logo");
if (prevBackdrop) prevBackdrop.classList.remove("animate");
if (prevLogo) prevLogo.classList.remove("animate");
}
}
}, CONFIG.fadeTransitionDuration);
}
},
/**
* Upgrades the image quality for all images in a slide
* @param {HTMLElement} slide - The slide element containing images to upgrade
*/
upgradeSlideImageQuality(slide) {
if (!slide) return;
const images = slide.querySelectorAll("img.low-quality");
images.forEach((img) => {
const highQualityUrl = img.getAttribute("data-high-quality");
// Prevent duplicate requests if already using high quality
if (highQualityUrl && img.src !== highQualityUrl) {
addThrottledRequest(highQualityUrl, () => {
img.src = highQualityUrl;
img.classList.remove("low-quality");
img.classList.add("high-quality");
});
}
});
},
/**
* Preloads adjacent slides for smoother transitions
* @param {number} currentIndex - Current slide index
*/
async preloadAdjacentSlides(currentIndex) {
const totalItems = STATE.slideshow.totalItems;
const preloadCount = Math.min(Math.max(CONFIG.preloadCount || 1, 1), 5);
// Preload next slides
for (let i = 1; i <= preloadCount; i++) {
const nextIndex = (currentIndex + i) % totalItems;
if (nextIndex === currentIndex) break;
const itemId = STATE.slideshow.itemIds[nextIndex];
SlideCreator.createSlideForItemId(itemId);
}
// Preload previous slides
for (let i = 1; i <= preloadCount; i++) {
const prevIndex = (currentIndex - i + totalItems) % totalItems;
if (prevIndex === currentIndex) break;
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);
},
/**
* Prunes the slide cache to prevent memory bloat
* Removes slides that are outside the viewing range
*/
pruneSlideCache() {
const currentIndex = STATE.slideshow.currentSlideIndex;
const keepRange = 5;
let prunedAny = false;
Object.keys(STATE.slideshow.createdSlides).forEach((itemId) => {
const index = STATE.slideshow.itemIds.indexOf(itemId);
if (index === -1) return;
const totalItems = STATE.slideshow.itemIds.length;
// Calculate wrapped distance
let distance = Math.abs(index - currentIndex);
if (totalItems > keepRange * 2) {
distance = Math.min(distance, totalItems - distance);
}
if (distance > keepRange) {
// Destroy video player if exists
if (STATE.slideshow.videoPlayers[itemId]) {
const player = STATE.slideshow.videoPlayers[itemId];
if (typeof player.destroy === 'function') {
player.destroy();
}
delete STATE.slideshow.videoPlayers[itemId];
}
delete STATE.slideshow.loadedItems[itemId];
const slide = document.querySelector(
`.slide[data-item-id="${itemId}"]`
);
if (slide) slide.remove();
delete STATE.slideshow.createdSlides[itemId];
prunedAny = true;
console.log(`Pruned slide ${itemId} at distance ${distance} from view`);
}
});
// After pruning, restore focus to container in TV mode
if (prunedAny) {
const isTvMode = (window.layoutManager && window.layoutManager.tv) ||
document.documentElement.classList.contains('layout-tv') ||
document.body.classList.contains('layout-tv');
if (isTvMode) {
// Use setTimeout to execute AFTER Jellyfin's focus manager processes the iframe removal
setTimeout(() => {
const container = document.getElementById("slides-container");
if (container && container.style.display !== 'none') {
container.focus({ preventScroll: true });
}
}, 0);
}
}
},
toggleMute() {
STATE.slideshow.isMuted = !STATE.slideshow.isMuted;
const isUnmuting = !STATE.slideshow.isMuted;
const muteButton = document.querySelector('.mute-button');
const updateIcon = () => {
if (!muteButton) return;
const isMuted = STATE.slideshow.isMuted;
muteButton.innerHTML = `<i class="material-icons">${isMuted ? 'volume_off' : 'volume_up'}</i>`;
const label = isMuted ? 'Unmute' : 'Mute';
muteButton.setAttribute("aria-label", LocalizationUtils.getLocalizedString(label, label));
muteButton.setAttribute("title", LocalizationUtils.getLocalizedString(label, label));
};
const currentItemId = STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex];
const player = STATE.slideshow.videoPlayers ? STATE.slideshow.videoPlayers[currentItemId] : null;
if (currentItemId) {
const currentSlide = document.querySelector(`.slide[data-item-id="${currentItemId}"]`);
const video = currentSlide?.querySelector('video');
if (video) {
video.muted = STATE.slideshow.isMuted;
if (!STATE.slideshow.isMuted) {
video.volume = 0.4;
}
video.play().catch(error => {
console.warn("Unmuted play blocked, reverting to muted...");
STATE.slideshow.isMuted = true;
video.muted = true;
video.play();
updateIcon();
});
}
if (player && typeof player.playVideo === 'function') {
if (STATE.slideshow.isMuted) {
player.mute();
} else {
player.unMute();
player.setVolume(40);
}
player.playVideo();
if (isUnmuting) {
setTimeout(() => {
const state = player.getPlayerState();
if (state === 2) {
console.log("Video was paused after unmute...");
STATE.slideshow.isMuted = true;
player.mute();
player.playVideo();
updateIcon();
}
}, 300);
}
}
}
updateIcon();
},
togglePause() {
STATE.slideshow.isPaused = !STATE.slideshow.isPaused;
const pauseButton = document.querySelector('.pause-button');
// Handle current video playback
const currentItemId = STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex];
const currentSlide = document.querySelector(`.slide[data-item-id="${currentItemId}"]`);
if (currentSlide) {
// Try YouTube player
const ytPlayer = STATE.slideshow.videoPlayers[currentItemId];
if (ytPlayer && typeof ytPlayer.getPlayerState === 'function') {
if (STATE.slideshow.isPaused) {
ytPlayer.pauseVideo();
} else {
ytPlayer.playVideo();
}
}
// Try HTML5 video
const html5Video = currentSlide.querySelector('video');
if (html5Video) {
if (STATE.slideshow.isPaused) {
html5Video.pause();
} else {
html5Video.play();
}
}
}
if (STATE.slideshow.isPaused) {
STATE.slideshow.slideInterval.stop();
pauseButton.innerHTML = '<i class="material-icons">play_arrow</i>';
const playLabel = LocalizationUtils.getLocalizedString('Play', 'Play');
pauseButton.setAttribute("aria-label", playLabel);
pauseButton.setAttribute("title", playLabel);
} else {
// Only restart interval if we are NOT waiting for a video to end
const currentItemId = STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex];
const currentSlide = document.querySelector(`.slide[data-item-id="${currentItemId}"]`);
const hasVideo = currentSlide && currentSlide.querySelector('.video-backdrop');
if (!CONFIG.waitForTrailerToEnd || !hasVideo) {
STATE.slideshow.slideInterval.start();
}
pauseButton.innerHTML = '<i class="material-icons">pause</i>';
const pauseLabel = LocalizationUtils.getLocalizedString('ButtonPause', 'Pause');
pauseButton.setAttribute("aria-label", pauseLabel);
pauseButton.setAttribute("title", pauseLabel);
}
},
/**
* Pauses all video players except the one with the given item ID
* @param {string} excludeItemId - Item ID to exclude from pausing
*/
pauseOtherVideos(excludeItemId) {
// Pause YouTube players
if (STATE.slideshow.videoPlayers) {
Object.keys(STATE.slideshow.videoPlayers).forEach(id => {
if (id !== excludeItemId) {
const p = STATE.slideshow.videoPlayers[id];
if (p) {
try {
if (typeof p.pauseVideo === 'function') {
p.pauseVideo();
if (typeof p.mute === 'function') {
p.mute();
}
}
else if (p.tagName === 'VIDEO') {
p.pause();
p.muted = true;
}
} catch (e) { console.warn("Error pausing player", id, e); }
}
}
});
}
// Pause HTML5 videos
document.querySelectorAll('video').forEach(video => {
const slideParent = video.closest('.slide');
if (slideParent && slideParent.dataset.itemId !== excludeItemId) {
try {
video.pause();
video.muted = true;
} catch (e) {}
}
});
},
/**
* Plays the video backdrop on the given slide and updates mute button visibility.
* Includes a retry mechanism for YouTube players that aren't ready yet.
* @param {Element} slide - The slide DOM element
* @param {string} itemId - The item ID of the slide
*/
playCurrentVideo(slide, itemId) {
// Find video element — check class (covers both original div and iframe with class restored by onReady)
const videoBackdrop = slide.querySelector('.video-backdrop');
const ytPlayer = STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[itemId];
const hasAnyVideo = !!(videoBackdrop || ytPlayer);
console.log(`[MBE-PLAY] playCurrentVideo for ${itemId}: videoBackdrop=${videoBackdrop?.tagName || 'null'}, ytPlayer=${!!ytPlayer}, ytReady=${ytPlayer && typeof ytPlayer.loadVideoById === 'function'}`);
// Update mute button visibility
const muteButton = document.querySelector('.mute-button');
if (muteButton) {
muteButton.style.display = hasAnyVideo ? 'block' : 'none';
}
if (!hasAnyVideo) {
console.log(`[MBE-PLAY] No video found for ${itemId}, skipping`);
return;
}
// HTML5 <video> element
if (videoBackdrop && videoBackdrop.tagName === 'VIDEO') {
console.log(`[MBE-PLAY] Playing HTML5 video for ${itemId}`);
videoBackdrop.currentTime = 0;
videoBackdrop.muted = STATE.slideshow.isMuted;
if (!STATE.slideshow.isMuted) videoBackdrop.volume = 0.4;
videoBackdrop.play().catch(() => {
setTimeout(() => {
if (videoBackdrop.paused && slide.classList.contains('active')) {
console.warn(`[MBE-PLAY] Autoplay blocked for ${itemId}, muted fallback`);
videoBackdrop.muted = true;
videoBackdrop.play().catch(err => console.error('[MBE-PLAY] Muted fallback failed', err));
}
}, 1000);
});
return;
}
// YouTube player — try to play now if ready
if (ytPlayer && typeof ytPlayer.loadVideoById === 'function' && ytPlayer._videoId) {
console.log(`[MBE-PLAY] YouTube player READY for ${itemId}, calling loadVideoById`);
ytPlayer.loadVideoById({
videoId: ytPlayer._videoId,
startSeconds: ytPlayer._startTime || 0,
endSeconds: ytPlayer._endTime
});
if (STATE.slideshow.isMuted) {
ytPlayer.mute();
} else {
ytPlayer.unMute();
ytPlayer.setVolume(40);
}
// Pause slideshow timer for video if configured
if (CONFIG.waitForTrailerToEnd && STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
// 1s check: if still not playing, force muted retry
setTimeout(() => {
if (!slide.classList.contains('active')) return;
try {
const state = ytPlayer.getPlayerState();
if (state !== YT.PlayerState.PLAYING && state !== YT.PlayerState.BUFFERING) {
console.warn(`[MBE-PLAY] loadVideoById didn't start for ${itemId} (state=${state}), muted retry`);
ytPlayer.mute();
ytPlayer.playVideo();
}
} catch (e) { console.warn('[MBE-PLAY] Error checking player state:', e); }
}, 1500);
return;
}
// YouTube player NOT ready yet (onReady hasn't fired).
// onReady will handle it IF the slide is still active when it fires.
// But as safety net: retry every 500ms for up to 6 seconds.
console.log(`[MBE-PLAY] YouTube player NOT READY for ${itemId}, starting retry loop (onReady will also attempt)`);
let retryCount = 0;
const maxRetries = 12; // 12 × 500ms = 6 seconds
const retryTimer = setInterval(() => {
retryCount++;
// Abort if slide changed or paused
if (!slide.classList.contains('active') || STATE.slideshow.isPaused) {
console.log(`[MBE-PLAY] Retry aborted for ${itemId} (slide inactive or paused)`);
clearInterval(retryTimer);
return;
}
const p = STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[itemId];
// Check if player is now playing (onReady may have started it)
if (p && typeof p.getPlayerState === 'function') {
try {
const state = p.getPlayerState();
if (state === YT.PlayerState.PLAYING || state === YT.PlayerState.BUFFERING) {
console.log(`[MBE-PLAY] Player for ${itemId} is already playing (started by onReady), stopping retry`);
clearInterval(retryTimer);
return;
}
} catch (e) { /* player not fully ready yet */ }
}
// Check if player is now ready
if (p && typeof p.loadVideoById === 'function' && p._videoId) {
console.log(`[MBE-PLAY] Retry #${retryCount}: Player for ${itemId} now READY, calling loadVideoById`);
clearInterval(retryTimer);
p.loadVideoById({
videoId: p._videoId,
startSeconds: p._startTime || 0,
endSeconds: p._endTime
});
if (STATE.slideshow.isMuted) {
p.mute();
} else {
p.unMute();
p.setVolume(40);
}
if (CONFIG.waitForTrailerToEnd && STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
return;
}
if (retryCount >= maxRetries) {
console.warn(`[MBE-PLAY] Gave up retrying for ${itemId} after ${maxRetries * 500}ms`);
clearInterval(retryTimer);
}
}, 500);
},
/**
* Stops all video playback (YouTube and HTML5)
* Used when navigating away from the home screen
*/
stopAllPlayback() {
// Clear any pending autoplay timeouts
if (STATE.slideshow.autoplayTimeouts) {
STATE.slideshow.autoplayTimeouts.forEach(id => clearTimeout(id));
STATE.slideshow.autoplayTimeouts = [];
}
// 1. Stop all YouTube players
if (STATE.slideshow.videoPlayers) {
Object.values(STATE.slideshow.videoPlayers).forEach(player => {
try {
if (player && typeof player.stopVideo === 'function') {
player.stopVideo();
if (typeof player.clearVideo === 'function') {
player.clearVideo();
}
} else if (player && typeof player.pauseVideo === 'function') {
player.pauseVideo();
}
} catch (e) {
console.warn("Error pausing/stopping YouTube player:", e);
}
});
}
// 2. Stop and mute all HTML5 videos
const container = document.getElementById("slides-container");
if (container) {
container.querySelectorAll('video').forEach(video => {
try {
video.pause();
video.muted = true;
video.currentTime = 0;
} catch (e) {
console.warn("Error stopping HTML5 video:", e);
}
});
}
},
/**
* Resumes playback for the active slide if not globally paused
*/
resumeActivePlayback() {
if (STATE.slideshow.isPaused) return;
const currentItemId = STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex];
if (!currentItemId) return;
const currentSlide = document.querySelector(`.slide[data-item-id="${currentItemId}"]`);
if (!currentSlide) return;
// YouTube player: just resume, don't reload
const ytPlayer = STATE.slideshow.videoPlayers?.[currentItemId];
if (ytPlayer && typeof ytPlayer.playVideo === 'function') {
if (STATE.slideshow.isMuted) {
if (typeof ytPlayer.mute === 'function') ytPlayer.mute();
} else {
if (typeof ytPlayer.unMute === 'function') ytPlayer.unMute();
if (typeof ytPlayer.setVolume === 'function') ytPlayer.setVolume(40);
}
ytPlayer.playVideo();
return;
}
// HTML5 video: just resume, don't reset currentTime
const html5Video = currentSlide.querySelector('video.video-backdrop');
if (html5Video) {
html5Video.muted = STATE.slideshow.isMuted;
if (!STATE.slideshow.isMuted) html5Video.volume = 0.4;
html5Video.play().catch(e => console.warn("Error resuming HTML5 video:", e));
}
},
/**
* Initializes touch events for swiping
*/
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 }
);
},
/**
* Handles swipe gestures
* @param {number} startX - Starting X position
* @param {number} endX - Ending X position
*/
handleSwipe(startX, endX) {
const diff = endX - startX;
if (Math.abs(diff) < CONFIG.minSwipeDistance) {
return;
}
if (diff > 0) {
this.prevSlide();
} else {
this.nextSlide();
}
},
/**
* Initializes keyboard event listeners
*/
initKeyboardEvents() {
if (!CONFIG.enableKeyboardControls) return;
document.addEventListener("keydown", (e) => {
const container = document.getElementById("slides-container");
if (!container || container.style.display === "none") {
return;
}
const activeElement = document.activeElement;
const isTvDevice = window.browser && window.browser.tv;
const isTvLayout = window.layoutManager && window.layoutManager.tv;
const hasTvClass = document.documentElement.classList.contains('layout-tv') || document.body.classList.contains('layout-tv');
const isTvMode = isTvDevice || isTvLayout || hasTvClass;
// Check Focus State
const isBodyFocused = activeElement === document.body;
const hasDirectFocus = container.contains(activeElement) || activeElement === container;
// Determine if we should handle navigation keys (Arrows, Space, M)
// TV Mode: Strict focus required (must be on slideshow)
// Desktop Mode: Loose focus allowed (slideshow OR body/nothing focused)
let canControlSlideshow = isTvMode ? hasDirectFocus : (hasDirectFocus || isBodyFocused);
// Check for Input Fields (always ignore typing)
const isInputElement = activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.isContentEditable);
if (isInputElement) return;
// Check active video players (ignore if video is playing/overlay is open)
const videoPlayer = document.querySelector('.videoPlayerContainer');
const trailerPlayer = document.querySelector('.youtubePlayerContainer');
const isVideoOpen = (videoPlayer && !videoPlayer.classList.contains('hide')) || (trailerPlayer && !trailerPlayer.classList.contains('hide'));
if (isVideoOpen) return;
switch (e.key) {
case "ArrowRight":
if (canControlSlideshow) {
SlideshowManager.nextSlide();
e.preventDefault();
}
break;
case "ArrowLeft":
if (canControlSlideshow) {
SlideshowManager.prevSlide();
e.preventDefault();
}
break;
case " ": // Space bar
if (canControlSlideshow) {
this.togglePause();
e.preventDefault();
}
break;
case "m": // Mute toggle
case "M":
if (canControlSlideshow) {
this.toggleMute();
e.preventDefault();
}
break;
case "Enter":
// Enter always requires direct focus on the slideshow to avoid conflicts
if (hasDirectFocus) {
const currentItemId = STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex];
if (currentItemId) {
if (window.Emby && window.Emby.Page) {
Emby.Page.show(
`/details?id=${currentItemId}&serverId=${STATE.jellyfinData.serverId}`
);
} else {
window.location.href = `#/details?id=${currentItemId}&serverId=${STATE.jellyfinData.serverId}`;
}
}
e.preventDefault();
}
break;
}
});
const container = SlideUtils.getOrCreateSlidesContainer();
container.addEventListener("focus", () => {
STATE.slideshow.containerFocused = true;
});
container.addEventListener("blur", () => {
STATE.slideshow.containerFocused = false;
});
},
/**
* Parses custom media IDs, handling seasonal content if enabled.
* If Seasonal Content is enabled:
* - Check if any defined season matches the current date.
* - If match: Return IDs from that season.
* - If NO match: Fall back to Default Custom IDs.
* If Custom Media IDs are enabled (and no seasonal match):
* - Return Default Custom IDs.
* If no Custom Media IDs are enabled:
* - Return empty array (triggering random fallback).
* @returns {string[]} Array of media IDs
*/
parseCustomIds() {
let idsString = CONFIG.customMediaIds;
let usingSeasonal = false;
if (CONFIG.enableSeasonalContent) {
try {
const sections = JSON.parse(CONFIG.seasonalSections || "[]");
const currentDate = new Date();
const currentMonth = currentDate.getMonth() + 1; // 1-12
const currentDay = currentDate.getDate(); // 1-31
for (const section of sections) {
const startDay = parseInt(section.StartDay);
const startMonth = parseInt(section.StartMonth);
const endDay = parseInt(section.EndDay);
const endMonth = parseInt(section.EndMonth);
let isInRange = false;
if (startMonth === endMonth) {
if (currentMonth === startMonth && currentDay >= startDay && currentDay <= endDay) {
isInRange = true;
}
} else if (startMonth < endMonth) {
// Normal range
if (
(currentMonth > startMonth && currentMonth < endMonth) ||
(currentMonth === startMonth && currentDay >= startDay) ||
(currentMonth === endMonth && currentDay <= endDay)
) {
isInRange = true;
}
} else {
// Wrap around year
if (
(currentMonth > startMonth || currentMonth < endMonth) ||
(currentMonth === startMonth && currentDay >= startDay) ||
(currentMonth === endMonth && currentDay <= endDay)
) {
isInRange = true;
}
}
if (isInRange) {
console.log(`Seasonal match found: ${section.Name}`);
idsString = section.MediaIds;
usingSeasonal = true;
break; // Use first matching season
}
}
} catch (e) {
console.error("Error parsing seasonal sections in JS:", e);
}
}
// If NOT using seasonal content (disabled or no match),
// Custom IDs are disabled, return empty to skip to random
if (!usingSeasonal && !CONFIG.enableCustomMediaIds) {
return [];
}
// Parse the resulting string (either seasonal or default)
if (!idsString) return [];
return idsString
.split(/[\n,]/)
.map((line) => {
const urlMatch = line.match(/\[(.*?)\]/);
let id = line;
if (urlMatch) {
const url = urlMatch[1];
// Remove the [url] part from the ID string for parsing
id = line.replace(/\[.*?\]/, '').trim();
// Attempt to extract GUID if present
const guidMatch = id.match(/([0-9a-f]{32})/i);
if (guidMatch) {
id = guidMatch[1];
} else {
// Fallback: split by pipe if used
id = id.split('|')[0].trim();
}
STATE.slideshow.customTrailerUrls[id] = url;
}
return id.trim();
})
.map((id) => id.trim())
.filter((id) => id);
},
/**
* Resolves a list of IDs, expanding collections (BoxSets) into their children
* @param {string[]} rawIds - List of input IDs
* @returns {Promise<string[]>} Flattened list of item IDs
*/
async resolveCollectionsAndItems(rawIds) {
const finalIds = [];
const guidRegex = /^([0-9a-f]{32})$/i;
for (const rawId of rawIds) {
try {
let id = rawId;
// If not a valid GUID, check if it starts with one (comments) or treat as a name
if (!guidRegex.test(rawId)) {
const guidMatch = rawId.match(/^([0-9a-f]{32})(?:[^0-9a-f]|$)/i);
if (guidMatch) {
id = guidMatch[1];
} else {
console.log(`Input '${rawId}' is not a GUID, searching for Collection/Playlist by name...`);
const resolvedId = await ApiUtils.findCollectionOrPlaylistByName(rawId);
if (resolvedId) {
console.log(`Resolved name '${rawId}' to ID: ${resolvedId}`);
id = resolvedId;
} else {
console.warn(`Could not find Collection or Playlist with name: '${rawId}'`);
continue; // Skip if resolution failed
}
}
}
const item = await ApiUtils.fetchItemDetails(id);
if (item && (item.Type === 'BoxSet' || item.Type === 'Playlist')) {
console.log(`Found Collection/Playlist: ${id} (${item.Type}), fetching children...`);
const children = await ApiUtils.fetchCollectionItems(id);
finalIds.push(...children);
} else if (item) {
finalIds.push({ Id: item.Id, Type: item.Type });
}
} catch (e) {
console.warn(`Error resolving item ${rawId}:`, e);
}
}
return finalIds;
},
/**
* Loads slideshow data and initializes the slideshow
*/
async loadSlideshowData() {
try {
STATE.slideshow.isLoading = true;
let itemIds = [];
// 1. Try Custom Media/Collection IDs from Config & seasonal content
if (CONFIG.enableCustomMediaIds || CONFIG.enableSeasonalContent) {
console.log("Using Custom Media IDs from configuration");
const rawIds = this.parseCustomIds();
const resolvedItems = await this.resolveCollectionsAndItems(rawIds);
// Apply max items limit to custom IDs if enabled
if (CONFIG.applyLimitsToCustomIds) {
let movieCount = 0;
let showCount = 0;
let keptItems = [];
for (const item of resolvedItems) {
if (keptItems.length >= CONFIG.maxItems) break;
if (item.Type === 'Movie') {
if (movieCount < CONFIG.maxMovies) {
movieCount++;
keptItems.push(item);
}
} else if (item.Type === 'Series' || item.Type === 'Season' || item.Type === 'Episode') {
// Count Seasons/Episodes as TV Shows
if (showCount < CONFIG.maxTvShows) {
showCount++;
keptItems.push(item);
}
} else {
// Other types: count towards total only
keptItems.push(item);
}
}
itemIds = keptItems.map(i => i.Id);
console.log(`Applied limits to custom IDs: ${itemIds.length} items (Movies: ${movieCount}, Shows: ${showCount})`);
} else {
itemIds = resolvedItems.map(i => i.Id);
}
}
// 2. Try Avatar List (list.txt)
if (itemIds.length === 0) {
itemIds = await ApiUtils.fetchItemIdsFromList();
}
// 3. Fallback to server query (Random)
if (itemIds.length === 0) {
console.log("No custom list found, fetching random items from server...");
itemIds = await ApiUtils.fetchItemIdsFromServer();
if (CONFIG.sortBy === 'Random') {
itemIds = SlideUtils.shuffleArray(itemIds);
}
} else {
// Custom IDs
if (CONFIG.sortBy === 'Random') {
itemIds = SlideUtils.shuffleArray(itemIds);
} else if (CONFIG.sortBy !== 'Original') {
// Client-side sort required...
console.log(`Sorting ${itemIds.length} custom items by ${CONFIG.sortBy} ${CONFIG.sortOrder}`);
const itemsWithDetails = [];
for (const id of itemIds) {
const item = await ApiUtils.fetchItemDetails(id);
if (item) itemsWithDetails.push(item);
}
const sortedItems = SlideUtils.sortItems(itemsWithDetails, CONFIG.sortBy, CONFIG.sortOrder);
itemIds = sortedItems.map(i => i.Id);
}
}
STATE.slideshow.itemIds = itemIds;
STATE.slideshow.totalItems = itemIds.length;
this.createPaginationDots();
await this.updateCurrentSlide(0);
STATE.slideshow.slideInterval = new SlideTimer(() => {
if (STATE.slideshow.isPaused) return;
if (CONFIG.waitForTrailerToEnd) {
const activeSlide = document.querySelector('.slide.active');
const hasActiveVideo = !!(activeSlide && activeSlide.querySelector('.video-backdrop'));
if (hasActiveVideo) return;
}
this.nextSlide();
}, CONFIG.shuffleInterval);
// Check if we should wait for trailer
const waitForTrailer = MediaBarEnhancedSettingsManager.getSetting('waitForTrailer', CONFIG.waitForTrailerToEnd);
if (waitForTrailer && STATE.slideshow.slideInterval) {
const activeSlide = document.querySelector('.slide.active');
const hasActiveVideo = !!(activeSlide && activeSlide.querySelector('.video-backdrop'));
if (hasActiveVideo) {
STATE.slideshow.slideInterval.stop();
}
}
} catch (error) {
console.error("Error loading slideshow data:", error);
} finally {
STATE.slideshow.isLoading = false;
}
},
};
/**
* Initializes arrow navigation elements
*/
const initArrowNavigation = () => {
const container = SlideUtils.getOrCreateSlidesContainer();
const leftArrow = SlideUtils.createElement("div", {
className: "arrow left-arrow",
innerHTML: '<i class="material-icons">chevron_left</i>',
tabIndex: "0",
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
SlideshowManager.prevSlide();
},
style: {
opacity: "0",
transition: "opacity 0.3s ease",
display: "none",
},
});
const rightArrow = SlideUtils.createElement("div", {
className: "arrow right-arrow",
innerHTML: '<i class="material-icons">chevron_right</i>',
tabIndex: "0",
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
SlideshowManager.nextSlide();
},
style: {
opacity: "0",
transition: "opacity 0.3s ease",
display: "none",
},
});
const pauseButton = SlideUtils.createElement("div", {
className: "pause-button",
innerHTML: '<i class="material-icons">pause</i>',
tabIndex: "0",
"aria-label": LocalizationUtils.getLocalizedString('ButtonPause', 'Pause'),
title: LocalizationUtils.getLocalizedString('ButtonPause', 'Pause'),
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
SlideshowManager.togglePause();
}
});
// Prevent touch events from bubbling to container
pauseButton.addEventListener("touchstart", (e) => e.stopPropagation(), { passive: true });
pauseButton.addEventListener("touchend", (e) => e.stopPropagation(), { passive: true });
pauseButton.addEventListener("mousedown", (e) => e.stopPropagation());
const muteButton = SlideUtils.createElement("div", {
className: "mute-button",
innerHTML: STATE.slideshow.isMuted ? '<i class="material-icons">volume_off</i>' : '<i class="material-icons">volume_up</i>',
tabIndex: "0",
"aria-label": STATE.slideshow.isMuted ? LocalizationUtils.getLocalizedString('Unmute', 'Unmute') : LocalizationUtils.getLocalizedString('Mute', 'Mute'),
title: STATE.slideshow.isMuted ? LocalizationUtils.getLocalizedString('Unmute', 'Unmute') : LocalizationUtils.getLocalizedString('Mute', 'Mute'),
style: { display: "none" },
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
SlideshowManager.toggleMute();
}
});
// Prevent touch events from bubbling to container
muteButton.addEventListener("touchstart", (e) => e.stopPropagation(), { passive: true });
muteButton.addEventListener("touchend", (e) => e.stopPropagation(), { passive: true });
muteButton.addEventListener("mousedown", (e) => e.stopPropagation());
container.appendChild(leftArrow);
container.appendChild(rightArrow);
container.appendChild(pauseButton);
container.appendChild(muteButton);
const showArrows = () => {
leftArrow.style.display = "block";
rightArrow.style.display = "block";
void leftArrow.offsetWidth;
void rightArrow.offsetWidth;
leftArrow.style.opacity = "1";
rightArrow.style.opacity = "1";
};
const hideArrows = () => {
leftArrow.style.opacity = "0";
rightArrow.style.opacity = "0";
setTimeout(() => {
if (leftArrow.style.opacity === "0") {
leftArrow.style.display = "none";
rightArrow.style.display = "none";
}
}, 300);
};
container.addEventListener("mouseenter", showArrows);
container.addEventListener("mouseleave", hideArrows);
if (CONFIG.alwaysShowArrows) {
showArrows();
// Remove listeners to keep them shown
container.removeEventListener("mouseenter", showArrows);
container.removeEventListener("mouseleave", hideArrows);
}
let arrowTimeout;
container.addEventListener(
"touchstart",
() => {
if (arrowTimeout) {
clearTimeout(arrowTimeout);
}
showArrows();
arrowTimeout = setTimeout(hideArrows, 2000);
},
{ passive: true }
);
};
const MediaBarEnhancedSettingsManager = {
initialized: false,
init() {
if (this.initialized) return;
if (!CONFIG.enableClientSideSettings) return;
this.initialized = true;
this.injectSettingsIcon();
console.log("MediaBarEnhanced: Client-Side Settings Manager initialized.");
},
getSetting(key, defaultValue) {
if (!CONFIG.enableClientSideSettings) return defaultValue;
const value = localStorage.getItem(`mediaBarEnhanced-${key}`);
return value !== null ? value === 'true' : defaultValue;
},
setSetting(key, value) {
localStorage.setItem(`mediaBarEnhanced-${key}`, value);
},
createIcon() {
const button = document.createElement('button');
button.type = 'button';
button.className = 'paper-icon-button-light headerButton media-bar-settings-button';
button.title = 'Media Bar Settings';
// button.innerHTML = '<span class="material-icons">tune</span>';
// button.innerHTML = '<img src="/MediaBarEnhanced/Resources/assets/logo_SW.svg" style="width: 24px; height: 24px; vertical-align: middle;">';
// currently not optimal, as it's egg-shaped due to the svg format... but if it's square, it's very small...
// button.innerHTML = '<img src="/MediaBarEnhanced/Resources/assets/logo_SW.svg" draggable="false" style="width: 52px; height: 24px; vertical-align: middle; pointer-events: none;">';
// button.innerHTML = '<img src="/MediaBarEnhanced/Resources/assets/logo_SW_SHORT.svg" draggable="false" style="width: 41px; height: 24px; vertical-align: middle; pointer-events: none;">';
button.innerHTML = '<img src="/MediaBarEnhanced/Resources/assets/logo_SW_MINIMAL.svg" draggable="false" style="width: 24px; height: 24px; vertical-align: middle; pointer-events: none;">';
button.style.verticalAlign = 'middle';
button.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleSettingsPopup(button);
});
return button;
},
injectSettingsIcon() {
const observer = new MutationObserver((mutations, obs) => {
const headerRight = document.querySelector('.headerRight');
if (headerRight && !document.querySelector('.media-bar-settings-button')) {
const icon = this.createIcon();
headerRight.prepend(icon);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
},
createPopup(anchorElement) {
const existing = document.querySelector('.media-bar-settings-popup');
if (existing) existing.remove();
const popup = document.createElement('div');
popup.className = 'media-bar-settings-popup dialog';
Object.assign(popup.style, {
position: 'fixed',
zIndex: '10000',
backgroundColor: '#202020',
padding: '1em',
borderRadius: '0.3em',
boxShadow: '0 0 20px rgba(0,0,0,0.5)',
minWidth: '250px',
color: '#fff',
});
const rect = anchorElement.getBoundingClientRect();
let rightPos = window.innerWidth - rect.right;
if (window.innerWidth < 450 || (window.innerWidth - rightPos) < 260) {
popup.style.right = '1rem';
popup.style.left = 'auto';
} else {
popup.style.right = `${rightPos}px`;
popup.style.left = 'auto';
}
popup.style.top = `${rect.bottom + 10}px`;
const settings = [
{ key: 'enabled', label: 'Enable Media Bar Enhanced', description: 'Toggle the entire media bar visibility.', default: true },
{ key: 'videoBackdrops', label: 'Enable Trailer Backdrops', description: 'Play trailers as background videos.', default: CONFIG.enableVideoBackdrop },
{ key: 'trailerButton', label: 'Show Trailer Button', description: 'Show button to play trailer in popup (only backdrops without trailer)', default: CONFIG.showTrailerButton },
{ key: 'mobileVideo', label: 'Enable Trailer On Mobile', description: 'Allow trailer backdrops on mobile devices.', default: CONFIG.enableMobileVideo },
{ key: 'waitForTrailer', label: 'Wait For Trailer To End', description: 'Wait for the trailer to finish before changing slides.', default: CONFIG.waitForTrailerToEnd },
{ key: 'slideAnimations', label: 'Enable Animations', description: 'Enable zooming-in effect (only on background images)', default: CONFIG.slideAnimationEnabled },
];
let html = '<h3 style="margin-top:0; margin-bottom:1em; border-bottom:1px solid #444; padding-bottom:0.5em;">Media Bar Settings</h3>';
settings.forEach(setting => {
const isChecked = this.getSetting(setting.key, setting.default);
html += `
<div class="checkboxContainer checkboxContainer-withDescription" style="margin-bottom: 0.5em;">
<label class="emby-checkbox-label">
<input id="mb-setting-${setting.key}" type="checkbox" is="emby-checkbox" class="emby-checkbox" ${isChecked ? 'checked' : ''} />
<span class="checkboxLabel">${setting.label}</span>
</label>
<div class="fieldDescription">${setting.description}</div>
</div>
`;
});
// Buttons Container
html += `
<div style="margin-top:1em; display:flex; justify-content:flex-end; align-items:center; gap:1.5em;">
<button is="emby-button" type="button" class="raised button-cancel emby-button" id="mb-settings-reset" title="Reset to Server Defaults">
<span>Load Server Defaults</span>
</button>
<button is="emby-button" type="button" class="raised button-submit emby-button" id="mb-settings-save">
<span>Save & Reload</span>
</button>
</div>
`;
popup.innerHTML = html;
// Add Listeners
settings.forEach(setting => {
const checkbox = popup.querySelector(`#mb-setting-${setting.key}`);
checkbox.addEventListener('change', (e) => {
this.setSetting(setting.key, e.target.checked);
});
});
// Reload Handler
popup.querySelector('#mb-settings-save').addEventListener('click', () => {
location.reload();
});
// Reset Handler
popup.querySelector('#mb-settings-reset').addEventListener('click', () => {
if (confirm("Reset all local Media Bar settings to server defaults?")) {
Object.keys(localStorage).forEach(key => {
if (key.startsWith('mediaBarEnhanced-')) {
localStorage.removeItem(key);
}
});
location.reload();
}
});
const closeHandler = (e) => {
if (!popup.contains(e.target) && e.target !== anchorElement && !anchorElement.contains(e.target)) {
popup.remove();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 0);
document.body.appendChild(popup);
},
toggleSettingsPopup(anchorElement) {
const existing = document.querySelector('.media-bar-settings-popup');
if (existing) {
existing.remove();
} else {
this.createPopup(anchorElement);
}
}
};
/**
* Initialize page visibility handling to pause when tab is inactive
*/
const initPageVisibilityHandler = () => {
document.addEventListener("visibilitychange", () => {
const isVideoPlayerOpen = document.querySelector('.videoPlayerContainer:not(.hide)') ||
document.querySelector('.youtubePlayerContainer:not(.hide)');
if (document.hidden) {
// Stop slide timer
if (STATE.slideshow.slideInterval) {
STATE.slideshow.slideInterval.stop();
}
if (isVideoPlayerOpen) {
// Jellyfin video is playing --> full stop to free all resources
console.log("Tab inactive and Jellyfin player active - stopping all playback");
SlideshowManager.stopAllPlayback();
} else {
// Simple tab switch: stop all others, pause only the current
console.log("Tab inactive. Pausing current video, stopping others");
const currentItemId = STATE.slideshow.itemIds?.[STATE.slideshow.currentSlideIndex];
// Stop all players except the current one
if (STATE.slideshow.videoPlayers) {
Object.keys(STATE.slideshow.videoPlayers).forEach(id => {
if (id === currentItemId) return;
const p = STATE.slideshow.videoPlayers[id];
if (p) {
try {
if (typeof p.stopVideo === 'function') {
p.stopVideo();
if (typeof p.clearVideo === 'function') p.clearVideo();
} else if (p.tagName === 'VIDEO') {
p.pause();
p.muted = true;
p.currentTime = 0;
}
} catch (e) { console.warn("Error stopping background player", id, e); }
}
});
}
// Pause only the current video
if (currentItemId) {
const player = STATE.slideshow.videoPlayers?.[currentItemId];
if (player) {
try {
if (typeof player.pauseVideo === 'function') {
player.pauseVideo();
} else if (player.tagName === 'VIDEO') {
player.pause();
}
} catch (e) { console.warn("Error pausing video on tab hide:", e); }
}
}
}
} else {
console.log("Tab active. Resuming slideshow");
const isOnHome = window.location.hash === "#/home.html" || window.location.hash === "#/home";
if (isOnHome && !STATE.slideshow.isPaused && !isVideoPlayerOpen) {
SlideshowManager.resumeActivePlayback();
if (STATE.slideshow.slideInterval && !CONFIG.waitForTrailerToEnd) {
STATE.slideshow.slideInterval.start();
}
}
}
});
};
/**
* Initialize the slideshow
*/
const slidesInit = async () => {
if (STATE.slideshow.hasInitialized) {
console.log("⚠️ Slideshow already initialized, skipping");
return;
}
if (CONFIG.enableClientSideSettings) {
MediaBarEnhancedSettingsManager.init();
const isEnabled = MediaBarEnhancedSettingsManager.getSetting('enabled', true);
if (!isEnabled) {
console.log("MediaBarEnhanced: Disabled by client-side setting.");
const homeSections = document.querySelector('.homeSectionsContainer');
if (homeSections) {
homeSections.style.top = '0';
homeSections.style.marginTop = '0';
}
const container = document.getElementById('slides-container');
if (container) container.style.display = 'none';
return;
}
}
STATE.slideshow.hasInitialized = true;
/**
* Initialize IntersectionObserver for lazy loading images
*/
const initLazyLoading = () => {
const imageObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const image = entry.target;
const highQualityUrl = image.getAttribute("data-high-quality");
if (
highQualityUrl &&
image.closest(".slide").style.opacity === "1"
) {
requestQueue.push({
url: highQualityUrl,
callback: () => {
image.src = highQualityUrl;
image.classList.remove("low-quality");
image.classList.add("high-quality");
},
});
if (requestQueue.length === 1) {
processNextRequest();
}
}
observer.unobserve(image);
}
});
},
{
rootMargin: "50px",
threshold: 0.1,
}
);
const observeSlideImages = () => {
const slides = document.querySelectorAll(".slide");
slides.forEach((slide) => {
const images = slide.querySelectorAll("img.low-quality");
images.forEach((image) => {
imageObserver.observe(image);
});
});
};
const slideObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.addedNodes) {
mutation.addedNodes.forEach((node) => {
if (node.classList && node.classList.contains("slide")) {
const images = node.querySelectorAll("img.low-quality");
images.forEach((image) => {
imageObserver.observe(image);
});
}
});
}
});
});
const container = SlideUtils.getOrCreateSlidesContainer();
slideObserver.observe(container, { childList: true });
observeSlideImages();
return imageObserver;
};
const lazyLoadObserver = initLazyLoading();
try {
console.log("🌟 Initializing Enhanced Jellyfin Slideshow");
initArrowNavigation();
await SlideshowManager.loadSlideshowData();
SlideshowManager.initTouchEvents();
SlideshowManager.initKeyboardEvents();
initPageVisibilityHandler();
VisibilityObserver.init();
console.log("✅ Enhanced Jellyfin Slideshow initialized successfully");
} catch (error) {
console.error("Error initializing slideshow:", error);
STATE.slideshow.hasInitialized = false;
}
};
window.mediaBarEnhanced = {
CONFIG,
STATE,
SlideUtils,
ApiUtils,
SlideCreator,
SlideshowManager,
VisibilityObserver,
initSlideshowData: () => {
SlideshowManager.loadSlideshowData();
},
nextSlide: () => {
SlideshowManager.nextSlide();
},
prevSlide: () => {
SlideshowManager.prevSlide();
},
};
initLoadingScreen();
fetchPluginConfig().then(() => {
startLoginStatusWatcher();
});