/* * Jellyfin Slideshow by M0RPH3US v4.0.1 * Modified by CodeDevMLH v1.1.0.0 * * 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) */ //Core Module Configuration const CONFIG = { IMAGE_SVG: { freshTomato: 'image/svg+xml', rottenTomato: '', }, 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", }; // 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, }, }; // 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 = `

`; 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} items - Array of item objects * @param {string} sortBy - Sort criteria * @param {string} sortOrder - Sort order 'Ascending' or 'Descending' * @returns {Array} 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" }); 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: `
`, }); return loadingIndicator; }, /** * Loads the YouTube IFrame API if not already loaded * @returns {Promise} */ 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: 'close', 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 } }); }); } 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} 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} */ 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} 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 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 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} 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} 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 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} 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 of item IDs */ async fetchCollectionItems(collectionId) { try { const response = await fetch( `${STATE.jellyfinData.serverAddress}/Items?ParentId=${collectionId}&Recursive=true&IncludeItemTypes=Movie,Series&fields=Id&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 => i.Id); } 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} Stream 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; // Construct stream URL return `${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 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"; 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()); 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]) { trailerUrl = STATE.slideshow.customTrailerUrls[itemId]; 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 { const urlObj = new URL(trailerUrl); 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 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 }; // 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) => { // 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 const slide = document.querySelector(`.slide[data-item-id="${itemId}"]`); if (slide && slide.classList.contains('active')) { event.target.playVideo(); // Check if it actually started playing after a short delay (handling autoplay blocks) setTimeout(() => { 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); // Pause slideshow timer when video starts if configured if (CONFIG.waitForTrailerToEnd && STATE.slideshow.slideInterval) { STATE.slideshow.slideInterval.stop(); } } }, 'onStateChange': (event) => { if (event.data === YT.PlayerState.ENDED) { if (CONFIG.waitForTrailerToEnd) { SlideshowManager.nextSlide(); } else { event.target.playVideo(); // Loop if not waiting for end if trailer is shorter than slide duration } } }, 'onError': () => { // 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: trailerUrl, autoplay: false, preload: "auto", loop: false, 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', () => { if (CONFIG.waitForTrailerToEnd && STATE.slideshow.slideInterval) { STATE.slideshow.slideInterval.stop(); } }); backdrop.addEventListener('ended', () => { if (CONFIG.waitForTrailerToEnd) { 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: `${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: ` ${playText} `, 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} url - Trailer URL * @returns {HTMLElement} Trailer button element */ createTrailerButton(url) { const trailerText = LocalizationUtils.getLocalizedString('Trailer', 'Trailer'); return SlideUtils.createElement("button", { className: "detailButton trailer-button", innerHTML: `movie ${trailerText}`, tabIndex: "0", onclick: (e) => { e.preventDefault(); e.stopPropagation(); 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} 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 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"); // Manage Video Playback: Stop others, Play current // 1. Pause all other YouTube players if (STATE.slideshow.videoPlayers) { Object.keys(STATE.slideshow.videoPlayers).forEach(id => { if (id !== currentItemId) { const p = STATE.slideshow.videoPlayers[id]; if (p && typeof p.pauseVideo === 'function') { p.pauseVideo(); } } }); } // 2. Pause all other HTML5 videos e.g. local trailers document.querySelectorAll('video').forEach(video => { if (!video.closest(`.slide[data-item-id="${currentItemId}"]`)) { video.pause(); } }); // 3. Play and Reset current video const videoBackdrop = currentSlide.querySelector('.video-backdrop'); // Update mute button visibility const muteButton = document.querySelector('.mute-button'); if (muteButton) { const hasVideo = !!videoBackdrop; muteButton.style.display = hasVideo ? 'block' : 'none'; } if (videoBackdrop) { if (videoBackdrop.tagName === 'VIDEO') { videoBackdrop.currentTime = 0; videoBackdrop.muted = STATE.slideshow.isMuted; if (!STATE.slideshow.isMuted) { videoBackdrop.volume = 0.4; } videoBackdrop.play().catch(e => { // Check if it actually started playing after a short delay (handling autoplay blocks) setTimeout(() => { if (videoBackdrop.paused) { console.warn(`Autoplay blocked for ${itemId}, attempting muted fallback`); videoBackdrop.muted = true; videoBackdrop.play().catch(err => console.error("Muted fallback failed", err)); } }, 1000); }); } else if (STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[currentItemId]) { const player = STATE.slideshow.videoPlayers[currentItemId]; if (player && typeof player.loadVideoById === 'function' && player._videoId) { // Use loadVideoById to enforce start and end times player.loadVideoById({ videoId: player._videoId, startSeconds: player._startTime || 0, endSeconds: player._endTime }); if (STATE.slideshow.isMuted) { player.mute(); } else { player.unMute(); player.setVolume(40); } // Check if playback successfully started, otherwise fallback to muted setTimeout(() => { if (player.getPlayerState && player.getPlayerState() !== YT.PlayerState.PLAYING && player.getPlayerState() !== YT.PlayerState.BUFFERING) { console.log("YouTube loadVideoById didn't start playback, retrying muted..."); player.mute(); player.playVideo(); } }, 1000); } else if (player && typeof player.seekTo === 'function') { // Fallback if loadVideoById is not available or videoId missing const startTime = player._startTime || 0; player.seekTo(startTime); player.playVideo(); } } } 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"); } currentSlide.querySelector(".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'); 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 = CONFIG.preloadCount; const nextIndex = (currentIndex + 1) % totalItems; const itemId = STATE.slideshow.itemIds[nextIndex]; await SlideCreator.createSlideForItemId(itemId); if (preloadCount > 1) { const prevIndex = (currentIndex - 1 + totalItems) % totalItems; const prevItemId = STATE.slideshow.itemIds[prevIndex]; SlideCreator.createSlideForItemId(prevItemId); } }, nextSlide() { const currentIndex = STATE.slideshow.currentSlideIndex; const totalItems = STATE.slideshow.totalItems; const nextIndex = (currentIndex + 1) % totalItems; this.updateCurrentSlide(nextIndex); }, prevSlide() { const currentIndex = STATE.slideshow.currentSlideIndex; const totalItems = STATE.slideshow.totalItems; const prevIndex = (currentIndex - 1 + totalItems) % totalItems; this.updateCurrentSlide(prevIndex); }, /** * 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; Object.keys(STATE.slideshow.createdSlides).forEach((itemId) => { const index = STATE.slideshow.itemIds.indexOf(itemId); if (index === -1) return; const distance = Math.abs(index - currentIndex); 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]; console.log(`Pruned slide ${itemId} at distance ${distance} from view`); } }); }, 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 = `${isMuted ? 'volume_off' : 'volume_up'}`; 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 = 'play_arrow'; 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 = 'pause'; const pauseLabel = LocalizationUtils.getLocalizedString('ButtonPause', 'Pause'); pauseButton.setAttribute("aria-label", pauseLabel); pauseButton.setAttribute("title", pauseLabel); } }, /** * Stops all video playback (YouTube and HTML5) * Used when navigating away from the home screen */ stopAllPlayback() { // 1. Pause all YouTube players if (STATE.slideshow.videoPlayers) { Object.values(STATE.slideshow.videoPlayers).forEach(player => { try { if (player && typeof player.pauseVideo === 'function') { player.pauseVideo(); } } catch (e) { console.warn("Error pausing YouTube player:", e); } }); } // 2. Pause all HTML5 videos const container = document.getElementById("slides-container"); if (container) { container.querySelectorAll('video').forEach(video => { try { video.pause(); } catch (e) { console.warn("Error pausing 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; // 1. Try YouTube Player const ytPlayer = STATE.slideshow.videoPlayers[currentItemId]; if (ytPlayer && typeof ytPlayer.playVideo === 'function') { ytPlayer.playVideo(); } // 2. Try HTML5 Video const html5Video = currentSlide.querySelector('video'); if (html5Video) { if (STATE.slideshow.isMuted) { html5Video.muted = true; } 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; } // Only trap keys if focus is on body (neutral) or inside our container. // To allow standard TV navigation to work for other elements (e.g. library cards). const activeEl = document.activeElement; const isBody = activeEl === document.body || !activeEl; const isInContainer = container.contains(activeEl) || activeEl === container; if (!isBody && !isInContainer) { return; } const focusElement = document.activeElement; switch (e.key) { case "ArrowRight": SlideshowManager.nextSlide(); e.preventDefault(); break; case "ArrowLeft": SlideshowManager.prevSlide(); e.preventDefault(); break; case " ": // Space bar this.togglePause(); e.preventDefault(); break; case "m": // Mute toggle case "M": this.toggleMute(); e.preventDefault(); break; case "Enter": 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 * @returns {string[]} Array of media IDs */ parseCustomIds() { if (!CONFIG.enableSeasonalContent) { return CONFIG.customMediaIds .split(/[\n,]/).map((line) => { const urlMatch = line.match(/\[(.*?)\]/); let id = line; if (urlMatch) { const url = urlMatch[1]; id = line.replace(/\[.*?\]/, '').trim(); const guidMatch = id.match(/([0-9a-f]{32})/i); if (guidMatch) { id = guidMatch[1]; } else { id = id.split('|')[0].trim(); } STATE.slideshow.customTrailerUrls[id] = url; } return id.trim(); }) .map((id) => id.trim()) .filter((id) => id); } else { return this.parseSeasonalIds(); } }, /** * Parses custom media IDs, handling seasonal content if enabled * @returns {string[]} Array of media IDs */ parseSeasonalIds() { console.log("Using Seasonal Content Mode"); const lines = CONFIG.customMediaIds.split('\n'); const currentDate = new Date(); const currentMonth = currentDate.getMonth() + 1; // 1-12 const currentDay = currentDate.getDate(); // 1-31 const rawIds = []; for (const line of lines) { const match = line.match(/^\s*(\d{1,2})\.(\d{1,2})-(\d{1,2})\.(\d{1,2})\s*\|.*\|(.*)$/) || line.match(/^\s*(\d{1,2})\.(\d{1,2})-(\d{1,2})\.(\d{1,2})\s*\|(.*)$/); if (match) { const startDay = parseInt(match[1]); const startMonth = parseInt(match[2]); const endDay = parseInt(match[3]); const endMonth = parseInt(match[4]); const idsPart = match[5]; let isInRange = false; if (startMonth === endMonth) { if (currentMonth === startMonth && currentDay >= startDay && currentDay <= endDay) { isInRange = true; } } else if (startMonth < endMonth) { // Normal range spanning months (e.g. 15.06 - 15.08) if ( (currentMonth > startMonth && currentMonth < endMonth) || (currentMonth === startMonth && currentDay >= startDay) || (currentMonth === endMonth && currentDay <= endDay) ) { isInRange = true; } } else { // Wrap around year (e.g. 01.12 - 15.01) if ( (currentMonth > startMonth || currentMonth < endMonth) || (currentMonth === startMonth && currentDay >= startDay) || (currentMonth === endMonth && currentDay <= endDay) ) { isInRange = true; } } if (isInRange) { console.log(`Seasonal match found: ${line}`); const ids = idsPart.split(/[,]/).map(line => { const urlMatch = line.match(/\[(.*?)\]/); let id = line; if (urlMatch) { const url = urlMatch[1]; id = line.replace(/\[.*?\]/, '').trim(); const guidMatch = id.match(/([0-9a-f]{32})/i); if (guidMatch) { id = guidMatch[1]; } else { id = id.split('|')[0].trim(); } STATE.slideshow.customTrailerUrls[id] = url; } return id.trim(); }).filter(id => id); rawIds.push(...ids); } } } return rawIds; }, /** * Resolves a list of IDs, expanding collections (BoxSets) into their children * @param {string[]} rawIds - List of input IDs * @returns {Promise} 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); } } catch (e) { console.warn(`Error resolving item ${id}:`, 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.customMediaIds) { console.log("Using Custom Media IDs from configuration"); const rawIds = this.parseCustomIds(); itemIds = await this.resolveCollectionsAndItems(rawIds); } // 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: 'chevron_left', 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: 'chevron_right', 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: 'pause', 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 ? 'volume_off' : 'volume_up', 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 = 'tune'; // button.innerHTML = ''; // currently not optimal, as it's egg-shaped due to the svg format... but if it's square, it's very small... // button.innerHTML = ''; // button.innerHTML = ''; button.innerHTML = ''; 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 = '

