/*
* 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:
'',
rottenTomato:
'',
},
shuffleInterval: 7000,
retryInterval: 500,
minSwipeDistance: 50,
loadingCheckInterval: 100,
maxPlotLength: 360,
maxMovies: 15,
maxTvShows: 15,
maxItems: 500,
preloadCount: 3,
fadeTransitionDuration: 500,
maxPaginationDots: 15,
showPaginationDots: true,
maxParentalRating: null,
maxDaysRecent: null,
slideAnimationEnabled: true,
enableVideoBackdrop: true,
useSponsorBlock: true,
preferLocalTrailers: false,
randomizeLocalTrailers: false,
preferLocalBackdrops: false,
randomizeThemeVideos: false,
includeWatchedContent: 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: "[]",
excludeSeasonalContent: true,
isEnabled: true,
};
// 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("🎬 Media Bar:", "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("🎬 Media Bar:", "Error checking login status:", error);
return false;
}
};
/**
* Detects if the current device is a low-power device (Smart TVs, etc.)
* @returns {boolean} True if running on a low-power device
*/
const isLowPowerDevice = () => {
return /webOS|LG Browser|SMART-TV|SmartTV|Tizen|Viera|NetCast|Roku|VIDAA/i.test(navigator.userAgent);
};
/**
* Initializes Jellyfin data from ApiClient
* @param {Function} callback - Function to call once data is initialized
*/
const initJellyfinData = (callback) => {
if (!window.ApiClient) {
console.warn("🎬 Media Bar:", "⏳ 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("🎬 Media Bar:", "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("🎬 Media Bar:", "✅ Localization initialized");
} catch (error) {
console.error("🎬 Media Bar:", "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("🎬 Media Bar:", "🔄 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("🎬 Media Bar:", "👤 User logged in. Initializing slideshow...");
if (!STATE.slideshow.hasInitialized) {
waitForApiClientAndInitialize();
} else {
console.log("🎬 Media Bar:", "🔄 Slideshow already initialized, skipping");
}
} else {
console.log("🎬 Media Bar:", "👋 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("🎬 Media Bar:", "⏳ ApiClient not available yet. Waiting...");
return;
}
if (
window.ApiClient._currentUser &&
window.ApiClient._currentUser.Id &&
window.ApiClient._serverInfo &&
window.ApiClient._serverInfo.AccessToken
) {
console.log("🎬 Media Bar:",
"🔓 User is fully logged in. Starting slideshow initialization..."
);
clearInterval(window.slideshowCheckInterval);
if (!STATE.slideshow.hasInitialized) {
initJellyfinData(async () => {
console.log("🎬 Media Bar:", "✅ Jellyfin API client initialized successfully");
await initLocalization();
await fetchPluginConfig();
slidesInit();
});
} else {
console.log("🎬 Media Bar:", "🔄 Slideshow already initialized, skipping");
}
} else {
console.log("🎬 Media Bar:",
"🔒 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("🎬 Media Bar:", "✅ MediaBarEnhanced config loaded", CONFIG);
}
}
} catch (e) {
console.error("🎬 Media Bar:", "Failed to load MediaBarEnhanced config", e);
}
};
waitForApiClientAndInitialize();
/**
* Utility functions for slide creation and management
*/
const SlideUtils = {
/**
* Sorts items based on configuration
* @param {Array