From a7ed05c95e6733affa9742dcabf465b15469dd68 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 9 Dec 2025 20:01:00 +0100 Subject: [PATCH 1/3] feat: add localization --- slideshowpure.js | 232 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 221 insertions(+), 11 deletions(-) diff --git a/slideshowpure.js b/slideshowpure.js index 2b3262c..63c5f09 100644 --- a/slideshowpure.js +++ b/slideshowpure.js @@ -146,6 +146,19 @@ const initJellyfinData = (callback) => { } }; +/** + * 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 */ @@ -312,8 +325,9 @@ const waitForApiClientAndInitialize = () => { clearInterval(window.slideshowCheckInterval); if (!STATE.slideshow.hasInitialized) { - initJellyfinData(() => { + initJellyfinData(async () => { console.log("✅ Jellyfin API client initialized successfully"); + await initLocalization(); slidesInit(); }); } else { @@ -452,6 +466,197 @@ const SlideUtils = { }, }; +/** + * 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 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 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"; + } + + 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 */ @@ -793,7 +998,7 @@ const SlideCreator = { const backdrop = SlideUtils.createElement("img", { className: "backdrop high-quality", src: `${serverAddress}/Items/${itemId}/Images/Backdrop/0?quality=60`, - alt: "Backdrop", + alt: LocalizationUtils.getLocalizedString('Backdrop', 'Backdrop'), loading: "eager", }); @@ -949,14 +1154,16 @@ const SlideCreator = { className: "runTime", }); if (seasonCount) { - container.innerHTML = `${seasonCount} Season${seasonCount > 1 ? "s" : ""}`; + 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); - container.innerText = `Ends at ${formattedEndTime}`; + const endsAtText = LocalizationUtils.getLocalizedString('EndsAtValue', 'Ends at {0}', formattedEndTime); + container.innerText = endsAtText; } miscInfo.appendChild(container); } @@ -970,10 +1177,11 @@ const SlideCreator = { * @returns {HTMLElement} Play button element */ createPlayButton(itemId) { + const playText = LocalizationUtils.getLocalizedString('Play', 'Play'); return SlideUtils.createElement("button", { className: "detailButton btnPlay play-button", innerHTML: ` - Play + ${playText} `, tabIndex: "0", onclick: (e) => { @@ -1322,13 +1530,15 @@ const SlideshowManager = { if (STATE.slideshow.isPaused) { STATE.slideshow.slideInterval.stop(); pauseButton.innerHTML = 'play_arrow'; - pauseButton.setAttribute("aria-label", "Play slideshow"); - pauseButton.setAttribute("title", "Play slideshow"); + const playLabel = LocalizationUtils.getLocalizedString('Play', 'Play'); + pauseButton.setAttribute("aria-label", playLabel); + pauseButton.setAttribute("title", playLabel); } else { STATE.slideshow.slideInterval.start(); pauseButton.innerHTML = 'pause'; - pauseButton.setAttribute("aria-label", "Pause slideshow"); - pauseButton.setAttribute("title", "Pause slideshow"); + const pauseLabel = LocalizationUtils.getLocalizedString('ButtonPause', 'Pause'); + pauseButton.setAttribute("aria-label", pauseLabel); + pauseButton.setAttribute("title", pauseLabel); } }, @@ -1505,8 +1715,8 @@ const initArrowNavigation = () => { className: "pause-button", innerHTML: 'pause', tabIndex: "0", - "aria-label": "Pause slideshow", - title: "Pause slideshow", + "aria-label": LocalizationUtils.getLocalizedString('ButtonPause', 'Pause'), + title: LocalizationUtils.getLocalizedString('ButtonPause', 'Pause'), onclick: (e) => { e.preventDefault(); e.stopPropagation(); From b1854c92242e68ba750fd39c6b8e7bca17d9a576 Mon Sep 17 00:00:00 2001 From: M0RPH3US Date: Wed, 10 Dec 2025 12:05:24 +0530 Subject: [PATCH 2/3] Update version number in slideshowpure.js --- slideshowpure.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slideshowpure.js b/slideshowpure.js index e206fa5..cfe3562 100644 --- a/slideshowpure.js +++ b/slideshowpure.js @@ -1,5 +1,5 @@ /* - * Jellyfin Slideshow by M0RPH3US v3.0.5 + * Jellyfin Slideshow by M0RPH3US v3.0.6 */ //Core Module Configuration From 0217ea13dd056a9e2f75c8badfd286b8270baa36 Mon Sep 17 00:00:00 2001 From: M0RPH3US Date: Wed, 10 Dec 2025 12:05:46 +0530 Subject: [PATCH 3/3] Update version number to 3.0.6 in CSS file --- slideshowpure.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slideshowpure.css b/slideshowpure.css index 1b9b35d..8982932 100644 --- a/slideshowpure.css +++ b/slideshowpure.css @@ -1,5 +1,5 @@ /* - * Jellyfin Slideshow by M0RPH3US v3.0.5 + * Jellyfin Slideshow by M0RPH3US v3.0.6 */ @import url(https://fonts.googleapis.com/css2?family=Archivo+Narrow:ital,wght@0,400..700;1,400..700&display=swap);