Media Bar Settings

'; settings.forEach(setting => { const isChecked = this.getSetting(setting.key, setting.default); html += `
${setting.description}
`; }); // Buttons Container html += `
`; 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 = () => { let wasVideoPlayingBeforeHide = false; document.addEventListener("visibilitychange", () => { if (document.hidden) { console.log("Tab inactive - pausing slideshow and videos"); wasVideoPlayingBeforeHide = STATE.slideshow.isVideoPlaying; if (STATE.slideshow.slideInterval) { STATE.slideshow.slideInterval.stop(); } // Pause active video if playing const currentItemId = STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex]; if (currentItemId) { // YouTube if (STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[currentItemId]) { const player = STATE.slideshow.videoPlayers[currentItemId]; if (typeof player.pauseVideo === "function") { try { player.pauseVideo(); STATE.slideshow.isVideoPlaying = false; } catch (e) { console.warn("Error pausing video on tab hide:", e); } } else if (player.tagName === 'VIDEO') { // HTML5 Video player.pause(); STATE.slideshow.isVideoPlaying = false; } } } } else { console.log("Tab active - resuming slideshow"); if (!STATE.slideshow.isPaused) { const currentItemId = STATE.slideshow.itemIds[STATE.slideshow.currentSlideIndex]; if (wasVideoPlayingBeforeHide && currentItemId && STATE.slideshow.videoPlayers && STATE.slideshow.videoPlayers[currentItemId]) { const player = STATE.slideshow.videoPlayers[currentItemId]; // YouTube if (typeof player.playVideo === "function") { try { player.playVideo(); STATE.slideshow.isVideoPlaying = true; } catch (e) { console.warn("Error resuming video on tab show:", e); if (STATE.slideshow.slideInterval) { STATE.slideshow.slideInterval.start(); } } } else if (player.tagName === 'VIDEO') { // HTML5 Video try { player.play().catch(e => console.warn("Error resuming HTML5 video:", e)); STATE.slideshow.isVideoPlaying = true; } catch(e) { console.warn(e); } } } else { // No video was playing, just restart interval const activeSlide = document.querySelector('.slide.active'); const hasVideo = activeSlide && activeSlide.querySelector('.video-backdrop'); if (CONFIG.waitForTrailerToEnd && hasVideo) { // Don't restart interval if waiting for trailer } else { if (STATE.slideshow.slideInterval) { STATE.slideshow.slideInterval.start(); } } } wasVideoPlayingBeforeHide = false; } } }); }; /** * 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(); });