/*
* Jellyfin Slideshow by M0RPH3US v3.0.8
*/
//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,
slideAnimationEnabled: 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,
},
};
// 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;
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();
}
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.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();
slidesInit();
});
} else {
console.log("🔄 Slideshow already initialized, skipping");
}
} else {
console.log(
"🔒 Authentication incomplete. Waiting for complete login..."
);
}
}, CONFIG.retryInterval);
};
waitForApiClientAndInitialize();
/**
* Utility functions for slide creation and management
*/
const SlideUtils = {
/**
* 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;
},
};
/**
* 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;
if (window.ApiClient && STATE.jellyfinData?.accessToken) {
try {
const userData = window.ApiClient.getCurrentUser();
if (userData.Configuration?.AudioLanguagePreference) {
locale = userData.Configuration.AudioLanguagePreference.toLowerCase();
}
} catch (error) {
console.warn("Could not fetch user 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 language preference:", error);
}
}
if (!locale) {
const langAttr = document.documentElement.getAttribute("lang");
if (langAttr) {
locale = langAttr.toLowerCase();
}
}
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}`
);
}
const chunkText = await response.text();
let jsonMatch = chunkText.match(/JSON\.parse\(['"](.*?)['"]\)/);
if (jsonMatch) {
let jsonString = jsonMatch[1]
.replace(/\\"/g, '"')
.replace(/\\n/g, "\n")
.replace(/\\\\/g, "\\")
.replace(/\\'/g, "'");
try {
this.translations[locale] = JSON.parse(jsonString);
return;
} catch (e) {
// Try direct extraction
}
}
const jsonStart = chunkText.indexOf("{");
const jsonEnd = chunkText.lastIndexOf("}") + 1;
if (jsonStart !== -1 && jsonEnd > jsonStart) {
const jsonString = chunkText.substring(jsonStart, jsonEnd);
try {
this.translations[locale] = JSON.parse(jsonString);
} 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