From 84c9fa16e1794f48208217a690bed85cbe698349 Mon Sep 17 00:00:00 2001 From: enyineer Date: Sun, 8 Mar 2026 11:38:14 +0100 Subject: [PATCH 01/11] feat: introduce comprehensive settings management with user profiles and an expanded configuration interface across frontend and backend. Signed-off-by: enyineer --- backend/Models/MoonfinSettingsProfile.cs | 9 + backend/Models/MoonfinUserSettings.cs | 6 + backend/Pages/configPage.html | 689 +++--- backend/Services/MoonfinSettingsService.cs | 3 + frontend/src/components/mediabar.js | 1542 +++++++------ frontend/src/components/settings.js | 2369 ++++++++++++-------- frontend/src/styles/settings.css | 824 ++++--- frontend/src/utils/api.js | 1294 ++++++----- frontend/src/utils/storage.js | 1449 ++++++------ 9 files changed, 4692 insertions(+), 3493 deletions(-) diff --git a/backend/Models/MoonfinSettingsProfile.cs b/backend/Models/MoonfinSettingsProfile.cs index 901964d..390ed6d 100644 --- a/backend/Models/MoonfinSettingsProfile.cs +++ b/backend/Models/MoonfinSettingsProfile.cs @@ -105,6 +105,15 @@ public class MoonfinSettingsProfile [JsonPropertyName("mediaBarTrailerPreview")] public bool? MediaBarTrailerPreview { get; set; } + [JsonPropertyName("mediaBarSourceType")] + public string? MediaBarSourceType { get; set; } + + [JsonPropertyName("mediaBarCollectionIds")] + public List? MediaBarCollectionIds { get; set; } + + [JsonPropertyName("mediaBarShuffleItems")] + public bool? MediaBarShuffleItems { get; set; } + [JsonPropertyName("seasonalSurprise")] public string? SeasonalSurprise { get; set; } diff --git a/backend/Models/MoonfinUserSettings.cs b/backend/Models/MoonfinUserSettings.cs index 7cb806f..b5dd916 100644 --- a/backend/Models/MoonfinUserSettings.cs +++ b/backend/Models/MoonfinUserSettings.cs @@ -100,6 +100,12 @@ public class MoonfinUserSettings public int? MediaBarIntervalMs { get; set; } [JsonPropertyName("mediaBarTrailerPreview")] public bool? MediaBarTrailerPreview { get; set; } + [JsonPropertyName("mediaBarSourceType")] + public string? MediaBarSourceType { get; set; } + [JsonPropertyName("mediaBarCollectionIds")] + public List? MediaBarCollectionIds { get; set; } + [JsonPropertyName("mediaBarShuffleItems")] + public bool? MediaBarShuffleItems { get; set; } [JsonPropertyName("seasonalSurprise")] public string? SeasonalSurprise { get; set; } [JsonPropertyName("backdropEnabled")] diff --git a/backend/Pages/configPage.html b/backend/Pages/configPage.html index 5432959..30053e4 100644 --- a/backend/Pages/configPage.html +++ b/backend/Pages/configPage.html @@ -1,316 +1,425 @@ + - - Moonfin + + Moonfin - -
-
-
-
-
-

Moonfin Settings

-
-
-

General

-
- -
- Allow Moonfin clients to sync their settings across devices via this server. -
-
-
+ +
+
+
+ +
+

Moonfin Settings

+
-
-

Jellyseerr / Seerr Integration

-
- -
- Enable Jellyseerr or Seerr integration for all users. Users can configure their own authentication method in their client settings. -
-
-
- - -
- The URL of your Jellyseerr or Seerr instance. This URL is used for server-to-server API proxying. -
-
-
- - -
- Optional override. The name shown in the UI (e.g., "Requests", "Media Requests"). - Leave blank to auto-detect "Jellyseerr" or "Seerr" based on the configured URL. - The icon is always auto-detected regardless of this setting. -
-
-
- - -
- Optional: When set, the client-side iframe loads directly from this URL - instead of through the proxy. Use this if you're running Seerr v3 (Next.js) behind a reverse proxy - and experiencing issues with the default proxy mode. -

- When to use: If Seerr works when accessed directly but not through Moonfin's proxy, - set this to your public Seerr URL (e.g., https://seerr.yourdomain.com). - SSO API calls will still go through the Jellyfin server. -
-
-
- SSO Proxy: When Jellyseerr is enabled, Moonfin provides single sign-on. - Users authenticate once with their Jellyfin credentials, and the session is stored server-side. - All Moonfin clients (web, Android, Roku, webOS, Tizen, etc) can access Jellyseerr through /Moonfin/Jellyseerr/Api/ - without re-authenticating. -
-
+
+

General

+
+ +
+ Allow Moonfin clients to sync their settings across devices via this server. +
+
+
-
-

MDBList Integration

-
- - -
- Provide a server-wide MDBList API key - that all users can use for ratings (IMDb, Rotten Tomatoes, Metacritic, etc.). - Users who set their own key in their Moonfin client settings will use that instead. -
-
-
+
+

Jellyseerr / Seerr Integration

+
+ +
+ Enable Jellyseerr or Seerr integration for all users. Users can configure their own authentication + method in their client settings. +
+
+
+ + +
+ The URL of your Jellyseerr or Seerr instance. This URL is used for server-to-server API proxying. +
+
+
+ + +
+ Optional override. The name shown in the UI (e.g., "Requests", "Media Requests"). + Leave blank to auto-detect "Jellyseerr" or "Seerr" based on the configured URL. + The icon is always auto-detected regardless of this setting. +
+
+
+ + +
+ Optional: When set, the client-side iframe loads directly from this URL + instead of through the proxy. Use this if you're running Seerr v3 (Next.js) behind a reverse proxy + and experiencing issues with the default proxy mode. +

+ When to use: If Seerr works when accessed directly but not through Moonfin's proxy, + set this to your public Seerr URL (e.g., https://seerr.yourdomain.com). + SSO API calls will still go through the Jellyfin server. +
+
+
+ SSO Proxy: When Jellyseerr is enabled, Moonfin provides single sign-on. + Users authenticate once with their Jellyfin credentials, and the session is stored server-side. + All Moonfin clients (web, Android, Roku, webOS, Tizen, etc) can access Jellyseerr through + /Moonfin/Jellyseerr/Api/ + without re-authenticating. +
+
-
-

TMDB Integration

-
- - -
- Provide a server-wide TMDB API key - that all users can use for episode ratings. - Users who set their own key in their Moonfin client settings will use that instead. - Supports both v3 API keys and v4 Bearer tokens. -
-
-
+
+

MDBList Integration

+
+ + +
+ Provide a server-wide MDBList API key + that all users can use for ratings (IMDb, Rotten Tomatoes, Metacritic, etc.). + Users who set their own key in their Moonfin client settings will use that instead. +
+
+
-
-

Default User Settings

-
- Configure default Moonfin settings for all users. New users and users who haven't customized - a setting will see these values. Users can always override defaults in their own Moonfin settings panel. -
+
+

TMDB Integration

+
+ + +
+ Provide a server-wide TMDB API key + that all users can use for episode ratings. + Users who set their own key in their Moonfin client settings will use that instead. + Supports both v3 API keys and v4 Bearer tokens. +
+
+
-
- How it works: These are the lowest-priority fallback. Resolution order: - User's device profile → User's global settings → These admin defaults → Built-in defaults. -
+
+

Default User Settings

+
+ Configure default Moonfin settings for all users. New users and users who haven't customized + a setting will see these values. Users can always override defaults in their own Moonfin settings panel. +
-

UI Features

-
- -
Enable the custom Moonfin navigation bar by default.
-
-
- - -
-
- -
Enable the featured media carousel on the home page by default.
-
-
- -
Use the custom Moonfin details page by default.
-
+
+ How it works: These are the lowest-priority fallback. Resolution order: + User's device profile → User's global settings → These admin defaults → + Built-in defaults. +
-

Toolbar Buttons

-
- -
Show clock in navigation bar by default.
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
+

UI Features

+
+ +
Enable the custom Moonfin navigation bar by default.
+
+
+ + +
+
+ +
Enable the featured media carousel on the home page by default.
+
+
+ + +
Default content source for the media bar. Users can override this.
+
+ +
+ +
Randomize the order of items from collections/playlists by default.
+
+
+ +
Use the custom Moonfin details page by default.
+
-

Integrations

-
- -
Enable MDBList ratings by default (requires server-wide or user API key).
-
-
- -
Enable TMDB episode ratings by default (requires server-wide or user API key).
-
-
+

Toolbar Buttons

+
+ +
Show clock in navigation bar by default.
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
-
-

Web Plugin Status

-
-

Moonfin uses the - File Transformation - plugin to inject its web UI. -

-

- Setup: -

-
    -
  1. Install the File Transformation plugin (add https://www.iamparadox.dev/jellyfin/plugins/manifest.json to your plugin repositories)
  2. -
  3. Restart Jellyfin
  4. -
  5. Force refresh your browser (Ctrl+Shift+R)
  6. -
-

- UI not loading? Go to Dashboard > Scheduled Tasks and run the - Moonfin Startup task once, then refresh your browser. -

-
-
+

Integrations

+
+ +
Enable MDBList ratings by default (requires server-wide or user API key). +
+
+
+ +
Enable TMDB episode ratings by default (requires server-wide or user API + key).
+
+
-
- -
- +
+

Web Plugin Status

+
+

Moonfin uses the + File + Transformation + plugin to inject its web UI. +

+

+ Setup: +

+
    +
  1. Install the File Transformation plugin (add + https://www.iamparadox.dev/jellyfin/plugins/manifest.json to your plugin repositories) +
  2. +
  3. Restart Jellyfin
  4. +
  5. Force refresh your browser (Ctrl+Shift+R)
  6. +
+

+ UI not loading? Go to Dashboard > Scheduled Tasks and run the + Moonfin Startup task once, then refresh your browser. +

-
+
+ +
+ +
+ +
+
+ + -
+ return false; + }); + }); + +
- + + \ No newline at end of file diff --git a/backend/Services/MoonfinSettingsService.cs b/backend/Services/MoonfinSettingsService.cs index 2ec7143..f5a51ac 100644 --- a/backend/Services/MoonfinSettingsService.cs +++ b/backend/Services/MoonfinSettingsService.cs @@ -279,6 +279,9 @@ private void ClearLegacyFields(MoonfinUserSettings settings) settings.MediaBarAutoAdvance = null; settings.MediaBarIntervalMs = null; settings.MediaBarTrailerPreview = null; + settings.MediaBarSourceType = null; + settings.MediaBarCollectionIds = null; + settings.MediaBarShuffleItems = null; settings.SeasonalSurprise = null; settings.BackdropEnabled = null; settings.HomeRowsImageTypeOverride = null; diff --git a/frontend/src/components/mediabar.js b/frontend/src/components/mediabar.js index a7a1af3..8df8b21 100644 --- a/frontend/src/components/mediabar.js +++ b/frontend/src/components/mediabar.js @@ -1,759 +1,899 @@ var MediaBar = { - container: null, - initialized: false, - items: [], - currentIndex: 0, - isPaused: false, - autoAdvanceTimer: null, - isVisible: true, - - _trailerState: 'idle', - _trailerPlayer: null, - _trailerRevealTimer: null, - _trailerVideoId: null, - _sponsorSegments: [], - _trailerRevealMs: 4000, - _ytApiReady: false, - _ytApiLoading: false, - - async init() { - var settings = Storage.getAll(); - if (!settings.mediaBarEnabled) { - document.body.classList.remove('moonfin-mediabar-active'); - return; - } - - if (this.initialized) return; - - this.createMediaBar(); - this.container.classList.add('loading'); - - if (Plugin.isHomePage()) { - document.body.classList.add('moonfin-mediabar-active'); - } + container: null, + initialized: false, + items: [], + currentIndex: 0, + isPaused: false, + autoAdvanceTimer: null, + isVisible: true, + + _trailerState: "idle", + _trailerPlayer: null, + _trailerRevealTimer: null, + _trailerVideoId: null, + _sponsorSegments: [], + _trailerRevealMs: 4000, + _ytApiReady: false, + _ytApiLoading: false, + + async init() { + var settings = Storage.getAll(); + if (!settings.mediaBarEnabled) { + document.body.classList.remove("moonfin-mediabar-active"); + return; + } - this.setupEventListeners(); - this.initialized = true; - - this._loadContentAsync(settings); - }, - - _loadContentAsync(settings) { - var self = this; - this.waitForApi().then(function() { - return self.loadContent(); - }).then(function() { - if (self.items.length > 0) { - self.container.classList.remove('loading'); - if (settings.mediaBarAutoAdvance) { - self.startAutoAdvance(); - } - } else { - document.body.classList.remove('moonfin-mediabar-active'); - self.container.classList.add('empty'); - } - }).catch(function(e) { - console.error('[Moonfin] MediaBar: Failed to load content -', e.message); - document.body.classList.remove('moonfin-mediabar-active'); - if (self.container) self.container.classList.add('empty'); - }); - }, - - waitForApi() { - return new Promise(function(resolve, reject) { - var attempts = 0; - var maxAttempts = 50; - - var check = function() { - var api = API.getApiClient(); - if (api) { - try { - var userId = api.getCurrentUserId(); - if (userId) { - resolve(); - return; - } - } catch (e) { - // Not authenticated yet - } - } - - if (attempts >= maxAttempts) { - reject(new Error('API timeout')); - } else { - attempts++; - setTimeout(check, 100); - } - }; - check(); - }); - }, + if (this.initialized) return; - createMediaBar() { - var existing = document.querySelector('.moonfin-mediabar'); - if (existing) { - existing.remove(); - } + this.createMediaBar(); + this.container.classList.add("loading"); - var settings = Storage.getAll(); - var overlayColor = Storage.getColorRgba(settings.mediaBarOverlayColor, settings.mediaBarOverlayOpacity); - - this.container = document.createElement('div'); - this.container.className = 'moonfin-mediabar'; - this.container.innerHTML = - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '
' + - '
' + - '' + - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '' + - '
' + - '
' + - '
' + - '
'; - - document.body.appendChild(this.container); - }, - - async loadContent() { - var settings = Storage.getAll(); - - this.items = await API.getRandomItems({ - contentType: settings.mediaBarContentType, - limit: settings.mediaBarItemCount - }); + if (Plugin.isHomePage()) { + document.body.classList.add("moonfin-mediabar-active"); + } - if (this.items.length > 0) { - this.updateDisplay(); - this.updateDots(); + this.setupEventListeners(); + this.initialized = true; + + this._loadContentAsync(settings); + }, + + _loadContentAsync(settings) { + var self = this; + this.waitForApi() + .then(function () { + return self.loadContent(); + }) + .then(function () { + if (self.items.length > 0) { + self.container.classList.remove("loading"); + if (settings.mediaBarAutoAdvance) { + self.startAutoAdvance(); + } } else { - this.container.classList.add('empty'); + document.body.classList.remove("moonfin-mediabar-active"); + self.container.classList.add("empty"); + } + }) + .catch(function (e) { + console.error( + "[Moonfin] MediaBar: Failed to load content -", + e.message, + ); + document.body.classList.remove("moonfin-mediabar-active"); + if (self.container) self.container.classList.add("empty"); + }); + }, + + waitForApi() { + return new Promise(function (resolve, reject) { + var attempts = 0; + var maxAttempts = 50; + + var check = function () { + var api = API.getApiClient(); + if (api) { + try { + var userId = api.getCurrentUserId(); + if (userId) { + resolve(); + return; + } + } catch (e) { + // Not authenticated yet + } } - }, - - updateDisplay() { - var item = this.items[this.currentIndex]; - if (!item) return; - - this.stopTrailer(); - - var backdropUrl = API.getImageUrl(item, 'Backdrop', { maxWidth: 1920 }); - this.updateBackdrop(backdropUrl); - var logoUrl = API.getImageUrl(item, 'Logo', { maxWidth: 500 }); - var logoContainer = this.container.querySelector('.moonfin-mediabar-logo-container'); - var logoImg = this.container.querySelector('.moonfin-mediabar-logo'); - - if (logoUrl) { - logoImg.src = logoUrl; - logoImg.alt = item.Name; - logoContainer.classList.remove('hidden'); + if (attempts >= maxAttempts) { + reject(new Error("API timeout")); } else { - logoContainer.classList.add('hidden'); - } - - var yearEl = this.container.querySelector('.moonfin-mediabar-year'); - var ratingBadge = this.container.querySelector('.moonfin-mediabar-rating-badge'); - var runtimeEl = this.container.querySelector('.moonfin-mediabar-runtime'); - var genresEl = this.container.querySelector('.moonfin-mediabar-genres'); - var ratingsEl = this.container.querySelector('.moonfin-mediabar-ratings'); - var overviewEl = this.container.querySelector('.moonfin-mediabar-overview'); + attempts++; + setTimeout(check, 100); + } + }; + check(); + }); + }, + + createMediaBar() { + var existing = document.querySelector(".moonfin-mediabar"); + if (existing) { + existing.remove(); + } - yearEl.textContent = item.ProductionYear || ''; + var settings = Storage.getAll(); + var overlayColor = Storage.getColorRgba( + settings.mediaBarOverlayColor, + settings.mediaBarOverlayOpacity, + ); + + this.container = document.createElement("div"); + this.container.className = "moonfin-mediabar"; + this.container.innerHTML = + '
' + + '
' + + '
' + + "
" + + '
' + + '
' + + '
' + + '
' + + '' + + "
" + + '
' + + '" + + '
' + + '
' + + "
" + + "
" + + '
' + + '" + + '" + + "
" + + '
' + + '
' + + "
"; + + document.body.appendChild(this.container); + }, + + async loadContent() { + var settings = Storage.getAll(); + + if ( + settings.mediaBarSourceType === "collection" && + settings.mediaBarCollectionIds && + settings.mediaBarCollectionIds.length > 0 + ) { + this.items = await API.getCollectionItems( + settings.mediaBarCollectionIds, + { + limit: settings.mediaBarItemCount, + shuffle: settings.mediaBarShuffleItems !== false, + }, + ); + } else { + this.items = await API.getRandomItems({ + contentType: settings.mediaBarContentType, + limit: settings.mediaBarItemCount, + }); + } - if (item.OfficialRating) { - ratingBadge.textContent = item.OfficialRating; - ratingBadge.classList.remove('hidden'); - } else { - ratingBadge.textContent = ''; - ratingBadge.classList.add('hidden'); - } + if (this.items.length > 0) { + this.updateDisplay(); + this.updateDots(); + } else { + this.container.classList.add("empty"); + } + }, - if (item.RunTimeTicks) { - var minutes = Math.round(item.RunTimeTicks / 600000000); - var hours = Math.floor(minutes / 60); - var mins = minutes % 60; - runtimeEl.textContent = hours > 0 ? hours + 'h ' + mins + 'm' : mins + 'm'; - } else { - runtimeEl.textContent = ''; - } + updateDisplay() { + var item = this.items[this.currentIndex]; + if (!item) return; - if (item.Genres && item.Genres.length > 0) { - genresEl.textContent = item.Genres.slice(0, 3).join(' \u2022 '); - } else { - genresEl.textContent = ''; - } + this.stopTrailer(); - var ratingParts = []; - if (item.CommunityRating) { - ratingParts.push('\u2605 ' + item.CommunityRating.toFixed(1)); - } - if (item.CriticRating) { - ratingParts.push('\uD83C\uDF45 ' + item.CriticRating + '%'); - } - ratingsEl.textContent = ratingParts.join(' \u2022 '); - - if (MdbList.isEnabled()) { - var currentIdx = this.currentIndex; - MdbList.fetchRatings(item).then(function(mdbRatings) { - if (MediaBar.currentIndex !== currentIdx) return; - if (mdbRatings && mdbRatings.length > 0) { - var mdbHtml = MdbList.buildRatingsHtml(mdbRatings, 'compact'); - if (mdbHtml) { - ratingsEl.innerHTML = mdbHtml; - } - } - }); - } + var backdropUrl = API.getImageUrl(item, "Backdrop", { maxWidth: 1920 }); + this.updateBackdrop(backdropUrl); - if (item.Overview) { - var tmp = document.createElement('div'); - tmp.innerHTML = item.Overview; - overviewEl.textContent = tmp.textContent || tmp.innerText || ''; - } else { - overviewEl.textContent = ''; - } + var logoUrl = API.getImageUrl(item, "Logo", { maxWidth: 500 }); + var logoContainer = this.container.querySelector( + ".moonfin-mediabar-logo-container", + ); + var logoImg = this.container.querySelector(".moonfin-mediabar-logo"); - this.updateActiveDot(); + if (logoUrl) { + logoImg.src = logoUrl; + logoImg.alt = item.Name; + logoContainer.classList.remove("hidden"); + } else { + logoContainer.classList.add("hidden"); + } - var settings = Storage.getAll(); - if (settings.mediaBarTrailerPreview) { - var currentIdx = this.currentIndex; - this.fetchAndPlayTrailer(item, currentIdx); - } - }, + var yearEl = this.container.querySelector(".moonfin-mediabar-year"); + var ratingBadge = this.container.querySelector( + ".moonfin-mediabar-rating-badge", + ); + var runtimeEl = this.container.querySelector(".moonfin-mediabar-runtime"); + var genresEl = this.container.querySelector(".moonfin-mediabar-genres"); + var ratingsEl = this.container.querySelector(".moonfin-mediabar-ratings"); + var overviewEl = this.container.querySelector(".moonfin-mediabar-overview"); + + yearEl.textContent = item.ProductionYear || ""; + + if (item.OfficialRating) { + ratingBadge.textContent = item.OfficialRating; + ratingBadge.classList.remove("hidden"); + } else { + ratingBadge.textContent = ""; + ratingBadge.classList.add("hidden"); + } - async fetchAndPlayTrailer(item, expectedIndex) { - if (item.RemoteTrailers) { - var videoId = this.extractYouTubeId(item.RemoteTrailers); - if (videoId && this.currentIndex === expectedIndex) { - this.startTrailerPreview(videoId); - } - return; - } + if (item.RunTimeTicks) { + var minutes = Math.round(item.RunTimeTicks / 600000000); + var hours = Math.floor(minutes / 60); + var mins = minutes % 60; + runtimeEl.textContent = + hours > 0 ? hours + "h " + mins + "m" : mins + "m"; + } else { + runtimeEl.textContent = ""; + } - var trailers = await API.getItemTrailers(item.Id); - if (this.currentIndex !== expectedIndex) return; - item.RemoteTrailers = trailers; - var videoId = this.extractYouTubeId(trailers); - if (videoId) { - this.startTrailerPreview(videoId); - } - }, + if (item.Genres && item.Genres.length > 0) { + genresEl.textContent = item.Genres.slice(0, 3).join(" \u2022 "); + } else { + genresEl.textContent = ""; + } - extractYouTubeId(trailers) { - if (!trailers || trailers.length === 0) return null; + var ratingParts = []; + if (item.CommunityRating) { + ratingParts.push("\u2605 " + item.CommunityRating.toFixed(1)); + } + if (item.CriticRating) { + ratingParts.push("\uD83C\uDF45 " + item.CriticRating + "%"); + } + ratingsEl.textContent = ratingParts.join(" \u2022 "); + + if (MdbList.isEnabled()) { + var currentIdx = this.currentIndex; + MdbList.fetchRatings(item).then(function (mdbRatings) { + if (MediaBar.currentIndex !== currentIdx) return; + if (mdbRatings && mdbRatings.length > 0) { + var mdbHtml = MdbList.buildRatingsHtml(mdbRatings, "compact"); + if (mdbHtml) { + ratingsEl.innerHTML = mdbHtml; + } + } + }); + } - for (var i = 0; i < trailers.length; i++) { - var url = trailers[i].Url || trailers[i].url || ''; - var match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/); - if (match) return match[1]; - } - return null; - }, - - startTrailerPreview(videoId) { - var self = this; - this._trailerState = 'resolving'; - this._trailerVideoId = videoId; - - this._ensureYTApi(function() { - if (self._trailerState !== 'resolving' || self._trailerVideoId !== videoId) return; - self.fetchSponsorSegments(videoId).then(function(segments) { - self._sponsorSegments = segments; - self._loadYTPlayer(videoId); - }).catch(function() { - self._sponsorSegments = []; - self._loadYTPlayer(videoId); - }); - }); - }, + if (item.Overview) { + var tmp = document.createElement("div"); + tmp.innerHTML = item.Overview; + overviewEl.textContent = tmp.textContent || tmp.innerText || ""; + } else { + overviewEl.textContent = ""; + } - _ensureYTApi(callback) { - if (this._ytApiReady && window.YT && window.YT.Player) { - callback(); - return; - } - var self = this; - if (!this._ytApiLoading) { - this._ytApiLoading = true; - var tag = document.createElement('script'); - tag.src = 'https://www.youtube.com/iframe_api'; - document.head.appendChild(tag); - } - var checkInterval = setInterval(function() { - if (window.YT && window.YT.Player) { - clearInterval(checkInterval); - self._ytApiReady = true; - self._ytApiLoading = false; - callback(); - } - }, 100); - setTimeout(function() { clearInterval(checkInterval); }, 10000); - }, + this.updateActiveDot(); - _loadYTPlayer(videoId) { - if (this._trailerState !== 'resolving') return; + var settings = Storage.getAll(); + if (settings.mediaBarTrailerPreview) { + var currentIdx = this.currentIndex; + this.fetchAndPlayTrailer(item, currentIdx); + } + }, + + async fetchAndPlayTrailer(item, expectedIndex) { + if (item.RemoteTrailers) { + var videoId = this.extractYouTubeId(item.RemoteTrailers); + if (videoId && this.currentIndex === expectedIndex) { + this.startTrailerPreview(videoId); + } + return; + } - var self = this; - var startTime = this.getTrailerStartTime(this._sponsorSegments); - var trailerContainer = this.container.querySelector('.moonfin-mediabar-trailer-container'); + var trailers = await API.getItemTrailers(item.Id); + if (this.currentIndex !== expectedIndex) return; + item.RemoteTrailers = trailers; + var videoId = this.extractYouTubeId(trailers); + if (videoId) { + this.startTrailerPreview(videoId); + } + }, - if (this._trailerPlayer) { - try { this._trailerPlayer.destroy(); } catch(e) {} - this._trailerPlayer = null; - } + extractYouTubeId(trailers) { + if (!trailers || trailers.length === 0) return null; - var playerDiv = document.createElement('div'); - playerDiv.id = 'moonfin-yt-player-' + Date.now(); - playerDiv.className = 'moonfin-mediabar-trailer-iframe'; - trailerContainer.innerHTML = ''; - trailerContainer.appendChild(playerDiv); - - this._trailerState = 'playing'; - this.stopAutoAdvance(); - - try { - this._trailerPlayer = new YT.Player(playerDiv.id, { - videoId: videoId, - playerVars: { - autoplay: 1, - mute: 1, - controls: 0, - start: Math.floor(startTime), - rel: 0, - modestbranding: 1, - playsinline: 1, - showinfo: 0, - iv_load_policy: 3, - disablekb: 1, - fs: 0, - origin: window.location.origin - }, - events: { - onReady: function(event) { - event.target.mute(); - event.target.playVideo(); - self._trailerRevealTimer = setTimeout(function() { - if (self._trailerState === 'playing') { - var iframe = trailerContainer.querySelector('iframe'); - if (iframe) iframe.classList.add('visible'); - self.container.classList.add('trailer-active'); - } - }, self._trailerRevealMs); - }, - onStateChange: function(event) { - if (event.data === 0) { - self.stopTrailer(); - } - }, - onError: function(event) { - console.warn('[Moonfin] MediaBar: YouTube player error:', event.data); - self._trailerState = 'unavailable'; - self.stopTrailer(); - } - } - }); - } catch(e) { - console.warn('[Moonfin] MediaBar: Failed to create YouTube player:', e); - this._trailerState = 'unavailable'; - } - }, - - fetchSponsorSegments(videoId) { - return new Promise(function(resolve) { - var url = 'https://sponsor.ajay.app/api/skipSegments?videoID=' + videoId + - '&categories=["sponsor","selfpromo","intro","outro","interaction","music_offtopic"]'; - - fetch(url).then(function(resp) { - if (!resp.ok) { resolve([]); return; } - return resp.json(); - }).then(function(data) { - if (!Array.isArray(data)) { resolve([]); return; } - var segments = []; - for (var i = 0; i < data.length; i++) { - if (data[i].segment && data[i].segment.length === 2) { - segments.push({ start: data[i].segment[0], end: data[i].segment[1] }); - } - } - resolve(segments); - }).catch(function() { - resolve([]); - }); + for (var i = 0; i < trailers.length; i++) { + var url = trailers[i].Url || trailers[i].url || ""; + var match = url.match( + /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/, + ); + if (match) return match[1]; + } + return null; + }, + + startTrailerPreview(videoId) { + var self = this; + this._trailerState = "resolving"; + this._trailerVideoId = videoId; + + this._ensureYTApi(function () { + if ( + self._trailerState !== "resolving" || + self._trailerVideoId !== videoId + ) + return; + self + .fetchSponsorSegments(videoId) + .then(function (segments) { + self._sponsorSegments = segments; + self._loadYTPlayer(videoId); + }) + .catch(function () { + self._sponsorSegments = []; + self._loadYTPlayer(videoId); }); - }, - - getTrailerStartTime(segments) { - var startTime = 0; - if (!segments || segments.length === 0) return startTime; - - var sorted = segments.slice().sort(function(a, b) { return a.start - b.start; }); - for (var i = 0; i < sorted.length; i++) { - if (sorted[i].start <= startTime + 1) { - startTime = Math.max(startTime, sorted[i].end); - } - } - return Math.max(startTime, 5); - }, + }); + }, - stopTrailer() { - if (this._trailerRevealTimer) { - clearTimeout(this._trailerRevealTimer); - this._trailerRevealTimer = null; - } - - if (this.container) this.container.classList.remove('trailer-active'); - - if (this._trailerPlayer) { - try { this._trailerPlayer.destroy(); } catch(e) {} - this._trailerPlayer = null; - } - - var trailerContainer = this.container ? this.container.querySelector('.moonfin-mediabar-trailer-container') : null; - if (trailerContainer) trailerContainer.innerHTML = ''; - - this._trailerState = 'idle'; - this._trailerVideoId = null; - this._sponsorSegments = []; + _ensureYTApi(callback) { + if (this._ytApiReady && window.YT && window.YT.Player) { + callback(); + return; + } + var self = this; + if (!this._ytApiLoading) { + this._ytApiLoading = true; + var tag = document.createElement("script"); + tag.src = "https://www.youtube.com/iframe_api"; + document.head.appendChild(tag); + } + var checkInterval = setInterval(function () { + if (window.YT && window.YT.Player) { + clearInterval(checkInterval); + self._ytApiReady = true; + self._ytApiLoading = false; + callback(); + } + }, 100); + setTimeout(function () { + clearInterval(checkInterval); + }, 10000); + }, + + _loadYTPlayer(videoId) { + if (this._trailerState !== "resolving") return; + + var self = this; + var startTime = this.getTrailerStartTime(this._sponsorSegments); + var trailerContainer = this.container.querySelector( + ".moonfin-mediabar-trailer-container", + ); + + if (this._trailerPlayer) { + try { + this._trailerPlayer.destroy(); + } catch (e) {} + this._trailerPlayer = null; + } - if (!this.isPaused) { - var settings = Storage.getAll(); - if (settings.mediaBarAutoAdvance && !this.autoAdvanceTimer) { - this.startAutoAdvance(); + var playerDiv = document.createElement("div"); + playerDiv.id = "moonfin-yt-player-" + Date.now(); + playerDiv.className = "moonfin-mediabar-trailer-iframe"; + trailerContainer.innerHTML = ""; + trailerContainer.appendChild(playerDiv); + + this._trailerState = "playing"; + this.stopAutoAdvance(); + + try { + this._trailerPlayer = new YT.Player(playerDiv.id, { + videoId: videoId, + playerVars: { + autoplay: 1, + mute: 1, + controls: 0, + start: Math.floor(startTime), + rel: 0, + modestbranding: 1, + playsinline: 1, + showinfo: 0, + iv_load_policy: 3, + disablekb: 1, + fs: 0, + origin: window.location.origin, + }, + events: { + onReady: function (event) { + event.target.mute(); + event.target.playVideo(); + self._trailerRevealTimer = setTimeout(function () { + if (self._trailerState === "playing") { + var iframe = trailerContainer.querySelector("iframe"); + if (iframe) iframe.classList.add("visible"); + self.container.classList.add("trailer-active"); + } + }, self._trailerRevealMs); + }, + onStateChange: function (event) { + if (event.data === 0) { + self.stopTrailer(); } - } - }, - - updateBackdrop(url) { - var current = this.container.querySelector('.moonfin-mediabar-backdrop-current'); - var next = this.container.querySelector('.moonfin-mediabar-backdrop-next'); - - if (!url) { - current.style.backgroundImage = ''; + }, + onError: function (event) { + console.warn( + "[Moonfin] MediaBar: YouTube player error:", + event.data, + ); + self._trailerState = "unavailable"; + self.stopTrailer(); + }, + }, + }); + } catch (e) { + console.warn("[Moonfin] MediaBar: Failed to create YouTube player:", e); + this._trailerState = "unavailable"; + } + }, + + fetchSponsorSegments(videoId) { + return new Promise(function (resolve) { + var url = + "https://sponsor.ajay.app/api/skipSegments?videoID=" + + videoId + + '&categories=["sponsor","selfpromo","intro","outro","interaction","music_offtopic"]'; + + fetch(url) + .then(function (resp) { + if (!resp.ok) { + resolve([]); return; - } - - if (this._crossfadeTimer) { - clearTimeout(this._crossfadeTimer); - this._crossfadeTimer = null; - } - - var img = new Image(); - var self = this; - var doSwap = function() { - next.style.transition = 'none'; - next.classList.remove('active'); - next.style.backgroundImage = "url('" + url + "')"; - - void next.offsetWidth; - next.style.transition = ''; - next.classList.add('active'); - - self._crossfadeTimer = setTimeout(function() { - current.style.backgroundImage = "url('" + url + "')"; - next.style.transition = 'none'; - next.classList.remove('active'); - void next.offsetWidth; - next.style.transition = ''; - self._crossfadeTimer = null; - }, 500); - }; - - img.onload = doSwap; - img.onerror = doSwap; - setTimeout(function() { - if (!img.complete) doSwap(); - }, 300); - img.src = url; - - this.preloadAdjacent(); - }, - - preloadAdjacent() { - if (!this.items || this.items.length < 2) return; - var nextIdx = (this.currentIndex + 1) % this.items.length; - var prevIdx = (this.currentIndex - 1 + this.items.length) % this.items.length; - var nextUrl = API.getImageUrl(this.items[nextIdx], 'Backdrop', { maxWidth: 1920 }); - var prevUrl = API.getImageUrl(this.items[prevIdx], 'Backdrop', { maxWidth: 1920 }); - if (nextUrl) { var i1 = new Image(); i1.src = nextUrl; } - if (prevUrl) { var i2 = new Image(); i2.src = prevUrl; } - }, - - updateDots() { - var dotsContainer = this.container.querySelector('.moonfin-mediabar-dots'); - var html = ''; - for (var i = 0; i < this.items.length; i++) { - html += ''; - } - dotsContainer.innerHTML = html; - }, - - updateActiveDot() { - var dots = this.container.querySelectorAll('.moonfin-mediabar-dot'); - for (var i = 0; i < dots.length; i++) { - dots[i].classList.toggle('active', i === this.currentIndex); - } - }, - - nextSlide() { - this.currentIndex = (this.currentIndex + 1) % this.items.length; - this.updateDisplay(); - this.resetAutoAdvance(); - }, - - prevSlide() { - this.currentIndex = (this.currentIndex - 1 + this.items.length) % this.items.length; - this.updateDisplay(); - this.resetAutoAdvance(); - }, - - goToSlide(index) { - if (index >= 0 && index < this.items.length) { - this.currentIndex = index; - this.updateDisplay(); - this.resetAutoAdvance(); - } - }, - - togglePause() { - this.isPaused = !this.isPaused; - this.container.classList.toggle('paused', this.isPaused); - - if (this.isPaused) { - this.stopAutoAdvance(); - } else { - this.startAutoAdvance(); - } - }, - - startAutoAdvance() { - var self = this; - var settings = Storage.getAll(); - if (!settings.mediaBarAutoAdvance) return; - - this.autoAdvanceTimer = setInterval(function() { - if (!self.isPaused && self.isVisible && self._trailerState === 'idle') { - self.nextSlide(); + } + return resp.json(); + }) + .then(function (data) { + if (!Array.isArray(data)) { + resolve([]); + return; + } + var segments = []; + for (var i = 0; i < data.length; i++) { + if (data[i].segment && data[i].segment.length === 2) { + segments.push({ + start: data[i].segment[0], + end: data[i].segment[1], + }); } - }, settings.mediaBarIntervalMs); - }, - - stopAutoAdvance() { - if (this.autoAdvanceTimer) { - clearInterval(this.autoAdvanceTimer); - this.autoAdvanceTimer = null; - } - }, + } + resolve(segments); + }) + .catch(function () { + resolve([]); + }); + }); + }, + + getTrailerStartTime(segments) { + var startTime = 0; + if (!segments || segments.length === 0) return startTime; + + var sorted = segments.slice().sort(function (a, b) { + return a.start - b.start; + }); + for (var i = 0; i < sorted.length; i++) { + if (sorted[i].start <= startTime + 1) { + startTime = Math.max(startTime, sorted[i].end); + } + } + return Math.max(startTime, 5); + }, - resetAutoAdvance() { - this.stopAutoAdvance(); - if (!this.isPaused) { - this.startAutoAdvance(); - } - }, + stopTrailer() { + if (this._trailerRevealTimer) { + clearTimeout(this._trailerRevealTimer); + this._trailerRevealTimer = null; + } - ensureInDOM() { - if (this.container && !document.body.contains(this.container)) { + if (this.container) this.container.classList.remove("trailer-active"); - document.body.appendChild(this.container); - } - }, + if (this._trailerPlayer) { + try { + this._trailerPlayer.destroy(); + } catch (e) {} + this._trailerPlayer = null; + } - setupEventListeners() { - var self = this; + var trailerContainer = this.container + ? this.container.querySelector(".moonfin-mediabar-trailer-container") + : null; + if (trailerContainer) trailerContainer.innerHTML = ""; - this.container.querySelector('.moonfin-mediabar-prev').addEventListener('click', function(e) { - e.stopPropagation(); - self.prevSlide(); - }); + this._trailerState = "idle"; + this._trailerVideoId = null; + this._sponsorSegments = []; - this.container.querySelector('.moonfin-mediabar-next').addEventListener('click', function(e) { - e.stopPropagation(); - self.nextSlide(); - }); + if (!this.isPaused) { + var settings = Storage.getAll(); + if (settings.mediaBarAutoAdvance && !this.autoAdvanceTimer) { + this.startAutoAdvance(); + } + } + }, - this.container.querySelector('.moonfin-mediabar-dots').addEventListener('click', function(e) { - e.stopPropagation(); - var dot = e.target.closest('.moonfin-mediabar-dot'); - if (dot) { - self.goToSlide(parseInt(dot.dataset.index, 10)); - } - }); + updateBackdrop(url) { + var current = this.container.querySelector( + ".moonfin-mediabar-backdrop-current", + ); + var next = this.container.querySelector(".moonfin-mediabar-backdrop-next"); - this.container.addEventListener('click', function(e) { - if (e.target.closest('.moonfin-mediabar-nav-btn, .moonfin-mediabar-dots, .moonfin-mediabar-dots-wrap')) { - return; - } - var item = self.items[self.currentIndex]; - if (item) { - if (Storage.get('detailsPageEnabled')) { - Details.showDetails(item.Id, item.Type); - } else { - API.navigateToItem(item.Id); - } - } - }); + if (!url) { + current.style.backgroundImage = ""; + return; + } - var touchStartX = 0; - var touchStartY = 0; - var touchMoved = false; - - this.container.addEventListener('touchstart', function(e) { - var touch = e.touches[0]; - touchStartX = touch.clientX; - touchStartY = touch.clientY; - touchMoved = false; - }, { passive: true }); - - this.container.addEventListener('touchmove', function(e) { - if (!touchStartX) return; - var dx = Math.abs(e.touches[0].clientX - touchStartX); - var dy = Math.abs(e.touches[0].clientY - touchStartY); - if (dx > 10 || dy > 10) touchMoved = true; - if (dx > dy && dx > 10) e.preventDefault(); - }, { passive: false }); - - this.container.addEventListener('touchend', function(e) { - if (!touchMoved) { touchStartX = 0; return; } - var dx = e.changedTouches[0].clientX - touchStartX; - if (Math.abs(dx) >= 50) { - if (dx < 0) self.nextSlide(); - else self.prevSlide(); - } - touchStartX = 0; - touchMoved = false; - }, { passive: true }); - - this.container.addEventListener('keydown', function(e) { - switch (e.key) { - case 'ArrowLeft': self.prevSlide(); e.preventDefault(); break; - case 'ArrowRight': self.nextSlide(); e.preventDefault(); break; - case ' ': self.togglePause(); e.preventDefault(); break; - case 'Enter': - var item = self.items[self.currentIndex]; - if (item) { - if (Storage.get('detailsPageEnabled')) { - Details.showDetails(item.Id, item.Type); - } else { - API.navigateToItem(item.Id); - } - } - e.preventDefault(); - break; - } - }); + if (this._crossfadeTimer) { + clearTimeout(this._crossfadeTimer); + this._crossfadeTimer = null; + } - this.container.addEventListener('mouseenter', function() { - self.container.classList.add('focused'); - }); + var img = new Image(); + var self = this; + var doSwap = function () { + next.style.transition = "none"; + next.classList.remove("active"); + next.style.backgroundImage = "url('" + url + "')"; + + void next.offsetWidth; + next.style.transition = ""; + next.classList.add("active"); + + self._crossfadeTimer = setTimeout(function () { + current.style.backgroundImage = "url('" + url + "')"; + next.style.transition = "none"; + next.classList.remove("active"); + void next.offsetWidth; + next.style.transition = ""; + self._crossfadeTimer = null; + }, 500); + }; + + img.onload = doSwap; + img.onerror = doSwap; + setTimeout(function () { + if (!img.complete) doSwap(); + }, 300); + img.src = url; + + this.preloadAdjacent(); + }, + + preloadAdjacent() { + if (!this.items || this.items.length < 2) return; + var nextIdx = (this.currentIndex + 1) % this.items.length; + var prevIdx = + (this.currentIndex - 1 + this.items.length) % this.items.length; + var nextUrl = API.getImageUrl(this.items[nextIdx], "Backdrop", { + maxWidth: 1920, + }); + var prevUrl = API.getImageUrl(this.items[prevIdx], "Backdrop", { + maxWidth: 1920, + }); + if (nextUrl) { + var i1 = new Image(); + i1.src = nextUrl; + } + if (prevUrl) { + var i2 = new Image(); + i2.src = prevUrl; + } + }, + + updateDots() { + var dotsContainer = this.container.querySelector(".moonfin-mediabar-dots"); + var html = ""; + for (var i = 0; i < this.items.length; i++) { + html += + ''; + } + dotsContainer.innerHTML = html; + }, - this.container.addEventListener('mouseleave', function() { - self.container.classList.remove('focused'); - }); + updateActiveDot() { + var dots = this.container.querySelectorAll(".moonfin-mediabar-dot"); + for (var i = 0; i < dots.length; i++) { + dots[i].classList.toggle("active", i === this.currentIndex); + } + }, + + nextSlide() { + this.currentIndex = (this.currentIndex + 1) % this.items.length; + this.updateDisplay(); + this.resetAutoAdvance(); + }, + + prevSlide() { + this.currentIndex = + (this.currentIndex - 1 + this.items.length) % this.items.length; + this.updateDisplay(); + this.resetAutoAdvance(); + }, + + goToSlide(index) { + if (index >= 0 && index < this.items.length) { + this.currentIndex = index; + this.updateDisplay(); + this.resetAutoAdvance(); + } + }, - document.addEventListener('visibilitychange', function() { - self.isVisible = !document.hidden; - if (document.hidden) { - self.stopTrailer(); - } - }); + togglePause() { + this.isPaused = !this.isPaused; + this.container.classList.toggle("paused", this.isPaused); - window.addEventListener('moonfin-settings-changed', function(e) { - self.applySettings(e.detail); - }); - }, + if (this.isPaused) { + this.stopAutoAdvance(); + } else { + this.startAutoAdvance(); + } + }, + + startAutoAdvance() { + var self = this; + var settings = Storage.getAll(); + if (!settings.mediaBarAutoAdvance) return; + + this.autoAdvanceTimer = setInterval(function () { + if (!self.isPaused && self.isVisible && self._trailerState === "idle") { + self.nextSlide(); + } + }, settings.mediaBarIntervalMs); + }, + + stopAutoAdvance() { + if (this.autoAdvanceTimer) { + clearInterval(this.autoAdvanceTimer); + this.autoAdvanceTimer = null; + } + }, - applySettings(settings) { - if (!this.container) return; + resetAutoAdvance() { + this.stopAutoAdvance(); + if (!this.isPaused) { + this.startAutoAdvance(); + } + }, - if (!settings.mediaBarEnabled) { - this.hide(); - return; + ensureInDOM() { + if (this.container && !document.body.contains(this.container)) { + document.body.appendChild(this.container); + } + }, + + setupEventListeners() { + var self = this; + + this.container + .querySelector(".moonfin-mediabar-prev") + .addEventListener("click", function (e) { + e.stopPropagation(); + self.prevSlide(); + }); + + this.container + .querySelector(".moonfin-mediabar-next") + .addEventListener("click", function (e) { + e.stopPropagation(); + self.nextSlide(); + }); + + this.container + .querySelector(".moonfin-mediabar-dots") + .addEventListener("click", function (e) { + e.stopPropagation(); + var dot = e.target.closest(".moonfin-mediabar-dot"); + if (dot) { + self.goToSlide(parseInt(dot.dataset.index, 10)); + } + }); + + this.container.addEventListener("click", function (e) { + if ( + e.target.closest( + ".moonfin-mediabar-nav-btn, .moonfin-mediabar-dots, .moonfin-mediabar-dots-wrap", + ) + ) { + return; + } + var item = self.items[self.currentIndex]; + if (item) { + if (Storage.get("detailsPageEnabled")) { + Details.showDetails(item.Id, item.Type); } else { - this.show(); - } - - var overlayColor = Storage.getColorRgba(settings.mediaBarOverlayColor, settings.mediaBarOverlayOpacity); + API.navigateToItem(item.Id); + } + } + }); + + var touchStartX = 0; + var touchStartY = 0; + var touchMoved = false; + + this.container.addEventListener( + "touchstart", + function (e) { + var touch = e.touches[0]; + touchStartX = touch.clientX; + touchStartY = touch.clientY; + touchMoved = false; + }, + { passive: true }, + ); + + this.container.addEventListener( + "touchmove", + function (e) { + if (!touchStartX) return; + var dx = Math.abs(e.touches[0].clientX - touchStartX); + var dy = Math.abs(e.touches[0].clientY - touchStartY); + if (dx > 10 || dy > 10) touchMoved = true; + if (dx > dy && dx > 10) e.preventDefault(); + }, + { passive: false }, + ); + + this.container.addEventListener( + "touchend", + function (e) { + if (!touchMoved) { + touchStartX = 0; + return; + } + var dx = e.changedTouches[0].clientX - touchStartX; + if (Math.abs(dx) >= 50) { + if (dx < 0) self.nextSlide(); + else self.prevSlide(); + } + touchStartX = 0; + touchMoved = false; + }, + { passive: true }, + ); + + this.container.addEventListener("keydown", function (e) { + switch (e.key) { + case "ArrowLeft": + self.prevSlide(); + e.preventDefault(); + break; + case "ArrowRight": + self.nextSlide(); + e.preventDefault(); + break; + case " ": + self.togglePause(); + e.preventDefault(); + break; + case "Enter": + var item = self.items[self.currentIndex]; + if (item) { + if (Storage.get("detailsPageEnabled")) { + Details.showDetails(item.Id, item.Type); + } else { + API.navigateToItem(item.Id); + } + } + e.preventDefault(); + break; + } + }); + + this.container.addEventListener("mouseenter", function () { + self.container.classList.add("focused"); + }); + + this.container.addEventListener("mouseleave", function () { + self.container.classList.remove("focused"); + }); + + document.addEventListener("visibilitychange", function () { + self.isVisible = !document.hidden; + if (document.hidden) { + self.stopTrailer(); + } + }); + + window.addEventListener("moonfin-settings-changed", function (e) { + self.applySettings(e.detail); + }); + }, + + applySettings(settings) { + if (!this.container) return; + + if (!settings.mediaBarEnabled) { + this.hide(); + return; + } else { + this.show(); + } - var infoBox = this.container.querySelector('.moonfin-mediabar-info'); - if (infoBox) infoBox.style.background = overlayColor; + var overlayColor = Storage.getColorRgba( + settings.mediaBarOverlayColor, + settings.mediaBarOverlayOpacity, + ); - this.container.querySelectorAll('.moonfin-mediabar-nav-btn').forEach(function(btn) { - btn.style.background = overlayColor; - }); + var infoBox = this.container.querySelector(".moonfin-mediabar-info"); + if (infoBox) infoBox.style.background = overlayColor; - var dotsWrap = this.container.querySelector('.moonfin-mediabar-dots-wrap'); - if (dotsWrap) dotsWrap.style.background = overlayColor; + this.container + .querySelectorAll(".moonfin-mediabar-nav-btn") + .forEach(function (btn) { + btn.style.background = overlayColor; + }); - this.updateDots(); - this.resetAutoAdvance(); + var dotsWrap = this.container.querySelector(".moonfin-mediabar-dots-wrap"); + if (dotsWrap) dotsWrap.style.background = overlayColor; - if (!settings.mediaBarTrailerPreview) { - this.stopTrailer(); - } + this.updateDots(); + this.resetAutoAdvance(); - if (this._lastContentType !== settings.mediaBarContentType || - this._lastItemCount !== settings.mediaBarItemCount) { - this._lastContentType = settings.mediaBarContentType; - this._lastItemCount = settings.mediaBarItemCount; - this.loadContent(); - } - }, + if (!settings.mediaBarTrailerPreview) { + this.stopTrailer(); + } - show() { - if (this.container) { - this.container.classList.remove('disabled'); - if (Plugin.isHomePage()) { - document.body.classList.add('moonfin-mediabar-active'); - } - } - }, + if ( + this._lastContentType !== settings.mediaBarContentType || + this._lastItemCount !== settings.mediaBarItemCount || + this._lastSourceType !== settings.mediaBarSourceType || + this._lastShuffleItems !== settings.mediaBarShuffleItems || + JSON.stringify(this._lastCollectionIds) !== + JSON.stringify(settings.mediaBarCollectionIds) + ) { + this._lastContentType = settings.mediaBarContentType; + this._lastItemCount = settings.mediaBarItemCount; + this._lastSourceType = settings.mediaBarSourceType; + this._lastCollectionIds = settings.mediaBarCollectionIds; + this._lastShuffleItems = settings.mediaBarShuffleItems; + this.loadContent(); + } + }, + + show() { + if (this.container) { + this.container.classList.remove("disabled"); + if (Plugin.isHomePage()) { + document.body.classList.add("moonfin-mediabar-active"); + } + } + }, - hide() { - if (this.container) { - this.container.classList.add('disabled'); - document.body.classList.remove('moonfin-mediabar-active'); - this.stopTrailer(); - } - }, - - async refresh() { - this.currentIndex = 0; - await this.loadContent(); - }, - - destroy() { - this.stopAutoAdvance(); - this.stopTrailer(); - if (this.container) { - this.container.remove(); - this.container = null; - } - document.body.classList.remove('moonfin-mediabar-active'); - this.initialized = false; - this.items = []; - this.currentIndex = 0; + hide() { + if (this.container) { + this.container.classList.add("disabled"); + document.body.classList.remove("moonfin-mediabar-active"); + this.stopTrailer(); + } + }, + + async refresh() { + this.currentIndex = 0; + await this.loadContent(); + }, + + destroy() { + this.stopAutoAdvance(); + this.stopTrailer(); + if (this.container) { + this.container.remove(); + this.container = null; } + document.body.classList.remove("moonfin-mediabar-active"); + this.initialized = false; + this.items = []; + this.currentIndex = 0; + }, }; diff --git a/frontend/src/components/settings.js b/frontend/src/components/settings.js index 26c2527..b73e47d 100644 --- a/frontend/src/components/settings.js +++ b/frontend/src/components/settings.js @@ -1,942 +1,1539 @@ var Settings = { - dialog: null, - isOpen: false, - _toastTimeout: null, - - show: function() { - if (this.isOpen) return; - - this.createDialog(); - // Trigger animation after append - var self = this; - requestAnimationFrame(function() { - requestAnimationFrame(function() { - if (self.dialog) { - self.dialog.classList.add('open'); - } - }); - }); - this.isOpen = true; - history.pushState({ moonfinSettings: true }, ''); - if (window.Moonfin && window.Moonfin.Plugin) window.Moonfin.Plugin._overlayHistoryDepth++; - else if (typeof Plugin !== 'undefined') Plugin._overlayHistoryDepth++; - }, - - hide: function(skipHistoryBack) { - if (!this.isOpen) return; - var self = this; - - this.isOpen = false; - - this.dialog.classList.remove('open'); - setTimeout(function() { - if (self.dialog) { - self.dialog.remove(); - self.dialog = null; - } - }, 300); - - if (!skipHistoryBack) { - try { history.back(); } catch(e) {} + dialog: null, + isOpen: false, + _toastTimeout: null, + + show: function () { + if (this.isOpen) return; + + this.createDialog(); + // Trigger animation after append + var self = this; + requestAnimationFrame(function () { + requestAnimationFrame(function () { + if (self.dialog) { + self.dialog.classList.add("open"); } - }, - - showToast: function(message) { - var existing = document.querySelector('.moonfin-toast'); - if (existing) existing.remove(); + }); + }); + this.isOpen = true; + history.pushState({ moonfinSettings: true }, ""); + if (window.Moonfin && window.Moonfin.Plugin) + window.Moonfin.Plugin._overlayHistoryDepth++; + else if (typeof Plugin !== "undefined") Plugin._overlayHistoryDepth++; + }, + + hide: function (skipHistoryBack) { + if (!this.isOpen) return; + var self = this; + + this.isOpen = false; + + this.dialog.classList.remove("open"); + setTimeout(function () { + if (self.dialog) { + self.dialog.remove(); + self.dialog = null; + } + }, 300); + + if (!skipHistoryBack) { + try { + history.back(); + } catch (e) {} + } + }, + + showToast: function (message) { + var existing = document.querySelector(".moonfin-toast"); + if (existing) existing.remove(); + + var toast = document.createElement("div"); + toast.className = "moonfin-toast"; + toast.textContent = message; + document.body.appendChild(toast); + + requestAnimationFrame(function () { + toast.classList.add("visible"); + }); + + if (this._toastTimeout) clearTimeout(this._toastTimeout); + this._toastTimeout = setTimeout(function () { + toast.classList.remove("visible"); + setTimeout(function () { + toast.remove(); + }, 300); + }, 2000); + }, + + saveSetting: function (name, value) { + var profileName = Storage.getActiveEditProfile(); + var profile = Storage.getProfile(profileName); + profile[name] = value; + Storage.saveProfile(profileName, profile); + var safeValue = + name.toLowerCase().indexOf("apikey") !== -1 || + name.toLowerCase().indexOf("token") !== -1 + ? "***" + : value; + console.log( + '[Moonfin] Setting saved to profile "' + profileName + '":', + name, + "=", + safeValue, + ); + }, + + createToggleCard: function (id, title, description, checked) { + return ( + '
' + + '" + + "
" + ); + }, + + createSelectCard: function (id, title, description, options, currentValue) { + var optionsHtml = ""; + for (var i = 0; i < options.length; i++) { + var opt = options[i]; + optionsHtml += + '"; + } - var toast = document.createElement('div'); - toast.className = 'moonfin-toast'; - toast.textContent = message; - document.body.appendChild(toast); + return ( + '
' + + '
' + + '
' + + title + + "
" + + (description + ? '
' + description + "
" + : "") + + "
" + + '" + + "
" + ); + }, + + createRangeCard: function ( + id, + title, + description, + min, + max, + step, + currentValue, + suffix, + ) { + return ( + '
' + + '
' + + '
' + + title + + ' ' + + currentValue + + (suffix || "") + + "
" + + (description + ? '
' + description + "
" + : "") + + "
" + + '' + + "
" + ); + }, + + createSection: function (icon, title, contentHtml, openByDefault) { + return ( + '
" + + '' + + (icon ? icon + " " : "") + + title + + "" + + '
' + + contentHtml + + "
" + + "
" + ); + }, + + createDialog: function () { + var existing = document.querySelector(".moonfin-settings-dialog"); + if (existing) existing.remove(); + + var settings = Storage.getAll(); + var self = this; + + this.dialog = document.createElement("div"); + this.dialog.className = "moonfin-settings-dialog"; + + var uiContent = + this.createToggleCard( + "navbarEnabled", + "Navigation Bar", + "Show the custom navigation bar with quick access buttons", + settings.navbarEnabled, + ) + + this.createSelectCard( + "navbarPosition", + "Navbar Position", + "Show the navigation bar at the top or as a left sidebar", + [ + { value: "top", label: "Top" }, + { value: "left", label: "Left (Sidebar)" }, + ], + settings.navbarPosition, + ) + + this.createToggleCard( + "mediaBarEnabled", + "Media Bar", + "Show the featured media carousel on the home page", + settings.mediaBarEnabled, + ) + + this.createToggleCard( + "detailsPageEnabled", + "Details Page", + "Use the custom Moonfin details page instead of the default Jellyfin one", + settings.detailsPageEnabled, + ); + + var mediaBarContent = + this.createSelectCard( + "mediaBarSourceType", + "Content Source", + "Where to pull media bar items from", + [ + { value: "library", label: "Library (Random)" }, + { value: "collection", label: "Collections / Playlists" }, + ], + settings.mediaBarSourceType, + ) + + '
' + + this.createSelectCard( + "mediaBarContentType", + "Content Type", + "What type of content to show in the media bar", + [ + { value: "both", label: "Movies & TV Shows" }, + { value: "movies", label: "Movies Only" }, + { value: "tv", label: "TV Shows Only" }, + ], + settings.mediaBarContentType, + ) + + "
" + + '
' + + '
' + + '
' + + '
Collections / Playlists
' + + '
Select which collections or playlists to show in the media bar
' + + "
" + + '
' + + '
Loading...
' + + "
" + + "
" + + this.createToggleCard( + "mediaBarShuffleItems", + "Shuffle Items", + "Randomize the order of items from selected collections", + settings.mediaBarShuffleItems !== false, + ) + + "
" + + this.createSelectCard( + "mediaBarItemCount", + "Number of Items", + "How many items to display", + [ + { value: "5", label: "5" }, + { value: "10", label: "10" }, + { value: "15", label: "15" }, + { value: "20", label: "20" }, + ], + settings.mediaBarItemCount, + ) + + this.createToggleCard( + "mediaBarTrailerPreview", + "Trailer Preview", + "Automatically play muted trailer previews in the media bar background", + settings.mediaBarTrailerPreview, + ); + + var colorOptions = []; + var colorKeys = Object.keys(Storage.colorOptions); + for (var i = 0; i < colorKeys.length; i++) { + colorOptions.push({ + value: colorKeys[i], + label: Storage.colorOptions[colorKeys[i]].name, + }); + } - requestAnimationFrame(function() { - toast.classList.add('visible'); - }); + var overlayContent = + this.createSelectCard( + "mediaBarOverlayColor", + "Overlay Color", + "Color of the gradient overlay on media bar items", + colorOptions, + settings.mediaBarOverlayColor, + ) + + '
' + + this.createRangeCard( + "mediaBarOverlayOpacity", + "Overlay Opacity", + "Transparency of the gradient overlay", + 0, + 100, + 5, + settings.mediaBarOverlayOpacity, + "%", + ); + + var toolbarContent = + this.createToggleCard( + "showShuffleButton", + "Shuffle Button", + "Show random content button in the toolbar", + settings.showShuffleButton, + ) + + this.createSelectCard( + "shuffleContentType", + "Shuffle Content Type", + "What type of content to shuffle", + [ + { value: "both", label: "Movies & TV Shows" }, + { value: "movies", label: "Movies Only" }, + { value: "tv", label: "TV Shows Only" }, + ], + settings.shuffleContentType, + ) + + this.createToggleCard( + "showGenresButton", + "Genres Button", + "Show genres dropdown in the toolbar", + settings.showGenresButton, + ) + + this.createToggleCard( + "showFavoritesButton", + "Favorites Button", + "Show favorites button in the toolbar", + settings.showFavoritesButton, + ) + + this.createToggleCard( + "showCastButton", + "Cast Button", + "Show Chromecast button in the toolbar", + settings.showCastButton, + ) + + this.createToggleCard( + "showSyncPlayButton", + "SyncPlay Button", + "Show SyncPlay button in the toolbar", + settings.showSyncPlayButton, + ) + + this.createToggleCard( + "showLibrariesInToolbar", + "Library Shortcuts", + "Show library quick links in the toolbar", + settings.showLibrariesInToolbar, + ); + + var seasonalOptions = []; + var seasonKeys = Object.keys(Storage.seasonalOptions); + for (var j = 0; j < seasonKeys.length; j++) { + seasonalOptions.push({ + value: seasonKeys[j], + label: Storage.seasonalOptions[seasonKeys[j]].name, + }); + } - if (this._toastTimeout) clearTimeout(this._toastTimeout); - this._toastTimeout = setTimeout(function() { - toast.classList.remove('visible'); - setTimeout(function() { toast.remove(); }, 300); - }, 2000); - }, - - saveSetting: function(name, value) { - var profileName = Storage.getActiveEditProfile(); - var profile = Storage.getProfile(profileName); - profile[name] = value; - Storage.saveProfile(profileName, profile); - var safeValue = name.toLowerCase().indexOf('apikey') !== -1 || name.toLowerCase().indexOf('token') !== -1 ? '***' : value; - console.log('[Moonfin] Setting saved to profile "' + profileName + '":', name, '=', safeValue); - }, - - createToggleCard: function(id, title, description, checked) { - return '
' + - '' + - '
'; - }, - - createSelectCard: function(id, title, description, options, currentValue) { - var optionsHtml = ''; - for (var i = 0; i < options.length; i++) { - var opt = options[i]; - optionsHtml += ''; + var displayContent = + this.createToggleCard( + "showClock", + "Clock", + "Show a clock in the navigation bar", + settings.showClock, + ) + + this.createToggleCard( + "use24HourClock", + "24-Hour Format", + "Use 24-hour time format instead of 12-hour", + settings.use24HourClock, + ) + + this.createSelectCard( + "seasonalSurprise", + "Seasonal Effect", + "Add a seasonal visual effect to the interface", + seasonalOptions, + settings.seasonalSurprise, + ); + + var jellyseerrContent = + '
' + + '
' + + '' + + 'Checking...' + + "
" + + "
" + + '" + + '"; + + var mdblistSources = [ + { key: "imdb", label: "IMDb" }, + { key: "tmdb", label: "TMDb" }, + { key: "trakt", label: "Trakt" }, + { key: "tomatoes", label: "Rotten Tomatoes (Critics)" }, + { key: "popcorn", label: "Rotten Tomatoes (Audience)" }, + { key: "metacritic", label: "Metacritic" }, + { key: "metacriticuser", label: "Metacritic User" }, + { key: "letterboxd", label: "Letterboxd" }, + { key: "rogerebert", label: "Roger Ebert" }, + { key: "myanimelist", label: "MyAnimeList" }, + { key: "anilist", label: "AniList" }, + ]; + var selectedSources = settings.mdblistRatingSources || [ + "imdb", + "tmdb", + "tomatoes", + "metacritic", + ]; + var serverUrl = + (window.ApiClient && window.ApiClient.serverAddress + ? window.ApiClient.serverAddress() + : "") || ""; + var sourceIconFiles = { + imdb: "imdb.svg", + tmdb: "tmdb.svg", + trakt: "trakt.svg", + tomatoes: "rt-fresh.svg", + popcorn: "rt-audience-up.svg", + metacritic: "metacritic.svg", + metacriticuser: "metacritic-user.svg", + letterboxd: "letterboxd.svg", + rogerebert: "rogerebert.svg", + myanimelist: "mal.svg", + anilist: "anilist.svg", + }; + + // Build ordered list: enabled sources first (in saved order), then disabled + var orderedSources = []; + for (var oi = 0; oi < selectedSources.length; oi++) { + for (var oj = 0; oj < mdblistSources.length; oj++) { + if (mdblistSources[oj].key === selectedSources[oi]) { + orderedSources.push({ + key: mdblistSources[oj].key, + label: mdblistSources[oj].label, + enabled: true, + }); + break; } + } + } + for (var uk = 0; uk < mdblistSources.length; uk++) { + if (selectedSources.indexOf(mdblistSources[uk].key) === -1) { + orderedSources.push({ + key: mdblistSources[uk].key, + label: mdblistSources[uk].label, + enabled: false, + }); + } + } - return '
' + - '
' + - '
' + title + '
' + - (description ? '
' + description + '
' : '') + - '
' + - '' + - '
'; - }, - - createRangeCard: function(id, title, description, min, max, step, currentValue, suffix) { - return '
' + - '
' + - '
' + title + ' ' + currentValue + (suffix || '') + '
' + - (description ? '
' + description + '
' : '') + - '
' + - '' + - '
'; - }, - - createSection: function(icon, title, contentHtml, openByDefault) { - return '
' + - '' + (icon ? icon + ' ' : '') + title + '' + - '
' + - contentHtml + - '
' + - '
'; - }, - - createDialog: function() { - var existing = document.querySelector('.moonfin-settings-dialog'); - if (existing) existing.remove(); + var sourceItems = ""; + for (var si = 0; si < orderedSources.length; si++) { + var src = orderedSources[si]; + var iconUrl = + serverUrl + + "/Moonfin/Assets/" + + (sourceIconFiles[src.key] || "imdb.svg"); + sourceItems += + '
' + + '' + + '' + + "" + + '' +
+        src.label +
+        '' + + '' + + src.label + + "" + + '" + + "
"; + } - var settings = Storage.getAll(); - var self = this; - - this.dialog = document.createElement('div'); - this.dialog.className = 'moonfin-settings-dialog'; - - var uiContent = - this.createToggleCard('navbarEnabled', 'Navigation Bar', 'Show the custom navigation bar with quick access buttons', settings.navbarEnabled) + - this.createSelectCard('navbarPosition', 'Navbar Position', 'Show the navigation bar at the top or as a left sidebar', [ - { value: 'top', label: 'Top' }, - { value: 'left', label: 'Left (Sidebar)' } - ], settings.navbarPosition) + - this.createToggleCard('mediaBarEnabled', 'Media Bar', 'Show the featured media carousel on the home page', settings.mediaBarEnabled) + - this.createToggleCard('detailsPageEnabled', 'Details Page', 'Use the custom Moonfin details page instead of the default Jellyfin one', settings.detailsPageEnabled); - - var mediaBarContent = - this.createSelectCard('mediaBarContentType', 'Content Type', 'What type of content to show in the media bar', [ - { value: 'both', label: 'Movies & TV Shows' }, - { value: 'movies', label: 'Movies Only' }, - { value: 'tv', label: 'TV Shows Only' } - ], settings.mediaBarContentType) + - - this.createSelectCard('mediaBarItemCount', 'Number of Items', 'How many items to display', [ - { value: '5', label: '5' }, - { value: '10', label: '10' }, - { value: '15', label: '15' }, - { value: '20', label: '20' } - ], settings.mediaBarItemCount) + - - this.createToggleCard('mediaBarTrailerPreview', 'Trailer Preview', 'Automatically play muted trailer previews in the media bar background', settings.mediaBarTrailerPreview); - - var colorOptions = []; - var colorKeys = Object.keys(Storage.colorOptions); - for (var i = 0; i < colorKeys.length; i++) { - colorOptions.push({ value: colorKeys[i], label: Storage.colorOptions[colorKeys[i]].name }); + var tmdbContent = + this.createToggleCard( + "tmdbEpisodeRatingsEnabled", + "Enable Episode Ratings", + "Show TMDB ratings for individual TV episodes on the details page", + settings.tmdbEpisodeRatingsEnabled, + ) + + '
' + + (Storage.syncState.tmdbAvailable + ? '
' + + "Your server admin has provided a server-wide TMDB API key. You can leave the field below blank to use it, or enter your own key." + + "
" + : "") + + '
' + + '' + + '' + + '
Get a free API key at themoviedb.org/settings/api
' + + "
" + + "
"; + + var mdblistContent = + this.createToggleCard( + "mdblistEnabled", + "Enable MDBList Ratings", + "Show ratings from MDBList (IMDb, Rotten Tomatoes, Metacritic, etc.) on media bar and item details", + settings.mdblistEnabled, + ) + + '
' + + (Storage.syncState.mdblistAvailable + ? '
' + + "Your server admin has provided a server-wide MDBList API key. You can leave the field below blank to use it, or enter your own key." + + "
" + : "") + + '
' + + '' + + '' + + '
Get your free API key at mdblist.com/preferences
' + + "
" + + '
' + + '' + + '

Drag to reorder. Click the icon on the right to enable or disable a source.

' + + '
' + + sourceItems + + "
" + + "
" + + "
"; + + var currentDeviceProfile = Device.getProfileName(); + var profileLabels = { + global: "All Devices", + desktop: "Desktop", + mobile: "Mobile", + tv: "TV", + }; + var profileTabsHtml = '
'; + var profileNames = ["global", "desktop", "mobile", "tv"]; + for (var pi = 0; pi < profileNames.length; pi++) { + var pn = profileNames[pi]; + var isActive = pn === "global"; + var isCurrent = pn === currentDeviceProfile; + profileTabsHtml += + '"; + } + profileTabsHtml += "
"; + var profileInfoHtml = + '
' + + ' ' + + '"All Devices" settings apply everywhere. Device profiles override only the settings you change.' + + "
"; + + this.dialog.innerHTML = + '
' + + '
' + + '
' + + '
' + + "

Moonfin

" + + 'Settings' + + "
" + + '" + + "
" + + profileTabsHtml + + profileInfoHtml + + '
' + + this.createSection("", "Moonfin UI", uiContent, true) + + this.createSection("", "Media Bar", mediaBarContent) + + this.createSection("", "Overlay Appearance", overlayContent) + + this.createSection("", "Toolbar Buttons", toolbarContent) + + this.createSection("", "Display", displayContent) + + this.createSection("", "TMDB Episode Ratings", tmdbContent) + + this.createSection("", "MDBList Ratings", mdblistContent) + + '" + + "
" + + '" + + "
"; + + document.body.appendChild(this.dialog); + Storage.setActiveEditProfile("global"); + this.setupEventListeners(); + this.updateSyncStatus(); + this.updateJellyseerrSsoSection(); + }, + + refreshFormValues: function (profileName) { + if (!this.dialog) return; + var resolved = Storage.resolveSettings(profileName); + var raw = profileName !== "global" ? Storage.getProfile(profileName) : null; + + // Update checkboxes + var checkboxes = this.dialog.querySelectorAll( + 'input[type="checkbox"][name]', + ); + for (var i = 0; i < checkboxes.length; i++) { + var name = checkboxes[i].name; + if (name in resolved) { + checkboxes[i].checked = resolved[name]; + // Visual indicator: dim if inherited from global/defaults on a device profile + var isInherited = + raw !== null && (raw[name] === undefined || raw[name] === null); + var card = checkboxes[i].closest(".moonfin-toggle-card"); + if (card) { + card.classList.toggle("moonfin-inherited", isInherited); } + } + } - var overlayContent = - this.createSelectCard('mediaBarOverlayColor', 'Overlay Color', 'Color of the gradient overlay on media bar items', colorOptions, settings.mediaBarOverlayColor) + - '
' + - this.createRangeCard('mediaBarOverlayOpacity', 'Overlay Opacity', 'Transparency of the gradient overlay', 0, 100, 5, settings.mediaBarOverlayOpacity, '%'); - - var toolbarContent = - this.createToggleCard('showShuffleButton', 'Shuffle Button', 'Show random content button in the toolbar', settings.showShuffleButton) + - this.createSelectCard('shuffleContentType', 'Shuffle Content Type', 'What type of content to shuffle', [ - { value: 'both', label: 'Movies & TV Shows' }, - { value: 'movies', label: 'Movies Only' }, - { value: 'tv', label: 'TV Shows Only' } - ], settings.shuffleContentType) + - this.createToggleCard('showGenresButton', 'Genres Button', 'Show genres dropdown in the toolbar', settings.showGenresButton) + - this.createToggleCard('showFavoritesButton', 'Favorites Button', 'Show favorites button in the toolbar', settings.showFavoritesButton) + - this.createToggleCard('showCastButton', 'Cast Button', 'Show Chromecast button in the toolbar', settings.showCastButton) + - this.createToggleCard('showSyncPlayButton', 'SyncPlay Button', 'Show SyncPlay button in the toolbar', settings.showSyncPlayButton) + - this.createToggleCard('showLibrariesInToolbar', 'Library Shortcuts', 'Show library quick links in the toolbar', settings.showLibrariesInToolbar); - - var seasonalOptions = []; - var seasonKeys = Object.keys(Storage.seasonalOptions); - for (var j = 0; j < seasonKeys.length; j++) { - seasonalOptions.push({ value: seasonKeys[j], label: Storage.seasonalOptions[seasonKeys[j]].name }); + // Update selects + var selects = this.dialog.querySelectorAll("select[name]"); + for (var j = 0; j < selects.length; j++) { + var sName = selects[j].name; + if (sName in resolved) { + selects[j].value = String(resolved[sName]); + var sCard = selects[j].closest(".moonfin-select-card"); + if (sCard && raw !== null) { + sCard.classList.toggle( + "moonfin-inherited", + raw[sName] === undefined || raw[sName] === null, + ); } + } + } - var displayContent = - this.createToggleCard('showClock', 'Clock', 'Show a clock in the navigation bar', settings.showClock) + - this.createToggleCard('use24HourClock', '24-Hour Format', 'Use 24-hour time format instead of 12-hour', settings.use24HourClock) + - this.createSelectCard('seasonalSurprise', 'Seasonal Effect', 'Add a seasonal visual effect to the interface', seasonalOptions, settings.seasonalSurprise); - - var jellyseerrContent = - '
' + - '
' + - '' + - 'Checking...' + - '
' + - '
' + - '' + - ''; - - var mdblistSources = [ - { key: 'imdb', label: 'IMDb' }, - { key: 'tmdb', label: 'TMDb' }, - { key: 'trakt', label: 'Trakt' }, - { key: 'tomatoes', label: 'Rotten Tomatoes (Critics)' }, - { key: 'popcorn', label: 'Rotten Tomatoes (Audience)' }, - { key: 'metacritic', label: 'Metacritic' }, - { key: 'metacriticuser', label: 'Metacritic User' }, - { key: 'letterboxd', label: 'Letterboxd' }, - { key: 'rogerebert', label: 'Roger Ebert' }, - { key: 'myanimelist', label: 'MyAnimeList' }, - { key: 'anilist', label: 'AniList' } - ]; - var selectedSources = settings.mdblistRatingSources || ['imdb', 'tmdb', 'tomatoes', 'metacritic']; - var serverUrl = (window.ApiClient && window.ApiClient.serverAddress ? window.ApiClient.serverAddress() : '') || ''; - var sourceIconFiles = { - imdb: 'imdb.svg', tmdb: 'tmdb.svg', trakt: 'trakt.svg', - tomatoes: 'rt-fresh.svg', popcorn: 'rt-audience-up.svg', - metacritic: 'metacritic.svg', metacriticuser: 'metacritic-user.svg', - letterboxd: 'letterboxd.svg', rogerebert: 'rogerebert.svg', - myanimelist: 'mal.svg', anilist: 'anilist.svg' - }; - - // Build ordered list: enabled sources first (in saved order), then disabled - var orderedSources = []; - for (var oi = 0; oi < selectedSources.length; oi++) { - for (var oj = 0; oj < mdblistSources.length; oj++) { - if (mdblistSources[oj].key === selectedSources[oi]) { - orderedSources.push({ key: mdblistSources[oj].key, label: mdblistSources[oj].label, enabled: true }); - break; - } - } - } - for (var uk = 0; uk < mdblistSources.length; uk++) { - if (selectedSources.indexOf(mdblistSources[uk].key) === -1) { - orderedSources.push({ key: mdblistSources[uk].key, label: mdblistSources[uk].label, enabled: false }); - } - } + // Update ranges + var ranges = this.dialog.querySelectorAll('input[type="range"][name]'); + for (var k = 0; k < ranges.length; k++) { + var rName = ranges[k].name; + if (rName in resolved) { + ranges[k].value = resolved[rName]; + var valueSpan = this.dialog.querySelector( + '.moonfin-range-value[data-for="' + rName + '"]', + ); + if (valueSpan) valueSpan.textContent = resolved[rName] + "%"; + } + } - var sourceItems = ''; - for (var si = 0; si < orderedSources.length; si++) { - var src = orderedSources[si]; - var iconUrl = serverUrl + '/Moonfin/Assets/' + (sourceIconFiles[src.key] || 'imdb.svg'); - sourceItems += '
' + - '' + - '' + - '' + - '' + src.label + '' + - '' + src.label + '' + - '' + - '
'; - } + // Update text/password inputs + var textInputs = [ + { id: "moonfin-mdblistApiKey", key: "mdblistApiKey" }, + { id: "moonfin-tmdbApiKey", key: "tmdbApiKey" }, + ]; + for (var ti = 0; ti < textInputs.length; ti++) { + var inp = this.dialog.querySelector("#" + textInputs[ti].id); + if (inp) inp.value = resolved[textInputs[ti].key] || ""; + } - var tmdbContent = - this.createToggleCard('tmdbEpisodeRatingsEnabled', 'Enable Episode Ratings', 'Show TMDB ratings for individual TV episodes on the details page', settings.tmdbEpisodeRatingsEnabled) + - '
' + - (Storage.syncState.tmdbAvailable ? - '
' + - 'Your server admin has provided a server-wide TMDB API key. You can leave the field below blank to use it, or enter your own key.' + - '
' : '') + - '
' + - '' + - '' + - '
Get a free API key at themoviedb.org/settings/api
' + - '
' + - '
'; - - var mdblistContent = - this.createToggleCard('mdblistEnabled', 'Enable MDBList Ratings', 'Show ratings from MDBList (IMDb, Rotten Tomatoes, Metacritic, etc.) on media bar and item details', settings.mdblistEnabled) + - '
' + - (Storage.syncState.mdblistAvailable ? - '
' + - 'Your server admin has provided a server-wide MDBList API key. You can leave the field below blank to use it, or enter your own key.' + - '
' : '') + - '
' + - '' + - '' + - '
Get your free API key at mdblist.com/preferences
' + - '
' + - '
' + - '' + - '

Drag to reorder. Click the icon on the right to enable or disable a source.

' + - '
' + sourceItems + '
' + - '
' + - '
'; - - var currentDeviceProfile = Device.getProfileName(); - var profileLabels = { global: 'All Devices', desktop: 'Desktop', mobile: 'Mobile', tv: 'TV' }; - var profileTabsHtml = '
'; - var profileNames = ['global', 'desktop', 'mobile', 'tv']; - for (var pi = 0; pi < profileNames.length; pi++) { - var pn = profileNames[pi]; - var isActive = pn === 'global'; - var isCurrent = pn === currentDeviceProfile; - profileTabsHtml += ''; - } - profileTabsHtml += '
'; - var profileInfoHtml = '
' + - ' ' + - '"All Devices" settings apply everywhere. Device profiles override only the settings you change.' + - '
'; - - this.dialog.innerHTML = - '
' + - '
' + - '
' + - '
' + - '

Moonfin

' + - 'Settings' + - '
' + - '' + - '
' + - profileTabsHtml + - profileInfoHtml + - '
' + - this.createSection('', 'Moonfin UI', uiContent, true) + - this.createSection('', 'Media Bar', mediaBarContent) + - this.createSection('', 'Overlay Appearance', overlayContent) + - this.createSection('', 'Toolbar Buttons', toolbarContent) + - this.createSection('', 'Display', displayContent) + - this.createSection('', 'TMDB Episode Ratings', tmdbContent) + - this.createSection('', 'MDBList Ratings', mdblistContent) + - '' + - '
' + - '' + - '
'; - - document.body.appendChild(this.dialog); - Storage.setActiveEditProfile('global'); - this.setupEventListeners(); - this.updateSyncStatus(); - this.updateJellyseerrSsoSection(); - }, - - refreshFormValues: function(profileName) { - if (!this.dialog) return; - var resolved = Storage.resolveSettings(profileName); - var raw = (profileName !== 'global') ? Storage.getProfile(profileName) : null; - - // Update checkboxes - var checkboxes = this.dialog.querySelectorAll('input[type="checkbox"][name]'); - for (var i = 0; i < checkboxes.length; i++) { - var name = checkboxes[i].name; - if (name in resolved) { - checkboxes[i].checked = resolved[name]; - // Visual indicator: dim if inherited from global/defaults on a device profile - var isInherited = raw !== null && (raw[name] === undefined || raw[name] === null); - var card = checkboxes[i].closest('.moonfin-toggle-card'); - if (card) { - card.classList.toggle('moonfin-inherited', isInherited); - } - } + // Update color preview + var colorPreview = this.dialog.querySelector("#moonfin-color-preview"); + if (colorPreview) { + colorPreview.style.background = Storage.getColorHex( + resolved.mediaBarOverlayColor, + ); + } + + // Toggle config sub-sections + var mdblistConfig = this.dialog.querySelector(".moonfin-mdblist-config"); + if (mdblistConfig) + mdblistConfig.style.display = resolved.mdblistEnabled ? "" : "none"; + var tmdbConfig = this.dialog.querySelector(".moonfin-tmdb-config"); + if (tmdbConfig) + tmdbConfig.style.display = resolved.tmdbEpisodeRatingsEnabled + ? "" + : "none"; + + // Toggle media bar source sub-sections + var isCollection = resolved.mediaBarSourceType === "collection"; + var libraryOpts = this.dialog.querySelector( + ".moonfin-mediabar-library-options", + ); + var collectionOpts = this.dialog.querySelector( + ".moonfin-mediabar-collection-options", + ); + if (libraryOpts) libraryOpts.style.display = isCollection ? "none" : ""; + if (collectionOpts) + collectionOpts.style.display = isCollection ? "" : "none"; + if (isCollection) this.loadCollectionPicker(); + + // Update profile info text + var infoText = this.dialog.querySelector(".moonfin-profile-info-text"); + if (infoText) { + if (profileName === "global") { + infoText.textContent = + '"All Devices" settings apply everywhere. Device profiles override only the settings you change.'; + } else { + var label = profileName.charAt(0).toUpperCase() + profileName.slice(1); + infoText.textContent = + "Editing " + + label + + ' overrides. Dimmed settings are inherited from "All Devices". Changes here only affect ' + + label + + " devices."; + } + } + }, + + loadCollectionPicker: function () { + var self = this; + var picker = this.dialog + ? this.dialog.querySelector("#moonfin-collection-picker") + : null; + if (!picker) return; + + picker.innerHTML = + '
Loading...
'; + + API.getCollectionsAndPlaylists() + .then(function (items) { + if (!self.dialog) return; + if (!items || items.length === 0) { + picker.innerHTML = + '
No collections or playlists found on this server.
'; + return; } - // Update selects - var selects = this.dialog.querySelectorAll('select[name]'); - for (var j = 0; j < selects.length; j++) { - var sName = selects[j].name; - if (sName in resolved) { - selects[j].value = String(resolved[sName]); - var sCard = selects[j].closest('.moonfin-select-card'); - if (sCard && raw !== null) { - sCard.classList.toggle('moonfin-inherited', raw[sName] === undefined || raw[sName] === null); - } - } + var settings = Storage.getAll(); + var selectedIds = settings.mediaBarCollectionIds || []; + var serverUrl = + (window.ApiClient && window.ApiClient.serverAddress + ? window.ApiClient.serverAddress() + : "") || ""; + var html = ""; + + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var isChecked = selectedIds.indexOf(item.Id) !== -1; + var typeBadge = item.Type === "BoxSet" ? "Collection" : "Playlist"; + var posterUrl = ""; + if (item.ImageTags && item.ImageTags.Primary) { + posterUrl = + serverUrl + + "/Items/" + + item.Id + + "/Images/Primary?maxWidth=60&quality=80&tag=" + + item.ImageTags.Primary; + } + + html += + '"; } - // Update ranges - var ranges = this.dialog.querySelectorAll('input[type="range"][name]'); - for (var k = 0; k < ranges.length; k++) { - var rName = ranges[k].name; - if (rName in resolved) { - ranges[k].value = resolved[rName]; - var valueSpan = this.dialog.querySelector('.moonfin-range-value[data-for="' + rName + '"]'); - if (valueSpan) valueSpan.textContent = resolved[rName] + '%'; + picker.innerHTML = html; + + // Event delegation for checkboxes + picker.addEventListener("change", function (e) { + var cb = e.target; + if (!cb.dataset.collectionId) return; + + var currentIds = Storage.get("mediaBarCollectionIds") || []; + // Ensure we're working with a copy + currentIds = currentIds.slice(); + var id = cb.dataset.collectionId; + var idx = currentIds.indexOf(id); + + if (cb.checked && idx === -1) { + currentIds.push(id); + } else if (!cb.checked && idx !== -1) { + currentIds.splice(idx, 1); + } + + self.saveSetting("mediaBarCollectionIds", currentIds); + + // Update visual state + var label = cb.closest(".moonfin-collection-item"); + if (label) + label.classList.toggle( + "moonfin-collection-item-active", + cb.checked, + ); + + self.showToast( + cb.checked ? "Added to media bar" : "Removed from media bar", + ); + }); + }) + .catch(function (e) { + console.error("[Moonfin] Failed to load collection picker:", e); + picker.innerHTML = + '
Failed to load collections.
'; + }); + }, + + updateJellyseerrSsoSection: function () { + var self = this; + var wrapper = this.dialog + ? this.dialog.querySelector(".moonfin-settings-jellyseerr-wrapper") + : null; + if (!wrapper) return Promise.resolve(); + + // Always fetch fresh config to catch admin changes + return Jellyseerr.fetchConfig().then(function () { + if ( + !Jellyseerr.config || + !Jellyseerr.config.enabled || + !Jellyseerr.config.url + ) { + console.log( + "[Moonfin] Jellyseerr not configured, hiding section. Config:", + Jellyseerr.config, + ); + wrapper.style.display = "none"; + return; + } + + wrapper.style.display = ""; + + var indicator = wrapper.querySelector( + ".moonfin-jellyseerr-sso-indicator", + ); + var text = wrapper.querySelector(".moonfin-jellyseerr-sso-text"); + var loginGroup = wrapper.querySelector(".moonfin-jellyseerr-login-group"); + var signedInGroup = wrapper.querySelector( + ".moonfin-jellyseerr-signedIn-group", + ); + + return Jellyseerr.checkSsoStatus().then(function () { + if (Jellyseerr.ssoStatus && Jellyseerr.ssoStatus.authenticated) { + indicator.className = "moonfin-jellyseerr-sso-indicator connected"; + var displayName = Jellyseerr.ssoStatus.displayName || "Unknown"; + text.textContent = "Signed in as " + displayName; + loginGroup.style.display = "none"; + signedInGroup.style.display = ""; + } else { + indicator.className = "moonfin-jellyseerr-sso-indicator disconnected"; + text.textContent = "Not signed in"; + loginGroup.style.display = ""; + signedInGroup.style.display = "none"; + + var api = API.getApiClient(); + if (api && api._currentUser) { + var usernameInput = wrapper.querySelector( + "#jellyseerr-settings-username", + ); + if (usernameInput && !usernameInput.value) { + usernameInput.value = api._currentUser.Name || ""; } + } } + }); + }); + }, + + updateSyncStatus: function () { + var self = this; + var statusEl = this.dialog + ? this.dialog.querySelector("#moonfinSyncStatus") + : null; + if (!statusEl) return Promise.resolve(); + + var indicator = statusEl.querySelector(".moonfin-sync-indicator"); + var text = statusEl.querySelector(".moonfin-sync-text"); + + var syncStatus = Storage.getSyncStatus(); + + if (syncStatus.syncing) { + indicator.className = "moonfin-sync-indicator syncing"; + text.textContent = "Syncing..."; + return Promise.resolve(); + } - // Update text/password inputs - var textInputs = [ - { id: 'moonfin-mdblistApiKey', key: 'mdblistApiKey' }, - { id: 'moonfin-tmdbApiKey', key: 'tmdbApiKey' } - ]; - for (var ti = 0; ti < textInputs.length; ti++) { - var inp = this.dialog.querySelector('#' + textInputs[ti].id); - if (inp) inp.value = resolved[textInputs[ti].key] || ''; + // Always re-ping when the panel opens to get fresh status + indicator.className = "moonfin-sync-indicator checking"; + text.textContent = "Checking server..."; + return Storage.pingServer().then(function () { + var freshStatus = Storage.getSyncStatus(); + if (freshStatus.available) { + indicator.className = "moonfin-sync-indicator connected"; + if (freshStatus.lastSync) { + var ago = Math.round((Date.now() - freshStatus.lastSync) / 1000); + text.textContent = + "Synced " + + (ago < 60 ? ago + "s" : Math.round(ago / 60) + "m") + + " ago"; + } else { + text.textContent = "Server sync available"; } - - // Update color preview - var colorPreview = this.dialog.querySelector('#moonfin-color-preview'); - if (colorPreview) { - colorPreview.style.background = Storage.getColorHex(resolved.mediaBarOverlayColor); + } else { + indicator.className = "moonfin-sync-indicator disconnected"; + text.textContent = freshStatus.error || "Server sync unavailable"; + } + }); + }, + + setupEventListeners: function () { + var self = this; + + this.dialog + .querySelector(".moonfin-settings-close") + .addEventListener("click", function () { + self.hide(); + }); + + this.dialog + .querySelector(".moonfin-settings-close-btn") + .addEventListener("click", function () { + self.hide(); + }); + + this.dialog + .querySelector(".moonfin-settings-overlay") + .addEventListener("click", function () { + self.hide(); + }); + + this.dialog + .querySelector(".moonfin-settings-reset") + .addEventListener("click", function () { + var activeProfile = Storage.getActiveEditProfile(); + if (activeProfile !== "global") { + if ( + confirm( + 'Reset "' + + activeProfile + + '" device profile? This will remove all overrides for this device.', + ) + ) { + Storage.deleteProfile(activeProfile); + self.showToast("Device profile reset"); + self.hide(); + setTimeout(function () { + self.show(); + }, 350); + } + } else { + if (confirm("Reset all Moonfin settings to defaults?")) { + Storage.reset(); + self.showToast("Settings reset to defaults"); + self.hide(); + setTimeout(function () { + self.show(); + }, 350); + } } - - // Toggle config sub-sections - var mdblistConfig = this.dialog.querySelector('.moonfin-mdblist-config'); - if (mdblistConfig) mdblistConfig.style.display = resolved.mdblistEnabled ? '' : 'none'; - var tmdbConfig = this.dialog.querySelector('.moonfin-tmdb-config'); - if (tmdbConfig) tmdbConfig.style.display = resolved.tmdbEpisodeRatingsEnabled ? '' : 'none'; - - // Update profile info text - var infoText = this.dialog.querySelector('.moonfin-profile-info-text'); - if (infoText) { - if (profileName === 'global') { - infoText.textContent = '"All Devices" settings apply everywhere. Device profiles override only the settings you change.'; - } else { - var label = profileName.charAt(0).toUpperCase() + profileName.slice(1); - infoText.textContent = 'Editing ' + label + ' overrides. Dimmed settings are inherited from "All Devices". Changes here only affect ' + label + ' devices.'; - } + }); + + this.dialog + .querySelector(".moonfin-settings-sync") + .addEventListener("click", function () { + var syncBtn = self.dialog.querySelector(".moonfin-settings-sync"); + syncBtn.disabled = true; + syncBtn.textContent = "Syncing..."; + + Storage.sync(true) + .then(function () { + return self.updateSyncStatus(); + }) + .then(function () { + syncBtn.disabled = false; + syncBtn.textContent = "Sync"; + self.showToast("Settings synced from server"); + self.hide(); + setTimeout(function () { + self.show(); + }, 350); + }); + }); + + // Profile tab switching + var profileTabs = this.dialog.querySelectorAll(".moonfin-profile-tab"); + for (var pti = 0; pti < profileTabs.length; pti++) { + profileTabs[pti].addEventListener("click", function () { + var profileName = this.getAttribute("data-profile"); + for (var pt = 0; pt < profileTabs.length; pt++) { + profileTabs[pt].classList.remove("moonfin-profile-tab-active"); } - }, - - updateJellyseerrSsoSection: function() { - var self = this; - var wrapper = this.dialog ? this.dialog.querySelector('.moonfin-settings-jellyseerr-wrapper') : null; - if (!wrapper) return Promise.resolve(); - - // Always fetch fresh config to catch admin changes - return Jellyseerr.fetchConfig().then(function() { - if (!Jellyseerr.config || !Jellyseerr.config.enabled || !Jellyseerr.config.url) { - console.log('[Moonfin] Jellyseerr not configured, hiding section. Config:', Jellyseerr.config); - wrapper.style.display = 'none'; - return; - } - - wrapper.style.display = ''; - - var indicator = wrapper.querySelector('.moonfin-jellyseerr-sso-indicator'); - var text = wrapper.querySelector('.moonfin-jellyseerr-sso-text'); - var loginGroup = wrapper.querySelector('.moonfin-jellyseerr-login-group'); - var signedInGroup = wrapper.querySelector('.moonfin-jellyseerr-signedIn-group'); - - return Jellyseerr.checkSsoStatus().then(function() { - if (Jellyseerr.ssoStatus && Jellyseerr.ssoStatus.authenticated) { - indicator.className = 'moonfin-jellyseerr-sso-indicator connected'; - var displayName = Jellyseerr.ssoStatus.displayName || 'Unknown'; - text.textContent = 'Signed in as ' + displayName; - loginGroup.style.display = 'none'; - signedInGroup.style.display = ''; - } else { - indicator.className = 'moonfin-jellyseerr-sso-indicator disconnected'; - text.textContent = 'Not signed in'; - loginGroup.style.display = ''; - signedInGroup.style.display = 'none'; - - var api = API.getApiClient(); - if (api && api._currentUser) { - var usernameInput = wrapper.querySelector('#jellyseerr-settings-username'); - if (usernameInput && !usernameInput.value) { - usernameInput.value = api._currentUser.Name || ''; - } - } - } - }); - }); - }, - - updateSyncStatus: function() { - var self = this; - var statusEl = this.dialog ? this.dialog.querySelector('#moonfinSyncStatus') : null; - if (!statusEl) return Promise.resolve(); - - var indicator = statusEl.querySelector('.moonfin-sync-indicator'); - var text = statusEl.querySelector('.moonfin-sync-text'); - - var syncStatus = Storage.getSyncStatus(); + this.classList.add("moonfin-profile-tab-active"); + Storage.setActiveEditProfile(profileName); + self.refreshFormValues(profileName); + self.showToast( + "Editing: " + + (profileName === "global" + ? "All Devices" + : profileName.charAt(0).toUpperCase() + profileName.slice(1)), + ); + }); + } - if (syncStatus.syncing) { - indicator.className = 'moonfin-sync-indicator syncing'; - text.textContent = 'Syncing...'; - return Promise.resolve(); - } + // Sync toggle + var syncToggle = this.dialog.querySelector("#moonfin-sync-toggle"); + if (syncToggle) { + syncToggle.addEventListener("change", function () { + Storage.setSyncEnabled(syncToggle.checked); + self.showToast(syncToggle.checked ? "Sync enabled" : "Sync disabled"); + self.updateSyncStatus(); + }); + } - // Always re-ping when the panel opens to get fresh status - indicator.className = 'moonfin-sync-indicator checking'; - text.textContent = 'Checking server...'; - return Storage.pingServer().then(function() { - var freshStatus = Storage.getSyncStatus(); - if (freshStatus.available) { - indicator.className = 'moonfin-sync-indicator connected'; - if (freshStatus.lastSync) { - var ago = Math.round((Date.now() - freshStatus.lastSync) / 1000); - text.textContent = 'Synced ' + (ago < 60 ? ago + 's' : Math.round(ago / 60) + 'm') + ' ago'; - } else { - text.textContent = 'Server sync available'; - } - } else { - indicator.className = 'moonfin-sync-indicator disconnected'; - text.textContent = freshStatus.error || 'Server sync unavailable'; + var checkboxes = this.dialog.querySelectorAll( + 'input[type="checkbox"][name]', + ); + for (var i = 0; i < checkboxes.length; i++) { + (function (cb) { + cb.addEventListener("change", function () { + self.saveSetting(cb.name, cb.checked); + self.showToast(cb.checked ? "Enabled" : "Disabled"); + + if (cb.name === "mdblistEnabled") { + var configDiv = self.dialog.querySelector( + ".moonfin-mdblist-config", + ); + if (configDiv) { + configDiv.style.display = cb.checked ? "" : "none"; } + } + + if (cb.name === "tmdbEpisodeRatingsEnabled") { + var tmdbConfigDiv = self.dialog.querySelector( + ".moonfin-tmdb-config", + ); + if (tmdbConfigDiv) { + tmdbConfigDiv.style.display = cb.checked ? "" : "none"; + } + } }); - }, - - setupEventListeners: function() { - var self = this; - - this.dialog.querySelector('.moonfin-settings-close').addEventListener('click', function() { - self.hide(); - }); - - this.dialog.querySelector('.moonfin-settings-close-btn').addEventListener('click', function() { - self.hide(); - }); + })(checkboxes[i]); + } - this.dialog.querySelector('.moonfin-settings-overlay').addEventListener('click', function() { - self.hide(); + var selects = this.dialog.querySelectorAll("select"); + for (var j = 0; j < selects.length; j++) { + (function (sel) { + sel.addEventListener("change", function () { + var val = sel.value; + var numVal = parseInt(val, 10); + self.saveSetting(sel.name, isNaN(numVal) ? val : numVal); + self.showToast("Setting updated"); }); + })(selects[j]); + } - this.dialog.querySelector('.moonfin-settings-reset').addEventListener('click', function() { - var activeProfile = Storage.getActiveEditProfile(); - if (activeProfile !== 'global') { - if (confirm('Reset "' + activeProfile + '" device profile? This will remove all overrides for this device.')) { - Storage.deleteProfile(activeProfile); - self.showToast('Device profile reset'); - self.hide(); - setTimeout(function() { self.show(); }, 350); - } - } else { - if (confirm('Reset all Moonfin settings to defaults?')) { - Storage.reset(); - self.showToast('Settings reset to defaults'); - self.hide(); - setTimeout(function() { self.show(); }, 350); - } - } + var ranges = this.dialog.querySelectorAll('input[type="range"]'); + for (var k = 0; k < ranges.length; k++) { + (function (range) { + range.addEventListener("input", function () { + var valueSpan = self.dialog.querySelector( + '.moonfin-range-value[data-for="' + range.name + '"]', + ); + if (valueSpan) { + valueSpan.textContent = range.value + "%"; + } }); - - this.dialog.querySelector('.moonfin-settings-sync').addEventListener('click', function() { - var syncBtn = self.dialog.querySelector('.moonfin-settings-sync'); - syncBtn.disabled = true; - syncBtn.textContent = 'Syncing...'; - - Storage.sync(true).then(function() { - return self.updateSyncStatus(); - }).then(function() { - syncBtn.disabled = false; - syncBtn.textContent = 'Sync'; - self.showToast('Settings synced from server'); - self.hide(); - setTimeout(function() { self.show(); }, 350); - }); + range.addEventListener("change", function () { + self.saveSetting(range.name, parseInt(range.value, 10)); + self.showToast("Setting updated"); }); + })(ranges[k]); + } - // Profile tab switching - var profileTabs = this.dialog.querySelectorAll('.moonfin-profile-tab'); - for (var pti = 0; pti < profileTabs.length; pti++) { - profileTabs[pti].addEventListener('click', function() { - var profileName = this.getAttribute('data-profile'); - for (var pt = 0; pt < profileTabs.length; pt++) { - profileTabs[pt].classList.remove('moonfin-profile-tab-active'); - } - this.classList.add('moonfin-profile-tab-active'); - Storage.setActiveEditProfile(profileName); - self.refreshFormValues(profileName); - self.showToast('Editing: ' + (profileName === 'global' ? 'All Devices' : profileName.charAt(0).toUpperCase() + profileName.slice(1))); - }); + var colorSelect = this.dialog.querySelector( + 'select[name="mediaBarOverlayColor"]', + ); + if (colorSelect) { + colorSelect.addEventListener("change", function () { + var preview = self.dialog.querySelector("#moonfin-color-preview"); + if (preview) { + preview.style.background = Storage.getColorHex(colorSelect.value); } + }); + } - // Sync toggle - var syncToggle = this.dialog.querySelector('#moonfin-sync-toggle'); - if (syncToggle) { - syncToggle.addEventListener('change', function() { - Storage.setSyncEnabled(syncToggle.checked); - self.showToast(syncToggle.checked ? 'Sync enabled' : 'Sync disabled'); - self.updateSyncStatus(); - }); + // Media bar source type toggle + var sourceTypeSelect = this.dialog.querySelector( + 'select[name="mediaBarSourceType"]', + ); + if (sourceTypeSelect) { + sourceTypeSelect.addEventListener("change", function () { + var isCollection = sourceTypeSelect.value === "collection"; + var libraryOpts = self.dialog.querySelector( + ".moonfin-mediabar-library-options", + ); + var collectionOpts = self.dialog.querySelector( + ".moonfin-mediabar-collection-options", + ); + if (libraryOpts) libraryOpts.style.display = isCollection ? "none" : ""; + if (collectionOpts) + collectionOpts.style.display = isCollection ? "" : "none"; + if (isCollection) { + self.loadCollectionPicker(); } + }); - var checkboxes = this.dialog.querySelectorAll('input[type="checkbox"][name]'); - for (var i = 0; i < checkboxes.length; i++) { - (function(cb) { - cb.addEventListener('change', function() { - self.saveSetting(cb.name, cb.checked); - self.showToast(cb.checked ? 'Enabled' : 'Disabled'); - - if (cb.name === 'mdblistEnabled') { - var configDiv = self.dialog.querySelector('.moonfin-mdblist-config'); - if (configDiv) { - configDiv.style.display = cb.checked ? '' : 'none'; - } - } - - if (cb.name === 'tmdbEpisodeRatingsEnabled') { - var tmdbConfigDiv = self.dialog.querySelector('.moonfin-tmdb-config'); - if (tmdbConfigDiv) { - tmdbConfigDiv.style.display = cb.checked ? '' : 'none'; - } - } - }); - })(checkboxes[i]); - } + // Load picker on init if collection mode is active + if (sourceTypeSelect.value === "collection") { + self.loadCollectionPicker(); + } + } - var selects = this.dialog.querySelectorAll('select'); - for (var j = 0; j < selects.length; j++) { - (function(sel) { - sel.addEventListener('change', function() { - var val = sel.value; - var numVal = parseInt(val, 10); - self.saveSetting(sel.name, isNaN(numVal) ? val : numVal); - self.showToast('Setting updated'); - }); - })(selects[j]); - } + this.dialog.addEventListener("keydown", function (e) { + if (e.key === "Escape") { + self.hide(); + } + }); + + // MDBList API key - save on input with debounce + on blur + var mdblistApiKeyInput = this.dialog.querySelector( + "#moonfin-mdblistApiKey", + ); + if (mdblistApiKeyInput) { + var mdblistKeyTimer = null; + mdblistApiKeyInput.addEventListener("input", function () { + if (mdblistKeyTimer) clearTimeout(mdblistKeyTimer); + mdblistKeyTimer = setTimeout(function () { + self.saveSetting("mdblistApiKey", mdblistApiKeyInput.value.trim()); + self.showToast("API key saved"); + }, 800); + }); + mdblistApiKeyInput.addEventListener("blur", function () { + if (mdblistKeyTimer) clearTimeout(mdblistKeyTimer); + self.saveSetting("mdblistApiKey", mdblistApiKeyInput.value.trim()); + }); + } - var ranges = this.dialog.querySelectorAll('input[type="range"]'); - for (var k = 0; k < ranges.length; k++) { - (function(range) { - range.addEventListener('input', function() { - var valueSpan = self.dialog.querySelector('.moonfin-range-value[data-for="' + range.name + '"]'); - if (valueSpan) { - valueSpan.textContent = range.value + '%'; - } - }); - range.addEventListener('change', function() { - self.saveSetting(range.name, parseInt(range.value, 10)); - self.showToast('Setting updated'); - }); - })(ranges[k]); - } + // TMDB API key - save on input with debounce + on blur + var tmdbApiKeyInput = this.dialog.querySelector("#moonfin-tmdbApiKey"); + if (tmdbApiKeyInput) { + var tmdbKeyTimer = null; + tmdbApiKeyInput.addEventListener("input", function () { + if (tmdbKeyTimer) clearTimeout(tmdbKeyTimer); + tmdbKeyTimer = setTimeout(function () { + self.saveSetting("tmdbApiKey", tmdbApiKeyInput.value.trim()); + self.showToast("TMDB API key saved"); + }, 800); + }); + tmdbApiKeyInput.addEventListener("blur", function () { + if (tmdbKeyTimer) clearTimeout(tmdbKeyTimer); + self.saveSetting("tmdbApiKey", tmdbApiKeyInput.value.trim()); + }); + } - var colorSelect = this.dialog.querySelector('select[name="mediaBarOverlayColor"]'); - if (colorSelect) { - colorSelect.addEventListener('change', function() { - var preview = self.dialog.querySelector('#moonfin-color-preview'); - if (preview) { - preview.style.background = Storage.getColorHex(colorSelect.value); - } - }); + // --- Sortable rating sources --- + var sortableList = this.dialog.querySelector("#moonfin-sources-sortable"); + if (sortableList) { + var dragItem = null; + var dragPlaceholder = document.createElement("div"); + dragPlaceholder.className = "moonfin-sortable-placeholder"; + + var saveSortableState = function () { + var items = sortableList.querySelectorAll(".moonfin-sortable-item"); + var enabled = []; + for (var i = 0; i < items.length; i++) { + if (items[i].classList.contains("moonfin-sortable-item-active")) { + enabled.push(items[i].getAttribute("data-source")); + } } - - this.dialog.addEventListener('keydown', function(e) { - if (e.key === 'Escape') { - self.hide(); - } - }); - - // MDBList API key - save on input with debounce + on blur - var mdblistApiKeyInput = this.dialog.querySelector('#moonfin-mdblistApiKey'); - if (mdblistApiKeyInput) { - var mdblistKeyTimer = null; - mdblistApiKeyInput.addEventListener('input', function() { - if (mdblistKeyTimer) clearTimeout(mdblistKeyTimer); - mdblistKeyTimer = setTimeout(function() { - self.saveSetting('mdblistApiKey', mdblistApiKeyInput.value.trim()); - self.showToast('API key saved'); - }, 800); - }); - mdblistApiKeyInput.addEventListener('blur', function() { - if (mdblistKeyTimer) clearTimeout(mdblistKeyTimer); - self.saveSetting('mdblistApiKey', mdblistApiKeyInput.value.trim()); - }); + self.saveSetting("mdblistRatingSources", enabled); + self.showToast("Rating sources updated"); + }; + + sortableList.addEventListener("dragstart", function (e) { + var item = e.target.closest(".moonfin-sortable-item"); + if (!item) return; + dragItem = item; + item.classList.add("moonfin-sortable-dragging"); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", ""); + }); + + sortableList.addEventListener("dragend", function () { + if (dragItem) { + dragItem.classList.remove("moonfin-sortable-dragging"); + dragItem = null; } - - // TMDB API key - save on input with debounce + on blur - var tmdbApiKeyInput = this.dialog.querySelector('#moonfin-tmdbApiKey'); - if (tmdbApiKeyInput) { - var tmdbKeyTimer = null; - tmdbApiKeyInput.addEventListener('input', function() { - if (tmdbKeyTimer) clearTimeout(tmdbKeyTimer); - tmdbKeyTimer = setTimeout(function() { - self.saveSetting('tmdbApiKey', tmdbApiKeyInput.value.trim()); - self.showToast('TMDB API key saved'); - }, 800); - }); - tmdbApiKeyInput.addEventListener('blur', function() { - if (tmdbKeyTimer) clearTimeout(tmdbKeyTimer); - self.saveSetting('tmdbApiKey', tmdbApiKeyInput.value.trim()); - }); + if (dragPlaceholder.parentNode) { + dragPlaceholder.parentNode.removeChild(dragPlaceholder); } - - // --- Sortable rating sources --- - var sortableList = this.dialog.querySelector('#moonfin-sources-sortable'); - if (sortableList) { - var dragItem = null; - var dragPlaceholder = document.createElement('div'); - dragPlaceholder.className = 'moonfin-sortable-placeholder'; - - var saveSortableState = function() { - var items = sortableList.querySelectorAll('.moonfin-sortable-item'); - var enabled = []; - for (var i = 0; i < items.length; i++) { - if (items[i].classList.contains('moonfin-sortable-item-active')) { - enabled.push(items[i].getAttribute('data-source')); - } - } - self.saveSetting('mdblistRatingSources', enabled); - self.showToast('Rating sources updated'); - }; - - sortableList.addEventListener('dragstart', function(e) { - var item = e.target.closest('.moonfin-sortable-item'); - if (!item) return; - dragItem = item; - item.classList.add('moonfin-sortable-dragging'); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', ''); - }); - - sortableList.addEventListener('dragend', function() { - if (dragItem) { - dragItem.classList.remove('moonfin-sortable-dragging'); - dragItem = null; - } - if (dragPlaceholder.parentNode) { - dragPlaceholder.parentNode.removeChild(dragPlaceholder); - } - }); - - sortableList.addEventListener('dragover', function(e) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - var target = e.target.closest('.moonfin-sortable-item'); - if (!target || target === dragItem) return; - - var rect = target.getBoundingClientRect(); - var midY = rect.top + rect.height / 2; - if (e.clientY < midY) { - sortableList.insertBefore(dragPlaceholder, target); - } else { - sortableList.insertBefore(dragPlaceholder, target.nextSibling); - } - }); - - sortableList.addEventListener('drop', function(e) { - e.preventDefault(); - if (!dragItem) return; - if (dragPlaceholder.parentNode) { - sortableList.insertBefore(dragItem, dragPlaceholder); - dragPlaceholder.parentNode.removeChild(dragPlaceholder); - } - saveSortableState(); - }); - - // Toggle enable/disable - sortableList.addEventListener('click', function(e) { - var toggleBtn = e.target.closest('.moonfin-sortable-toggle'); - if (!toggleBtn) return; - var item = toggleBtn.closest('.moonfin-sortable-item'); - if (!item) return; - var isActive = item.classList.toggle('moonfin-sortable-item-active'); - var svg = toggleBtn.querySelector('path'); - if (svg) { - svg.setAttribute('d', isActive - ? 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' - : 'M19 13H5v-2h14v2z'); - } - toggleBtn.title = isActive ? 'Disable' : 'Enable'; - saveSortableState(); - }); - - // Touch drag support for mobile / TV - (function() { - var touchItem = null; - var touchClone = null; - var touchOffsetY = 0; - - sortableList.addEventListener('touchstart', function(e) { - var handle = e.target.closest('.moonfin-sortable-handle'); - if (!handle) return; - var item = handle.closest('.moonfin-sortable-item'); - if (!item) return; - touchItem = item; - var rect = item.getBoundingClientRect(); - touchOffsetY = e.touches[0].clientY - rect.top; - touchClone = item.cloneNode(true); - touchClone.className = 'moonfin-sortable-item moonfin-sortable-touch-clone'; - touchClone.style.width = rect.width + 'px'; - touchClone.style.top = rect.top + 'px'; - touchClone.style.left = rect.left + 'px'; - document.body.appendChild(touchClone); - item.classList.add('moonfin-sortable-dragging'); - }, { passive: true }); - - sortableList.addEventListener('touchmove', function(e) { - if (!touchItem || !touchClone) return; - e.preventDefault(); - var y = e.touches[0].clientY; - touchClone.style.top = (y - touchOffsetY) + 'px'; - - var items = sortableList.querySelectorAll('.moonfin-sortable-item:not(.moonfin-sortable-dragging)'); - for (var i = 0; i < items.length; i++) { - var rect = items[i].getBoundingClientRect(); - var midY = rect.top + rect.height / 2; - if (y < midY) { - sortableList.insertBefore(touchItem, items[i]); - return; - } - } - sortableList.appendChild(touchItem); - }, { passive: false }); - - sortableList.addEventListener('touchend', function() { - if (touchItem) { - touchItem.classList.remove('moonfin-sortable-dragging'); - touchItem = null; - } - if (touchClone && touchClone.parentNode) { - touchClone.parentNode.removeChild(touchClone); - touchClone = null; - } - saveSortableState(); - }, { passive: true }); - })(); + }); + + sortableList.addEventListener("dragover", function (e) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + var target = e.target.closest(".moonfin-sortable-item"); + if (!target || target === dragItem) return; + + var rect = target.getBoundingClientRect(); + var midY = rect.top + rect.height / 2; + if (e.clientY < midY) { + sortableList.insertBefore(dragPlaceholder, target); + } else { + sortableList.insertBefore(dragPlaceholder, target.nextSibling); } - - var loginBtn = this.dialog.querySelector('.moonfin-jellyseerr-settings-login-btn'); - if (loginBtn) { - loginBtn.addEventListener('click', function() { - self.handleJellyseerrLogin(); - }); + }); + + sortableList.addEventListener("drop", function (e) { + e.preventDefault(); + if (!dragItem) return; + if (dragPlaceholder.parentNode) { + sortableList.insertBefore(dragItem, dragPlaceholder); + dragPlaceholder.parentNode.removeChild(dragPlaceholder); } - - var passwordInput = this.dialog.querySelector('#jellyseerr-settings-password'); - if (passwordInput) { - passwordInput.addEventListener('keydown', function(e) { - if (e.key === 'Enter') { - self.handleJellyseerrLogin(); - } - }); + saveSortableState(); + }); + + // Toggle enable/disable + sortableList.addEventListener("click", function (e) { + var toggleBtn = e.target.closest(".moonfin-sortable-toggle"); + if (!toggleBtn) return; + var item = toggleBtn.closest(".moonfin-sortable-item"); + if (!item) return; + var isActive = item.classList.toggle("moonfin-sortable-item-active"); + var svg = toggleBtn.querySelector("path"); + if (svg) { + svg.setAttribute( + "d", + isActive + ? "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" + : "M19 13H5v-2h14v2z", + ); } + toggleBtn.title = isActive ? "Disable" : "Enable"; + saveSortableState(); + }); + + // Touch drag support for mobile / TV + (function () { + var touchItem = null; + var touchClone = null; + var touchOffsetY = 0; + + sortableList.addEventListener( + "touchstart", + function (e) { + var handle = e.target.closest(".moonfin-sortable-handle"); + if (!handle) return; + var item = handle.closest(".moonfin-sortable-item"); + if (!item) return; + touchItem = item; + var rect = item.getBoundingClientRect(); + touchOffsetY = e.touches[0].clientY - rect.top; + touchClone = item.cloneNode(true); + touchClone.className = + "moonfin-sortable-item moonfin-sortable-touch-clone"; + touchClone.style.width = rect.width + "px"; + touchClone.style.top = rect.top + "px"; + touchClone.style.left = rect.left + "px"; + document.body.appendChild(touchClone); + item.classList.add("moonfin-sortable-dragging"); + }, + { passive: true }, + ); + + sortableList.addEventListener( + "touchmove", + function (e) { + if (!touchItem || !touchClone) return; + e.preventDefault(); + var y = e.touches[0].clientY; + touchClone.style.top = y - touchOffsetY + "px"; + + var items = sortableList.querySelectorAll( + ".moonfin-sortable-item:not(.moonfin-sortable-dragging)", + ); + for (var i = 0; i < items.length; i++) { + var rect = items[i].getBoundingClientRect(); + var midY = rect.top + rect.height / 2; + if (y < midY) { + sortableList.insertBefore(touchItem, items[i]); + return; + } + } + sortableList.appendChild(touchItem); + }, + { passive: false }, + ); + + sortableList.addEventListener( + "touchend", + function () { + if (touchItem) { + touchItem.classList.remove("moonfin-sortable-dragging"); + touchItem = null; + } + if (touchClone && touchClone.parentNode) { + touchClone.parentNode.removeChild(touchClone); + touchClone = null; + } + saveSortableState(); + }, + { passive: true }, + ); + })(); + } - var authTypeBtns = this.dialog.querySelectorAll('.moonfin-segmented-btn[data-auth-type]'); - for (var ati = 0; ati < authTypeBtns.length; ati++) { - authTypeBtns[ati].addEventListener('click', function() { - var wrapper = self.dialog.querySelector('.moonfin-settings-jellyseerr-wrapper'); - if (!wrapper) return; - var btns = wrapper.querySelectorAll('.moonfin-segmented-btn[data-auth-type]'); - for (var j = 0; j < btns.length; j++) btns[j].classList.remove('moonfin-segmented-btn-active'); - this.classList.add('moonfin-segmented-btn-active'); - var isLocal = this.getAttribute('data-auth-type') === 'local'; - var desc = wrapper.querySelector('.moonfin-jellyseerr-login-desc'); - var usernameLabel = wrapper.querySelector('.moonfin-jellyseerr-username-label'); - if (desc) desc.textContent = isLocal - ? 'Enter your local Jellyseerr account credentials. Your session is stored on the server so all devices stay signed in.' - : 'Enter your Jellyfin credentials to sign in to Jellyseerr. Your session is stored on the server so all devices stay signed in.'; - if (usernameLabel) usernameLabel.textContent = isLocal ? 'Email' : 'Username'; - var passwordField = wrapper.querySelector('#jellyseerr-settings-password'); - if (passwordField) passwordField.placeholder = isLocal ? '' : 'Leave empty if no password'; - }); - } + var loginBtn = this.dialog.querySelector( + ".moonfin-jellyseerr-settings-login-btn", + ); + if (loginBtn) { + loginBtn.addEventListener("click", function () { + self.handleJellyseerrLogin(); + }); + } - var logoutBtn = this.dialog.querySelector('.moonfin-jellyseerr-settings-logout-btn'); - if (logoutBtn) { - logoutBtn.addEventListener('click', function() { - if (confirm('Sign out of Jellyseerr? You will need to sign in again to use it.')) { - Jellyseerr.ssoLogout().then(function() { - self.updateJellyseerrSsoSection(); - self.showToast('Signed out of Jellyseerr'); - }); - } - }); + var passwordInput = this.dialog.querySelector( + "#jellyseerr-settings-password", + ); + if (passwordInput) { + passwordInput.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + self.handleJellyseerrLogin(); } - }, + }); + } - handleJellyseerrLogin: function() { - var self = this; - var wrapper = this.dialog ? this.dialog.querySelector('.moonfin-settings-jellyseerr-wrapper') : null; + var authTypeBtns = this.dialog.querySelectorAll( + ".moonfin-segmented-btn[data-auth-type]", + ); + for (var ati = 0; ati < authTypeBtns.length; ati++) { + authTypeBtns[ati].addEventListener("click", function () { + var wrapper = self.dialog.querySelector( + ".moonfin-settings-jellyseerr-wrapper", + ); if (!wrapper) return; + var btns = wrapper.querySelectorAll( + ".moonfin-segmented-btn[data-auth-type]", + ); + for (var j = 0; j < btns.length; j++) + btns[j].classList.remove("moonfin-segmented-btn-active"); + this.classList.add("moonfin-segmented-btn-active"); + var isLocal = this.getAttribute("data-auth-type") === "local"; + var desc = wrapper.querySelector(".moonfin-jellyseerr-login-desc"); + var usernameLabel = wrapper.querySelector( + ".moonfin-jellyseerr-username-label", + ); + if (desc) + desc.textContent = isLocal + ? "Enter your local Jellyseerr account credentials. Your session is stored on the server so all devices stay signed in." + : "Enter your Jellyfin credentials to sign in to Jellyseerr. Your session is stored on the server so all devices stay signed in."; + if (usernameLabel) + usernameLabel.textContent = isLocal ? "Email" : "Username"; + var passwordField = wrapper.querySelector( + "#jellyseerr-settings-password", + ); + if (passwordField) + passwordField.placeholder = isLocal + ? "" + : "Leave empty if no password"; + }); + } - var username = wrapper.querySelector('#jellyseerr-settings-username'); - var password = wrapper.querySelector('#jellyseerr-settings-password'); - var errorEl = wrapper.querySelector('.moonfin-jellyseerr-login-error'); - var submitBtn = wrapper.querySelector('.moonfin-jellyseerr-settings-login-btn'); - - var usernameVal = username ? username.value : ''; - var passwordVal = password ? password.value : ''; - - var activeAuthBtn = wrapper.querySelector('.moonfin-segmented-btn-active[data-auth-type]'); - var authType = activeAuthBtn ? activeAuthBtn.getAttribute('data-auth-type') : 'jellyfin'; - var isLocalAuth = authType === 'local'; - if (!usernameVal) { - errorEl.textContent = isLocalAuth ? 'Please enter your email.' : 'Please enter your username.'; - errorEl.style.display = 'block'; - return; + var logoutBtn = this.dialog.querySelector( + ".moonfin-jellyseerr-settings-logout-btn", + ); + if (logoutBtn) { + logoutBtn.addEventListener("click", function () { + if ( + confirm( + "Sign out of Jellyseerr? You will need to sign in again to use it.", + ) + ) { + Jellyseerr.ssoLogout().then(function () { + self.updateJellyseerrSsoSection(); + self.showToast("Signed out of Jellyseerr"); + }); } - if (isLocalAuth && !passwordVal) { - errorEl.textContent = 'Please enter your password.'; - errorEl.style.display = 'block'; - return; - } - - submitBtn.disabled = true; - submitBtn.textContent = 'Signing in...'; - errorEl.style.display = 'none'; - - Jellyseerr.ssoLogin(usernameVal, passwordVal, authType).then(function(result) { - if (result.success) { - self.updateJellyseerrSsoSection(); - self.showToast('Signed in to Jellyseerr'); - } else { - errorEl.textContent = result.error || 'Authentication failed'; - errorEl.style.display = 'block'; - submitBtn.disabled = false; - submitBtn.textContent = 'Sign In'; - } - }); + }); + } + }, + + handleJellyseerrLogin: function () { + var self = this; + var wrapper = this.dialog + ? this.dialog.querySelector(".moonfin-settings-jellyseerr-wrapper") + : null; + if (!wrapper) return; + + var username = wrapper.querySelector("#jellyseerr-settings-username"); + var password = wrapper.querySelector("#jellyseerr-settings-password"); + var errorEl = wrapper.querySelector(".moonfin-jellyseerr-login-error"); + var submitBtn = wrapper.querySelector( + ".moonfin-jellyseerr-settings-login-btn", + ); + + var usernameVal = username ? username.value : ""; + var passwordVal = password ? password.value : ""; + + var activeAuthBtn = wrapper.querySelector( + ".moonfin-segmented-btn-active[data-auth-type]", + ); + var authType = activeAuthBtn + ? activeAuthBtn.getAttribute("data-auth-type") + : "jellyfin"; + var isLocalAuth = authType === "local"; + if (!usernameVal) { + errorEl.textContent = isLocalAuth + ? "Please enter your email." + : "Please enter your username."; + errorEl.style.display = "block"; + return; + } + if (isLocalAuth && !passwordVal) { + errorEl.textContent = "Please enter your password."; + errorEl.style.display = "block"; + return; } + + submitBtn.disabled = true; + submitBtn.textContent = "Signing in..."; + errorEl.style.display = "none"; + + Jellyseerr.ssoLogin(usernameVal, passwordVal, authType).then( + function (result) { + if (result.success) { + self.updateJellyseerrSsoSection(); + self.showToast("Signed in to Jellyseerr"); + } else { + errorEl.textContent = result.error || "Authentication failed"; + errorEl.style.display = "block"; + submitBtn.disabled = false; + submitBtn.textContent = "Sign In"; + } + }, + ); + }, }; diff --git a/frontend/src/styles/settings.css b/frontend/src/styles/settings.css index a019875..037e438 100644 --- a/frontend/src/styles/settings.css +++ b/frontend/src/styles/settings.css @@ -4,633 +4,733 @@ */ .moonfin-settings-dialog { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 99999; - display: flex; - justify-content: flex-end; - opacity: 0; - visibility: hidden; - transition: opacity 0.3s ease, visibility 0.3s ease; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 99999; + display: flex; + justify-content: flex-end; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; } .moonfin-settings-dialog.open { - opacity: 1; - visibility: visible; + opacity: 1; + visibility: visible; } .moonfin-settings-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(4px); + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); } .moonfin-settings-panel { - position: relative; - width: 420px; - max-width: 90vw; - height: 100%; - background: rgba(20, 20, 25, 0.97); - display: flex; - flex-direction: column; - transform: translateX(100%); - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: -4px 0 30px rgba(0, 0, 0, 0.5); + position: relative; + width: 420px; + max-width: 90vw; + height: 100%; + background: rgba(20, 20, 25, 0.97); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: -4px 0 30px rgba(0, 0, 0, 0.5); } .moonfin-settings-dialog.open .moonfin-settings-panel { - transform: translateX(0); + transform: translateX(0); } .moonfin-settings-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 20px 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(15, 15, 20, 0.8); + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 20px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(15, 15, 20, 0.8); } .moonfin-settings-header-left { - display: flex; - align-items: baseline; - gap: 8px; + display: flex; + align-items: baseline; + gap: 8px; } .moonfin-settings-header h2 { - margin: 0; - font-size: 20px; - font-weight: 700; - color: #00a4dc; + margin: 0; + font-size: 20px; + font-weight: 700; + color: #00a4dc; } .moonfin-settings-subtitle { - font-size: 14px; - color: rgba(255, 255, 255, 0.5); - font-weight: 400; + font-size: 14px; + color: rgba(255, 255, 255, 0.5); + font-weight: 400; } .moonfin-settings-close { - width: 36px; - height: 36px; - border: none; - background: rgba(255, 255, 255, 0.06); - border-radius: 50%; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - color: rgba(255, 255, 255, 0.7); - transition: background 0.2s ease, color 0.2s ease; + width: 36px; + height: 36px; + border: none; + background: rgba(255, 255, 255, 0.06); + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.7); + transition: background 0.2s ease, color 0.2s ease; } .moonfin-settings-close:hover { - background: rgba(255, 255, 255, 0.12); - color: white; + background: rgba(255, 255, 255, 0.12); + color: white; } /* Profile Tabs */ .moonfin-profile-tabs { - display: flex; - gap: 2px; - padding: 10px 16px 0; - background: rgba(15, 15, 20, 0.6); - border-bottom: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + gap: 2px; + padding: 10px 16px 0; + background: rgba(15, 15, 20, 0.6); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .moonfin-profile-tab { - flex: 1; - padding: 8px 4px; - border: none; - background: rgba(255, 255, 255, 0.04); - color: rgba(255, 255, 255, 0.5); - font-size: 12px; - font-weight: 500; - cursor: pointer; - border-radius: 6px 6px 0 0; - transition: background 0.2s ease, color 0.2s ease; - text-align: center; - white-space: nowrap; - position: relative; + flex: 1; + padding: 8px 4px; + border: none; + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.5); + font-size: 12px; + font-weight: 500; + cursor: pointer; + border-radius: 6px 6px 0 0; + transition: background 0.2s ease, color 0.2s ease; + text-align: center; + white-space: nowrap; + position: relative; } .moonfin-profile-tab:hover { - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.8); + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.8); } .moonfin-profile-tab-active { - background: rgba(0, 164, 220, 0.15); - color: #00a4dc; - font-weight: 600; + background: rgba(0, 164, 220, 0.15); + color: #00a4dc; + font-weight: 600; } .moonfin-profile-tab-active:hover { - background: rgba(0, 164, 220, 0.2); - color: #00a4dc; + background: rgba(0, 164, 220, 0.2); + color: #00a4dc; } .moonfin-profile-current-badge { - color: #4caf50; - font-size: 8px; - vertical-align: super; + color: #4caf50; + font-size: 8px; + vertical-align: super; } .moonfin-profile-info { - display: flex; - align-items: flex-start; - gap: 6px; - padding: 8px 16px; - font-size: 11px; - color: rgba(255, 255, 255, 0.45); - background: rgba(0, 164, 220, 0.04); - line-height: 1.4; + display: flex; + align-items: flex-start; + gap: 6px; + padding: 8px 16px; + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + background: rgba(0, 164, 220, 0.04); + line-height: 1.4; } .moonfin-profile-info-icon { - color: rgba(0, 164, 220, 0.6); - flex-shrink: 0; - font-size: 12px; + color: rgba(0, 164, 220, 0.6); + flex-shrink: 0; + font-size: 12px; } /* Inherited setting indicator for device profiles */ .moonfin-toggle-card.moonfin-inherited, .moonfin-select-card.moonfin-inherited { - opacity: 0.55; - border-left: 2px solid rgba(255, 255, 255, 0.1); - padding-left: 10px; - transition: opacity 0.2s ease; + opacity: 0.55; + border-left: 2px solid rgba(255, 255, 255, 0.1); + padding-left: 10px; + transition: opacity 0.2s ease; } .moonfin-toggle-card.moonfin-inherited:hover, .moonfin-select-card.moonfin-inherited:hover { - opacity: 0.85; + opacity: 0.85; } /* Sync toggle in footer */ .moonfin-sync-toggle-label { - display: inline-flex; - align-items: center; - gap: 4px; - margin-left: 10px; - cursor: pointer; - font-size: 11px; - color: rgba(255, 255, 255, 0.5); - user-select: none; + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 10px; + cursor: pointer; + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + user-select: none; } .moonfin-sync-toggle-label input[type="checkbox"] { - width: 14px; - height: 14px; - margin: 0; - cursor: pointer; - accent-color: #00a4dc; + width: 14px; + height: 14px; + margin: 0; + cursor: pointer; + accent-color: #00a4dc; } .moonfin-sync-toggle-text { - font-weight: 500; + font-weight: 500; } .moonfin-settings-content { - flex: 1; - overflow-y: auto; - padding: 16px 16px 20px; - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.15) transparent; + flex: 1; + overflow-y: auto; + padding: 16px 16px 20px; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.15) transparent; } .moonfin-settings-content::-webkit-scrollbar { - width: 6px; + width: 6px; } .moonfin-settings-content::-webkit-scrollbar-track { - background: transparent; + background: transparent; } .moonfin-settings-content::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.15); - border-radius: 3px; + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; } .moonfin-panel-section { - margin-bottom: 12px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; - background: rgba(255, 255, 255, 0.03); - overflow: hidden; + margin-bottom: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + overflow: hidden; } .moonfin-panel-summary { - padding: 14px 16px; - font-weight: 600; - font-size: 14px; - color: #00a4dc; - cursor: pointer; - user-select: none; - list-style: none; - display: flex; - align-items: center; - gap: 6px; - transition: background 0.2s ease; + padding: 14px 16px; + font-weight: 600; + font-size: 14px; + color: #00a4dc; + cursor: pointer; + user-select: none; + list-style: none; + display: flex; + align-items: center; + gap: 6px; + transition: background 0.2s ease; } .moonfin-panel-summary::-webkit-details-marker { - display: none; + display: none; } .moonfin-panel-summary::after { - content: ''; - margin-left: auto; - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 5px solid rgba(255, 255, 255, 0.4); - transition: transform 0.2s ease; + content: ''; + margin-left: auto; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid rgba(255, 255, 255, 0.4); + transition: transform 0.2s ease; } -.moonfin-panel-section[open] > .moonfin-panel-summary::after { - transform: rotate(180deg); +.moonfin-panel-section[open]>.moonfin-panel-summary::after { + transform: rotate(180deg); } .moonfin-panel-summary:hover { - background: rgba(255, 255, 255, 0.04); + background: rgba(255, 255, 255, 0.04); } .moonfin-panel-section-content { - padding: 4px 16px 16px; + padding: 4px 16px 16px; } .moonfin-toggle-card { - margin-bottom: 10px; - padding: 12px; - background: rgba(255, 255, 255, 0.04); - border-radius: 6px; - border-left: 3px solid #00a4dc; + margin-bottom: 10px; + padding: 12px; + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; + border-left: 3px solid #00a4dc; } .moonfin-toggle-card:last-child { - margin-bottom: 0; + margin-bottom: 0; } .moonfin-toggle-label { - display: flex; - align-items: center; - gap: 12px; - cursor: pointer; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; } .moonfin-toggle-label input[type="checkbox"] { - width: 18px; - height: 18px; - accent-color: #00a4dc; - cursor: pointer; - flex-shrink: 0; + width: 18px; + height: 18px; + accent-color: #00a4dc; + cursor: pointer; + flex-shrink: 0; } .moonfin-toggle-info { - flex: 1; - min-width: 0; + flex: 1; + min-width: 0; } .moonfin-toggle-title { - font-weight: 500; - font-size: 14px; - color: rgba(255, 255, 255, 0.9); + font-weight: 500; + font-size: 14px; + color: rgba(255, 255, 255, 0.9); } .moonfin-toggle-desc { - font-size: 12px; - color: rgba(255, 255, 255, 0.45); - margin-top: 2px; - line-height: 1.4; + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + margin-top: 2px; + line-height: 1.4; } .moonfin-select-card { - margin-bottom: 10px; - padding: 12px; - background: rgba(255, 255, 255, 0.04); - border-radius: 6px; - border-left: 3px solid rgba(0, 164, 220, 0.4); + margin-bottom: 10px; + padding: 12px; + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; + border-left: 3px solid rgba(0, 164, 220, 0.4); } .moonfin-select-card:last-child { - margin-bottom: 0; + margin-bottom: 0; } .moonfin-select-info { - margin-bottom: 8px; + margin-bottom: 8px; } .moonfin-panel-select { - width: 100%; - padding: 8px 12px; - font-size: 13px; - color: #fff; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 6px; - cursor: pointer; - outline: none; - transition: border-color 0.2s ease; - -webkit-appearance: none; - appearance: none; + width: 100%; + padding: 8px 12px; + font-size: 13px; + color: #fff; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + cursor: pointer; + outline: none; + transition: border-color 0.2s ease; + -webkit-appearance: none; + appearance: none; } .moonfin-panel-select:hover, .moonfin-panel-select:focus { - border-color: #00a4dc; + border-color: #00a4dc; } .moonfin-panel-select option { - background: #1a1a1e; - color: #fff; + background: #1a1a1e; + color: #fff; } .moonfin-panel-range { - width: 100%; - height: 4px; - -webkit-appearance: none; - appearance: none; - background: rgba(255, 255, 255, 0.15); - border-radius: 2px; - cursor: pointer; - outline: none; - margin-top: 4px; + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; + cursor: pointer; + outline: none; + margin-top: 4px; } .moonfin-panel-range::-webkit-slider-thumb { - -webkit-appearance: none; - width: 16px; - height: 16px; - background: #00a4dc; - border-radius: 50%; - cursor: pointer; - transition: transform 0.15s ease; + -webkit-appearance: none; + width: 16px; + height: 16px; + background: #00a4dc; + border-radius: 50%; + cursor: pointer; + transition: transform 0.15s ease; } .moonfin-panel-range::-webkit-slider-thumb:hover { - transform: scale(1.15); + transform: scale(1.15); } .moonfin-panel-range::-moz-range-thumb { - width: 16px; - height: 16px; - background: #00a4dc; - border: none; - border-radius: 50%; - cursor: pointer; + width: 16px; + height: 16px; + background: #00a4dc; + border: none; + border-radius: 50%; + cursor: pointer; } .moonfin-range-value { - font-weight: 600; - color: #00a4dc; - font-size: 13px; + font-weight: 600; + color: #00a4dc; + font-size: 13px; } .moonfin-color-preview { - width: 100%; - height: 6px; - border-radius: 3px; - margin: 4px 0 10px; + width: 100%; + height: 6px; + border-radius: 3px; + margin: 4px 0 10px; } .moonfin-panel-input { - width: 100%; - padding: 8px 12px; - font-size: 13px; - color: #fff; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 6px; - outline: none; - transition: border-color 0.2s ease; - box-sizing: border-box; + width: 100%; + padding: 8px 12px; + font-size: 13px; + color: #fff; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + outline: none; + transition: border-color 0.2s ease; + box-sizing: border-box; } .moonfin-panel-input:focus { - border-color: #00a4dc; + border-color: #00a4dc; } .moonfin-input-label { - display: block; - font-size: 12px; - color: rgba(255, 255, 255, 0.6); - margin-bottom: 4px; + display: block; + font-size: 12px; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 4px; } .moonfin-panel-btn { - display: inline-block; - padding: 8px 16px; - font-size: 13px; - font-weight: 600; - border: none; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; - width: 100%; - text-align: center; + display: inline-block; + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + width: 100%; + text-align: center; } .moonfin-panel-btn-primary { - background: #00a4dc; - color: #fff; + background: #00a4dc; + color: #fff; } .moonfin-panel-btn-primary:hover { - background: #0096c8; + background: #0096c8; } .moonfin-panel-btn-primary:disabled { - opacity: 0.6; - cursor: not-allowed; + opacity: 0.6; + cursor: not-allowed; } .moonfin-panel-btn-danger { - background: rgba(239, 68, 68, 0.15); - color: #ef4444; - border: 1px solid rgba(239, 68, 68, 0.3); + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); } .moonfin-panel-btn-danger:hover { - background: rgba(239, 68, 68, 0.25); + background: rgba(239, 68, 68, 0.25); } .moonfin-panel-btn-ghost { - background: transparent; - color: rgba(255, 255, 255, 0.6); - border: 1px solid rgba(255, 255, 255, 0.12); - padding: 6px 14px; - width: auto; + background: transparent; + color: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 6px 14px; + width: auto; } .moonfin-panel-btn-ghost:hover { - background: rgba(255, 255, 255, 0.08); - color: white; + background: rgba(255, 255, 255, 0.08); + color: white; } .moonfin-panel-btn-ghost:disabled { - opacity: 0.5; - cursor: not-allowed; + opacity: 0.5; + cursor: not-allowed; } .moonfin-panel-btn-close { - background: #00a4dc; - color: #fff; - padding: 6px 20px; - width: auto; + background: #00a4dc; + color: #fff; + padding: 6px 20px; + width: auto; } .moonfin-panel-btn-close:hover { - background: #0096c8; + background: #0096c8; } .moonfin-settings-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-top: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(15, 15, 20, 0.8); - gap: 12px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(15, 15, 20, 0.8); + gap: 12px; } .moonfin-settings-footer-buttons { - display: flex; - gap: 8px; - align-items: center; + display: flex; + gap: 8px; + align-items: center; } .moonfin-sync-status { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - color: rgba(255, 255, 255, 0.45); + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: rgba(255, 255, 255, 0.45); } .moonfin-sync-indicator { - width: 6px; - height: 6px; - border-radius: 50%; - background: #666; - flex-shrink: 0; + width: 6px; + height: 6px; + border-radius: 50%; + background: #666; + flex-shrink: 0; } .moonfin-sync-indicator.connected { - background: #4caf50; - box-shadow: 0 0 4px #4caf50; + background: #4caf50; + box-shadow: 0 0 4px #4caf50; } .moonfin-sync-indicator.disconnected { - background: #f44336; + background: #f44336; } .moonfin-sync-indicator.checking, .moonfin-sync-indicator.syncing { - background: #ff9800; - animation: moonfin-pulse 1s infinite; + background: #ff9800; + animation: moonfin-pulse 1s infinite; } @keyframes moonfin-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } } .moonfin-jellyseerr-sso-status { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; } .moonfin-jellyseerr-sso-indicator { - width: 8px; - height: 8px; - border-radius: 50%; - background: #666; - flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: #666; + flex-shrink: 0; } .moonfin-jellyseerr-sso-indicator.connected { - background: #4caf50; - box-shadow: 0 0 6px #4caf50; + background: #4caf50; + box-shadow: 0 0 6px #4caf50; } .moonfin-jellyseerr-sso-indicator.disconnected { - background: #f44336; + background: #f44336; } .moonfin-jellyseerr-sso-text { - font-size: 13px; - color: rgba(255, 255, 255, 0.8); + font-size: 13px; + color: rgba(255, 255, 255, 0.8); } .moonfin-jellyseerr-login-error { - color: #ef4444; - font-size: 12px; - margin-bottom: 8px; - padding: 6px 10px; - background: rgba(239, 68, 68, 0.1); - border-radius: 4px; + color: #ef4444; + font-size: 12px; + margin-bottom: 8px; + padding: 6px 10px; + background: rgba(239, 68, 68, 0.1); + border-radius: 4px; } .moonfin-segmented-control { - display: flex; - background: rgba(255, 255, 255, 0.08); - border-radius: 8px; - padding: 3px; - gap: 2px; + display: flex; + background: rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 3px; + gap: 2px; } .moonfin-segmented-btn { - flex: 1; - padding: 6px 12px; - border: none; - border-radius: 6px; - background: transparent; - color: rgba(255, 255, 255, 0.6); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - font-family: inherit; + flex: 1; + padding: 6px 12px; + border: none; + border-radius: 6px; + background: transparent; + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; } .moonfin-segmented-btn:hover { - color: rgba(255, 255, 255, 0.85); - background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.85); + background: rgba(255, 255, 255, 0.05); } .moonfin-segmented-btn-active { - background: rgba(255, 255, 255, 0.15); - color: #fff; + background: rgba(255, 255, 255, 0.15); + color: #fff; } @media (max-width: 520px) { - .moonfin-settings-panel { - width: 100%; - max-width: 100vw; - } + .moonfin-settings-panel { + width: 100%; + max-width: 100vw; + } + + .moonfin-settings-header h2 { + font-size: 18px; + } + + .moonfin-settings-footer { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .moonfin-settings-footer-buttons { + justify-content: flex-end; + } +} - .moonfin-settings-header h2 { - font-size: 18px; - } +/* Collection / Playlist Picker */ +.moonfin-mediabar-library-options, +.moonfin-mediabar-collection-options { + margin-bottom: 10px; +} - .moonfin-settings-footer { - flex-direction: column; - align-items: stretch; - gap: 8px; - } +.moonfin-collection-picker { + max-height: 240px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.15) transparent; +} - .moonfin-settings-footer-buttons { - justify-content: flex-end; - } +.moonfin-collection-picker::-webkit-scrollbar { + width: 4px; } + +.moonfin-collection-picker::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; +} + +.moonfin-collection-picker-loading { + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + padding: 12px 0; +} + +.moonfin-collection-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s ease; + margin-bottom: 2px; +} + +.moonfin-collection-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +.moonfin-collection-item-active { + background: rgba(0, 164, 220, 0.1); +} + +.moonfin-collection-item-active:hover { + background: rgba(0, 164, 220, 0.15); +} + +.moonfin-collection-item input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #00a4dc; + cursor: pointer; + flex-shrink: 0; +} + +.moonfin-collection-poster { + width: 40px; + height: 40px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.08); +} + +.moonfin-collection-poster-empty { + display: flex; + align-items: center; + justify-content: center; +} + +.moonfin-collection-info { + flex: 1; + min-width: 0; +} + +.moonfin-collection-name { + font-size: 13px; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.moonfin-collection-type { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + margin-top: 1px; +} \ No newline at end of file diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 5a4d331..b730433 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -1,585 +1,755 @@ const API = { - toCamelCase: function(obj) { - if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj; - var result = {}; - var keys = Object.keys(obj); - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - var camel = key.charAt(0).toLowerCase() + key.slice(1); - result[camel] = obj[key]; - } - return result; - }, - - getApiClient() { - return window.ApiClient || (window.connectionManager && window.connectionManager.currentApiClient()); - }, - - async getCurrentUser() { - const api = this.getApiClient(); - if (!api) return null; - - try { - const user = await api.getCurrentUser(); - return user; - } catch (e) { - console.error('[Moonfin] Failed to get current user:', e); - return null; - } - }, - - async getUserViews() { - const api = this.getApiClient(); - if (!api) return []; - - try { - const userId = api.getCurrentUserId(); - const result = await api.getUserViews(userId); - return result.Items || []; - } catch (e) { - console.error('[Moonfin] Failed to get user views:', e); - return []; - } - }, - - async getRandomItems(options = {}) { - const api = this.getApiClient(); - if (!api) return []; - - const { contentType = 'both', limit = 10 } = options; - - try { - const userId = api.getCurrentUserId(); - - let includeItemTypes = []; - if (contentType === 'movies' || contentType === 'both') { - includeItemTypes.push('Movie'); - } - if (contentType === 'tv' || contentType === 'both') { - includeItemTypes.push('Series'); - } + toCamelCase: function (obj) { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj; + var result = {}; + var keys = Object.keys(obj); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + var camel = key.charAt(0).toLowerCase() + key.slice(1); + result[camel] = obj[key]; + } + return result; + }, + + getApiClient() { + return ( + window.ApiClient || + (window.connectionManager && window.connectionManager.currentApiClient()) + ); + }, + + async getCurrentUser() { + const api = this.getApiClient(); + if (!api) return null; + + try { + const user = await api.getCurrentUser(); + return user; + } catch (e) { + console.error("[Moonfin] Failed to get current user:", e); + return null; + } + }, + + async getUserViews() { + const api = this.getApiClient(); + if (!api) return []; + + try { + const userId = api.getCurrentUserId(); + const result = await api.getUserViews(userId); + return result.Items || []; + } catch (e) { + console.error("[Moonfin] Failed to get user views:", e); + return []; + } + }, + + async getRandomItems(options = {}) { + const api = this.getApiClient(); + if (!api) return []; + + const { contentType = "both", limit = 10 } = options; + + try { + const userId = api.getCurrentUserId(); + + let includeItemTypes = []; + if (contentType === "movies" || contentType === "both") { + includeItemTypes.push("Movie"); + } + if (contentType === "tv" || contentType === "both") { + includeItemTypes.push("Series"); + } + + const params = { + userId: userId, + includeItemTypes: includeItemTypes.join(","), + sortBy: "Random", + limit: limit, + recursive: true, + hasThemeSong: false, + hasThemeVideo: false, + fields: + "Overview,Genres,CommunityRating,CriticRating,OfficialRating,RunTimeTicks,ProductionYear,ProviderIds", + imageTypeLimit: 1, + enableImageTypes: "Backdrop,Logo,Primary", + }; + + const result = await api.getItems(userId, params); + return result.Items || []; + } catch (e) { + console.error("[Moonfin] Failed to get random items:", e); + return []; + } + }, + + async getCollectionsAndPlaylists() { + const api = this.getApiClient(); + if (!api) return []; + + try { + const userId = api.getCurrentUserId(); + const result = await api.getItems(userId, { + userId: userId, + includeItemTypes: "BoxSet,Playlist", + sortBy: "SortName", + sortOrder: "Ascending", + recursive: true, + fields: "PrimaryImageAspectRatio", + imageTypeLimit: 1, + enableImageTypes: "Primary", + }); + return result.Items || []; + } catch (e) { + console.error("[Moonfin] Failed to get collections/playlists:", e); + return []; + } + }, + + async getCollectionItems(collectionIds, options = {}) { + const api = this.getApiClient(); + if (!api || !collectionIds || collectionIds.length === 0) return []; + + const { limit = 10, shuffle = true } = options; + + try { + const userId = api.getCurrentUserId(); + const allItems = []; + const seenIds = {}; + + for (var i = 0; i < collectionIds.length; i++) { + const result = await api.getItems(userId, { + userId: userId, + parentId: collectionIds[i], + sortBy: shuffle ? "Random" : "SortName", + recursive: true, + fields: + "Overview,Genres,CommunityRating,CriticRating,OfficialRating,RunTimeTicks,ProductionYear,ProviderIds", + imageTypeLimit: 1, + enableImageTypes: "Backdrop,Logo,Primary", + }); - const params = { - userId: userId, - includeItemTypes: includeItemTypes.join(','), - sortBy: 'Random', - limit: limit, - recursive: true, - hasThemeSong: false, - hasThemeVideo: false, - fields: 'Overview,Genres,CommunityRating,CriticRating,OfficialRating,RunTimeTicks,ProductionYear,ProviderIds', - imageTypeLimit: 1, - enableImageTypes: 'Backdrop,Logo,Primary' - }; - - const result = await api.getItems(userId, params); - return result.Items || []; - } catch (e) { - console.error('[Moonfin] Failed to get random items:', e); - return []; + var items = result.Items || []; + for (var j = 0; j < items.length; j++) { + if (!seenIds[items[j].Id]) { + seenIds[items[j].Id] = true; + allItems.push(items[j]); + } } - }, - - async getItemTrailers(itemId) { - const api = this.getApiClient(); - if (!api || !itemId) return []; - - try { - const userId = api.getCurrentUserId(); - const result = await api.getItems(userId, { - ids: itemId, - userId: userId, - fields: 'RemoteTrailers', - limit: 1 - }); - const item = result.Items && result.Items[0]; - return (item && item.RemoteTrailers) || []; - } catch (e) { - return []; - } - }, - - getImageUrl(item, imageType = 'Backdrop', options = {}) { - const api = this.getApiClient(); - if (!api || !item) return null; - - const itemId = item.Id; - const { maxWidth = 1920, maxHeight = 1080, quality = 96 } = options; - - if (!item.ImageTags || !item.ImageTags[imageType]) { - // For backdrop, check BackdropImageTags - if (imageType === 'Backdrop' && item.BackdropImageTags && item.BackdropImageTags.length > 0) { - return api.getScaledImageUrl(itemId, { - type: 'Backdrop', - maxWidth, - maxHeight, - quality, - tag: item.BackdropImageTags[0] - }); - } - return null; + } + + // Shuffle merged results if requested + if (shuffle) { + for (var k = allItems.length - 1; k > 0; k--) { + var r = Math.floor(Math.random() * (k + 1)); + var temp = allItems[k]; + allItems[k] = allItems[r]; + allItems[r] = temp; } + } + return allItems.slice(0, limit); + } catch (e) { + console.error("[Moonfin] Failed to get collection items:", e); + return []; + } + }, + + async getItemTrailers(itemId) { + const api = this.getApiClient(); + if (!api || !itemId) return []; + + try { + const userId = api.getCurrentUserId(); + const result = await api.getItems(userId, { + ids: itemId, + userId: userId, + fields: "RemoteTrailers", + limit: 1, + }); + const item = result.Items && result.Items[0]; + return (item && item.RemoteTrailers) || []; + } catch (e) { + return []; + } + }, + + getImageUrl(item, imageType = "Backdrop", options = {}) { + const api = this.getApiClient(); + if (!api || !item) return null; + + const itemId = item.Id; + const { maxWidth = 1920, maxHeight = 1080, quality = 96 } = options; + + if (!item.ImageTags || !item.ImageTags[imageType]) { + // For backdrop, check BackdropImageTags + if ( + imageType === "Backdrop" && + item.BackdropImageTags && + item.BackdropImageTags.length > 0 + ) { return api.getScaledImageUrl(itemId, { - type: imageType, - maxWidth, - maxHeight, - quality, - tag: item.ImageTags[imageType] + type: "Backdrop", + maxWidth, + maxHeight, + quality, + tag: item.BackdropImageTags[0], }); - }, + } + return null; + } - getUserAvatarUrl(user) { - const api = this.getApiClient(); - if (!api || !user) return null; + return api.getScaledImageUrl(itemId, { + type: imageType, + maxWidth, + maxHeight, + quality, + tag: item.ImageTags[imageType], + }); + }, + + getUserAvatarUrl(user) { + const api = this.getApiClient(); + if (!api || !user) return null; + + if (user.PrimaryImageTag) { + return api.getUserImageUrl(user.Id, { + type: "Primary", + tag: user.PrimaryImageTag, + }); + } + return null; + }, + + navigateToItem(itemId) { + if (window.Emby && window.Emby.Page) { + window.Emby.Page.show("/details?id=" + itemId); + } else if (window.appRouter) { + window.appRouter.show("/details?id=" + itemId); + } + }, - if (user.PrimaryImageTag) { - return api.getUserImageUrl(user.Id, { - type: 'Primary', - tag: user.PrimaryImageTag - }); - } - return null; - }, - - navigateToItem(itemId) { - if (window.Emby && window.Emby.Page) { - window.Emby.Page.show('/details?id=' + itemId); - } else if (window.appRouter) { - window.appRouter.show('/details?id=' + itemId); + navigateTo(path) { + if (window.Emby && window.Emby.Page) { + window.Emby.Page.show(path); + } else if (window.appRouter) { + window.appRouter.show(path); + } + }, + + async getGenres(parentId) { + var api = this.getApiClient(); + if (!api) return []; + + try { + var userId = api.getCurrentUserId(); + var params = { + userId: userId, + includeItemTypes: "Movie,Series", + sortBy: "SortName", + sortOrder: "Ascending", + recursive: true, + enableTotalRecordCount: true, + }; + if (parentId) { + params.parentId = parentId; + } + var result = await api.getGenres(userId, params); + return result.Items || []; + } catch (e) { + console.error("[Moonfin] Failed to get genres:", e); + return []; + } + }, + + async getGenreItems(genreName, options) { + var api = this.getApiClient(); + if (!api) return { Items: [], TotalRecordCount: 0 }; + + try { + var userId = api.getCurrentUserId(); + var params = { + userId: userId, + genres: genreName, + includeItemTypes: options.includeItemTypes || "Movie,Series", + sortBy: options.sortBy || "SortName", + sortOrder: options.sortOrder || "Ascending", + recursive: true, + startIndex: options.startIndex || 0, + limit: options.limit || 100, + enableTotalRecordCount: true, + fields: + "PrimaryImageAspectRatio,ProductionYear,CommunityRating,OfficialRating,RunTimeTicks,Overview,Genres", + imageTypeLimit: 1, + enableImageTypes: "Primary,Backdrop", + }; + if (options.parentId) { + params.parentId = options.parentId; + } + if (options.nameStartsWith) { + params.nameStartsWith = options.nameStartsWith; + } + if (options.nameLessThan) { + params.nameLessThan = options.nameLessThan; + } + var result = await api.getItems(userId, params); + return result; + } catch (e) { + console.error("[Moonfin] Failed to get genre items:", e); + return { Items: [], TotalRecordCount: 0 }; + } + }, + + async getLibraryItems(parentId, options) { + var api = this.getApiClient(); + if (!api) return { Items: [], TotalRecordCount: 0 }; + + try { + var userId = api.getCurrentUserId(); + var params = { + userId: userId, + parentId: parentId, + includeItemTypes: options.includeItemTypes || "Movie,Series", + sortBy: options.sortBy || "SortName", + sortOrder: options.sortOrder || "Ascending", + recursive: true, + startIndex: options.startIndex || 0, + limit: options.limit || 100, + enableTotalRecordCount: true, + fields: + "PrimaryImageAspectRatio,ProductionYear,CommunityRating,OfficialRating,RunTimeTicks,Overview,Genres", + imageTypeLimit: 1, + enableImageTypes: "Primary,Backdrop", + }; + if (options.nameStartsWith) { + params.nameStartsWith = options.nameStartsWith; + } + if (options.nameLessThan) { + params.nameLessThan = options.nameLessThan; + } + var result = await api.getItems(userId, params); + return result; + } catch (e) { + console.error("[Moonfin] Failed to get library items:", e); + return { Items: [], TotalRecordCount: 0 }; + } + }, + + getPrimaryImageUrl(item, options) { + var api = this.getApiClient(); + if (!api || !item) return null; + + var opts = options || {}; + var maxWidth = opts.maxWidth || 300; + var quality = opts.quality || 90; + + if (item.ImageTags && item.ImageTags.Primary) { + return api.getScaledImageUrl(item.Id, { + type: "Primary", + maxWidth: maxWidth, + quality: quality, + tag: item.ImageTags.Primary, + }); + } + return null; + }, + + getBackdropUrl(item, options) { + var api = this.getApiClient(); + if (!api || !item) return null; + + var opts = options || {}; + var maxWidth = opts.maxWidth || 780; + var quality = opts.quality || 80; + + if (item.BackdropImageTags && item.BackdropImageTags.length > 0) { + return api.getScaledImageUrl(item.Id, { + type: "Backdrop", + maxWidth: maxWidth, + quality: quality, + tag: item.BackdropImageTags[0], + }); + } + // Fallback for items without their own backdrop + if ( + item.ParentBackdropItemId && + item.ParentBackdropImageTags && + item.ParentBackdropImageTags.length > 0 + ) { + return api.getScaledImageUrl(item.ParentBackdropItemId, { + type: "Backdrop", + maxWidth: maxWidth, + quality: quality, + tag: item.ParentBackdropImageTags[0], + }); + } + return null; + }, + + // ===== Webpack Interop for Jellyfin Web Editor Dialogs ===== + // Editor modules (metadataEditor, imageeditor, subtitleeditor, itemidentifier) are in + // separate lazy-loaded webpack chunks. We capture __webpack_require__ via the chunk push + // trick, find the shortcuts.js + itemContextMenu.js modules by their unique strings, + // extract the chunk IDs from their compiled source, load those chunks, then find the + // editor modules by their export shapes. + + getServerId: function () { + var api = this.getApiClient(); + if (!api) return null; + try { + if (api.serverInfo && typeof api.serverInfo === "function") + return api.serverInfo().Id; + if (api._serverInfo) return api._serverInfo.Id; + if (api.serverId && typeof api.serverId === "function") + return api.serverId(); + } catch (e) {} + return null; + }, + + _initWebpackRequire: function () { + if (window.__moonfin_wp_require) return true; + // Auto-detect: the compiled global varies by build config + // Production jellyfin-web uses just 'webpackChunk' (no suffix) + var chunkArr = + window.webpackChunk || + window.webpackChunkjellyfin_web || + window["webpackChunk_jellyfin_web"]; + if (!chunkArr) { + // Scan window for any webpackChunk* array + var keys = Object.keys(window); + for (var i = 0; i < keys.length; i++) { + if ( + keys[i].indexOf("webpackChunk") === 0 && + Array.isArray(window[keys[i]]) + ) { + chunkArr = window[keys[i]]; + break; } - }, + } + } + if (!chunkArr) { + console.warn("[Moonfin] No webpackChunk array found"); + return false; + } + chunkArr.push([ + ["moonfin"], + {}, + function (__webpack_require__) { + window.__moonfin_wp_require = __webpack_require__; + }, + ]); + return !!window.__moonfin_wp_require; + }, + + _extractChunkIds: function (factorySource) { + var ids = []; + // Match numeric chunk IDs: .e(12345) + var regex = /\.e\((\d+)\)/g; + var match; + while ((match = regex.exec(factorySource)) !== null) { + var id = parseInt(match[1]); + if (ids.indexOf(id) === -1) ids.push(id); + } + // Match string chunk IDs: .e("chunk-name") + regex = /\.e\("([^"]+)"\)/g; + while ((match = regex.exec(factorySource)) !== null) { + if (ids.indexOf(match[1]) === -1) ids.push(match[1]); + } + return ids; + }, - navigateTo(path) { - if (window.Emby && window.Emby.Page) { - window.Emby.Page.show(path); - } else if (window.appRouter) { - window.appRouter.show(path); - } - }, - - async getGenres(parentId) { - var api = this.getApiClient(); - if (!api) return []; - - try { - var userId = api.getCurrentUserId(); - var params = { - userId: userId, - includeItemTypes: 'Movie,Series', - sortBy: 'SortName', - sortOrder: 'Ascending', - recursive: true, - enableTotalRecordCount: true - }; - if (parentId) { - params.parentId = parentId; - } - var result = await api.getGenres(userId, params); - return result.Items || []; - } catch (e) { - console.error('[Moonfin] Failed to get genres:', e); - return []; - } - }, - - async getGenreItems(genreName, options) { - var api = this.getApiClient(); - if (!api) return { Items: [], TotalRecordCount: 0 }; - - try { - var userId = api.getCurrentUserId(); - var params = { - userId: userId, - genres: genreName, - includeItemTypes: options.includeItemTypes || 'Movie,Series', - sortBy: options.sortBy || 'SortName', - sortOrder: options.sortOrder || 'Ascending', - recursive: true, - startIndex: options.startIndex || 0, - limit: options.limit || 100, - enableTotalRecordCount: true, - fields: 'PrimaryImageAspectRatio,ProductionYear,CommunityRating,OfficialRating,RunTimeTicks,Overview,Genres', - imageTypeLimit: 1, - enableImageTypes: 'Primary,Backdrop' - }; - if (options.parentId) { - params.parentId = options.parentId; - } - if (options.nameStartsWith) { - params.nameStartsWith = options.nameStartsWith; - } - if (options.nameLessThan) { - params.nameLessThan = options.nameLessThan; - } - var result = await api.getItems(userId, params); - return result; - } catch (e) { - console.error('[Moonfin] Failed to get genre items:', e); - return { Items: [], TotalRecordCount: 0 }; - } - }, - - async getLibraryItems(parentId, options) { - var api = this.getApiClient(); - if (!api) return { Items: [], TotalRecordCount: 0 }; - - try { - var userId = api.getCurrentUserId(); - var params = { - userId: userId, - parentId: parentId, - includeItemTypes: options.includeItemTypes || 'Movie,Series', - sortBy: options.sortBy || 'SortName', - sortOrder: options.sortOrder || 'Ascending', - recursive: true, - startIndex: options.startIndex || 0, - limit: options.limit || 100, - enableTotalRecordCount: true, - fields: 'PrimaryImageAspectRatio,ProductionYear,CommunityRating,OfficialRating,RunTimeTicks,Overview,Genres', - imageTypeLimit: 1, - enableImageTypes: 'Primary,Backdrop' - }; - if (options.nameStartsWith) { - params.nameStartsWith = options.nameStartsWith; - } - if (options.nameLessThan) { - params.nameLessThan = options.nameLessThan; - } - var result = await api.getItems(userId, params); - return result; - } catch (e) { - console.error('[Moonfin] Failed to get library items:', e); - return { Items: [], TotalRecordCount: 0 }; - } - }, - - getPrimaryImageUrl(item, options) { - var api = this.getApiClient(); - if (!api || !item) return null; - - var opts = options || {}; - var maxWidth = opts.maxWidth || 300; - var quality = opts.quality || 90; - - if (item.ImageTags && item.ImageTags.Primary) { - return api.getScaledImageUrl(item.Id, { - type: 'Primary', - maxWidth: maxWidth, - quality: quality, - tag: item.ImageTags.Primary - }); - } - return null; - }, - - getBackdropUrl(item, options) { - var api = this.getApiClient(); - if (!api || !item) return null; - - var opts = options || {}; - var maxWidth = opts.maxWidth || 780; - var quality = opts.quality || 80; - - if (item.BackdropImageTags && item.BackdropImageTags.length > 0) { - return api.getScaledImageUrl(item.Id, { - type: 'Backdrop', - maxWidth: maxWidth, - quality: quality, - tag: item.BackdropImageTags[0] - }); - } - // Fallback for items without their own backdrop - if (item.ParentBackdropItemId && item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) { - return api.getScaledImageUrl(item.ParentBackdropItemId, { - type: 'Backdrop', - maxWidth: maxWidth, - quality: quality, - tag: item.ParentBackdropImageTags[0] - }); - } - return null; - }, - - // ===== Webpack Interop for Jellyfin Web Editor Dialogs ===== - // Editor modules (metadataEditor, imageeditor, subtitleeditor, itemidentifier) are in - // separate lazy-loaded webpack chunks. We capture __webpack_require__ via the chunk push - // trick, find the shortcuts.js + itemContextMenu.js modules by their unique strings, - // extract the chunk IDs from their compiled source, load those chunks, then find the - // editor modules by their export shapes. - - getServerId: function() { - var api = this.getApiClient(); - if (!api) return null; - try { - if (api.serverInfo && typeof api.serverInfo === 'function') return api.serverInfo().Id; - if (api._serverInfo) return api._serverInfo.Id; - if (api.serverId && typeof api.serverId === 'function') return api.serverId(); - } catch(e) {} - return null; - }, - - _initWebpackRequire: function() { - if (window.__moonfin_wp_require) return true; - // Auto-detect: the compiled global varies by build config - // Production jellyfin-web uses just 'webpackChunk' (no suffix) - var chunkArr = window.webpackChunk - || window.webpackChunkjellyfin_web - || window['webpackChunk_jellyfin_web']; - if (!chunkArr) { - // Scan window for any webpackChunk* array - var keys = Object.keys(window); - for (var i = 0; i < keys.length; i++) { - if (keys[i].indexOf('webpackChunk') === 0 && Array.isArray(window[keys[i]])) { - chunkArr = window[keys[i]]; - break; - } - } - } - if (!chunkArr) { - console.warn('[Moonfin] No webpackChunk array found'); - return false; - } - chunkArr.push([['moonfin'], {}, function(__webpack_require__) { - window.__moonfin_wp_require = __webpack_require__; - }]); - return !!window.__moonfin_wp_require; - }, - - _extractChunkIds: function(factorySource) { - var ids = []; - // Match numeric chunk IDs: .e(12345) - var regex = /\.e\((\d+)\)/g; - var match; - while ((match = regex.exec(factorySource)) !== null) { - var id = parseInt(match[1]); - if (ids.indexOf(id) === -1) ids.push(id); + _loadChunks: function (req, chunkIds) { + var loads = []; + for (var i = 0; i < chunkIds.length; i++) { + loads.push(req.e(chunkIds[i]).catch(function () {})); + } + return Promise.all(loads); + }, + + _findFactoryByHint: function (req, hint) { + var factories = req.m || {}; + var keys = Object.keys(factories); + for (var i = 0; i < keys.length; i++) { + try { + if (factories[keys[i]].toString().indexOf(hint) !== -1) { + return keys[i]; } - // Match string chunk IDs: .e("chunk-name") - regex = /\.e\("([^"]+)"\)/g; - while ((match = regex.exec(factorySource)) !== null) { - if (ids.indexOf(match[1]) === -1) ids.push(match[1]); + } catch (e) {} + } + return null; + }, + + _findFactoryByHints: function (req, hints) { + var factories = req.m || {}; + var keys = Object.keys(factories); + for (var i = 0; i < keys.length; i++) { + try { + var src = factories[keys[i]].toString(); + var allMatch = true; + for (var h = 0; h < hints.length; h++) { + if (src.indexOf(hints[h]) === -1) { + allMatch = false; + break; + } } - return ids; - }, + if (allMatch) return keys[i]; + } catch (e) {} + } + return null; + }, + + _editorModulesPromise: null, + + _loadEditorModules: function () { + if (this._editorModulesPromise) return this._editorModulesPromise; + if ( + window.__moonfin_editors && + Object.keys(window.__moonfin_editors).length >= 4 + ) { + return Promise.resolve(true); + } - _loadChunks: function(req, chunkIds) { - var loads = []; - for (var i = 0; i < chunkIds.length; i++) { - loads.push(req.e(chunkIds[i]).catch(function() {})); - } - return Promise.all(loads); - }, - - _findFactoryByHint: function(req, hint) { - var factories = req.m || {}; - var keys = Object.keys(factories); - for (var i = 0; i < keys.length; i++) { - try { - if (factories[keys[i]].toString().indexOf(hint) !== -1) { - return keys[i]; + var apiSelf = this; + this._editorModulesPromise = new Promise(function (resolve) { + if (!apiSelf._initWebpackRequire()) { + resolve(false); + return; + } + + var req = window.__moonfin_wp_require; + + // Step 1: Find shortcuts.js module by its unique 'playAllFromHere' export + var shortcutsId = apiSelf._findFactoryByHint(req, "playAllFromHere"); + if (!shortcutsId) { + console.warn( + "[Moonfin] Could not find shortcuts module in webpack factories", + ); + resolve(false); + return; + } + + // Step 2: Extract chunk IDs from shortcuts factory and load them + var shortcutChunkIds = apiSelf._extractChunkIds( + req.m[shortcutsId].toString(), + ); + console.log("[Moonfin] Loading shortcuts chunks:", shortcutChunkIds); + + apiSelf + ._loadChunks(req, shortcutChunkIds) + .then(function () { + // Step 3: Find itemContextMenu module by its unique command strings + var icmId = apiSelf._findFactoryByHints(req, [ + "editimages", + "editsubtitles", + "identify", + ]); + if (icmId) { + // Step 4: Extract and load itemContextMenu's chunks (editor modules) + var icmChunkIds = apiSelf._extractChunkIds(req.m[icmId].toString()); + console.log( + "[Moonfin] Loading itemContextMenu chunks:", + icmChunkIds, + ); + return apiSelf._loadChunks(req, icmChunkIds); + } + }) + .then(function () { + // Step 5: All editor chunks loaded. Find editor modules by export shape. + var editors = {}; + var factories = req.m; + var cache = req.c || {}; + var fkeys = Object.keys(factories); + + // Source hints to narrow down which factories to try instantiating + var editorHints = [ + "editItemMetadataForm", + "MessageItemSaved", + "imageType", + "hasChanges", + "subtitleList", + "btnOpenUploadMenu", + "showFindNew", + "identifyResults", + ]; + + for (var i = 0; i < fkeys.length; i++) { + var id = fkeys[i]; + var mod; + + // Check cache first (no side effects) + if (cache[id]) { + mod = cache[id].exports; + } else { + // Only try modules whose factory contains editor-related strings + var src; + try { + src = factories[id].toString(); + } catch (e) { + continue; + } + var isEditor = false; + for (var h = 0; h < editorHints.length; h++) { + if (src.indexOf(editorHints[h]) !== -1) { + isEditor = true; + break; } - } catch(e) {} - } - return null; - }, - - _findFactoryByHints: function(req, hints) { - var factories = req.m || {}; - var keys = Object.keys(factories); - for (var i = 0; i < keys.length; i++) { - try { - var src = factories[keys[i]].toString(); - var allMatch = true; - for (var h = 0; h < hints.length; h++) { - if (src.indexOf(hints[h]) === -1) { allMatch = false; break; } - } - if (allMatch) return keys[i]; - } catch(e) {} - } - return null; - }, - - _editorModulesPromise: null, - - _loadEditorModules: function() { - if (this._editorModulesPromise) return this._editorModulesPromise; - if (window.__moonfin_editors && Object.keys(window.__moonfin_editors).length >= 4) { - return Promise.resolve(true); - } - - var apiSelf = this; - this._editorModulesPromise = new Promise(function(resolve) { - if (!apiSelf._initWebpackRequire()) { - resolve(false); - return; - } - - var req = window.__moonfin_wp_require; - - // Step 1: Find shortcuts.js module by its unique 'playAllFromHere' export - var shortcutsId = apiSelf._findFactoryByHint(req, 'playAllFromHere'); - if (!shortcutsId) { - console.warn('[Moonfin] Could not find shortcuts module in webpack factories'); - resolve(false); - return; + } + if (!isEditor) continue; + + try { + mod = req(id); + } catch (e) { + continue; + } } - // Step 2: Extract chunk IDs from shortcuts factory and load them - var shortcutChunkIds = apiSelf._extractChunkIds(req.m[shortcutsId].toString()); - console.log('[Moonfin] Loading shortcuts chunks:', shortcutChunkIds); - - apiSelf._loadChunks(req, shortcutChunkIds).then(function() { - // Step 3: Find itemContextMenu module by its unique command strings - var icmId = apiSelf._findFactoryByHints(req, ['editimages', 'editsubtitles', 'identify']); - if (icmId) { - // Step 4: Extract and load itemContextMenu's chunks (editor modules) - var icmChunkIds = apiSelf._extractChunkIds(req.m[icmId].toString()); - console.log('[Moonfin] Loading itemContextMenu chunks:', icmChunkIds); - return apiSelf._loadChunks(req, icmChunkIds); - } - }).then(function() { - // Step 5: All editor chunks loaded. Find editor modules by export shape. - var editors = {}; - var factories = req.m; - var cache = req.c || {}; - var fkeys = Object.keys(factories); - - // Source hints to narrow down which factories to try instantiating - var editorHints = [ - 'editItemMetadataForm', 'MessageItemSaved', - 'imageType', 'hasChanges', - 'subtitleList', 'btnOpenUploadMenu', - 'showFindNew', 'identifyResults' - ]; - - for (var i = 0; i < fkeys.length; i++) { - var id = fkeys[i]; - var mod; - - // Check cache first (no side effects) - if (cache[id]) { - mod = cache[id].exports; - } else { - // Only try modules whose factory contains editor-related strings - var src; - try { src = factories[id].toString(); } catch(e) { continue; } - var isEditor = false; - for (var h = 0; h < editorHints.length; h++) { - if (src.indexOf(editorHints[h]) !== -1) { isEditor = true; break; } - } - if (!isEditor) continue; - - try { mod = req(id); } catch(e) { continue; } - } - - if (!mod) continue; - apiSelf._matchEditorExports(mod, editors); - - if (editors.metadata && editors.identifier && editors.image && editors.subtitle) break; - } - - window.__moonfin_editors = editors; - var found = Object.keys(editors); - console.log('[Moonfin] Found editor modules:', found.join(', ') || 'none'); - resolve(found.length > 0); - }).catch(function(e) { - console.error('[Moonfin] Error loading editor chunks:', e); - resolve(false); - }); + if (!mod) continue; + apiSelf._matchEditorExports(mod, editors); + + if ( + editors.metadata && + editors.identifier && + editors.image && + editors.subtitle + ) + break; + } + + window.__moonfin_editors = editors; + var found = Object.keys(editors); + console.log( + "[Moonfin] Found editor modules:", + found.join(", ") || "none", + ); + resolve(found.length > 0); + }) + .catch(function (e) { + console.error("[Moonfin] Error loading editor chunks:", e); + resolve(false); }); + }); + + // Allow retry on failure + this._editorModulesPromise.then(function (success) { + if (!success) apiSelf._editorModulesPromise = null; + }); + + return this._editorModulesPromise; + }, + + _matchEditorExports: function (mod, editors) { + if (!mod) return; + var d = mod.default || mod; + + // metadataEditor: default export with show() + embed() — unique shape + if ( + !editors.metadata && + d && + typeof d.show === "function" && + typeof d.embed === "function" + ) { + editors.metadata = d; + return; + } - // Allow retry on failure - this._editorModulesPromise.then(function(success) { - if (!success) apiSelf._editorModulesPromise = null; - }); - - return this._editorModulesPromise; - }, - - _matchEditorExports: function(mod, editors) { - if (!mod) return; - var d = mod.default || mod; - - // metadataEditor: default export with show() + embed() — unique shape - if (!editors.metadata && d && typeof d.show === 'function' && typeof d.embed === 'function') { - editors.metadata = d; - return; - } - - // itemIdentifier: named exports show() + showFindNew() — unique shape - if (!editors.identifier && typeof mod.show === 'function' && typeof mod.showFindNew === 'function') { - editors.identifier = mod; - return; - } + // itemIdentifier: named exports show() + showFindNew() — unique shape + if ( + !editors.identifier && + typeof mod.show === "function" && + typeof mod.showFindNew === "function" + ) { + editors.identifier = mod; + return; + } - // imageeditor: named export show(options), length <= 1, no showFindNew/embed - if (!editors.image && typeof mod.show === 'function' && !mod.showFindNew && - !(d && typeof d.embed === 'function') && mod.show.length <= 1 && - mod !== (editors.identifier || null)) { - editors.image = mod; - return; - } + // imageeditor: named export show(options), length <= 1, no showFindNew/embed + if ( + !editors.image && + typeof mod.show === "function" && + !mod.showFindNew && + !(d && typeof d.embed === "function") && + mod.show.length <= 1 && + mod !== (editors.identifier || null) + ) { + editors.image = mod; + return; + } - // subtitleeditor: default export with show(itemId, serverId), no embed - if (!editors.subtitle && d && d !== mod && typeof d.show === 'function' && - !d.embed && d.show.length >= 2) { - editors.subtitle = d; - return; - } - }, - - openMetadataEditor: function(itemId) { - var serverId = this.getServerId(); - return this._loadEditorModules().then(function(loaded) { - var ed = window.__moonfin_editors && window.__moonfin_editors.metadata; - if (!ed) return false; - ed.show(itemId, serverId); - return true; - }).catch(function(e) { - console.warn('[Moonfin] Failed to open metadata editor:', e); - return false; - }); - }, - - openImageEditor: function(itemId) { - var serverId = this.getServerId(); - return this._loadEditorModules().then(function(loaded) { - var ed = window.__moonfin_editors && window.__moonfin_editors.image; - if (!ed) return false; - var showFn = ed.show || (ed.default && ed.default.show); - if (!showFn) return false; - showFn({ itemId: itemId, serverId: serverId }); - return true; - }).catch(function(e) { - console.warn('[Moonfin] Failed to open image editor:', e); - return false; - }); - }, - - openSubtitleEditor: function(itemId) { - var serverId = this.getServerId(); - return this._loadEditorModules().then(function(loaded) { - var ed = window.__moonfin_editors && window.__moonfin_editors.subtitle; - if (!ed) return false; - ed.show(itemId, serverId); - return true; - }).catch(function(e) { - console.warn('[Moonfin] Failed to open subtitle editor:', e); - return false; - }); - }, - - openItemIdentifier: function(itemId) { - var serverId = this.getServerId(); - return this._loadEditorModules().then(function(loaded) { - var ed = window.__moonfin_editors && window.__moonfin_editors.identifier; - if (!ed) return false; - ed.show(itemId, serverId); - return true; - }).catch(function(e) { - console.warn('[Moonfin] Failed to open item identifier:', e); - return false; - }); + // subtitleeditor: default export with show(itemId, serverId), no embed + if ( + !editors.subtitle && + d && + d !== mod && + typeof d.show === "function" && + !d.embed && + d.show.length >= 2 + ) { + editors.subtitle = d; + return; } + }, + + openMetadataEditor: function (itemId) { + var serverId = this.getServerId(); + return this._loadEditorModules() + .then(function (loaded) { + var ed = window.__moonfin_editors && window.__moonfin_editors.metadata; + if (!ed) return false; + ed.show(itemId, serverId); + return true; + }) + .catch(function (e) { + console.warn("[Moonfin] Failed to open metadata editor:", e); + return false; + }); + }, + + openImageEditor: function (itemId) { + var serverId = this.getServerId(); + return this._loadEditorModules() + .then(function (loaded) { + var ed = window.__moonfin_editors && window.__moonfin_editors.image; + if (!ed) return false; + var showFn = ed.show || (ed.default && ed.default.show); + if (!showFn) return false; + showFn({ itemId: itemId, serverId: serverId }); + return true; + }) + .catch(function (e) { + console.warn("[Moonfin] Failed to open image editor:", e); + return false; + }); + }, + + openSubtitleEditor: function (itemId) { + var serverId = this.getServerId(); + return this._loadEditorModules() + .then(function (loaded) { + var ed = window.__moonfin_editors && window.__moonfin_editors.subtitle; + if (!ed) return false; + ed.show(itemId, serverId); + return true; + }) + .catch(function (e) { + console.warn("[Moonfin] Failed to open subtitle editor:", e); + return false; + }); + }, + + openItemIdentifier: function (itemId) { + var serverId = this.getServerId(); + return this._loadEditorModules() + .then(function (loaded) { + var ed = + window.__moonfin_editors && window.__moonfin_editors.identifier; + if (!ed) return false; + ed.show(itemId, serverId); + return true; + }) + .catch(function (e) { + console.warn("[Moonfin] Failed to open item identifier:", e); + return false; + }); + }, }; diff --git a/frontend/src/utils/storage.js b/frontend/src/utils/storage.js index 5e099e9..a008fbc 100644 --- a/frontend/src/utils/storage.js +++ b/frontend/src/utils/storage.js @@ -1,730 +1,795 @@ const Storage = { - STORAGE_KEY: 'moonfin_settings', - PROFILES_KEY: 'moonfin_profiles', - SNAPSHOT_KEY: 'moonfin_sync_snapshot', - SYNC_PREF_KEY: 'moonfin_sync_enabled', - CLIENT_ID: 'moonfin-web', - - syncState: { - serverAvailable: null, - lastSyncTime: null, - lastSyncError: null, - syncing: false, - mdblistAvailable: false, - tmdbAvailable: false, - adminDefaults: null - }, - - defaults: { - navbarEnabled: false, - detailsPageEnabled: false, - - mediaBarEnabled: false, - mediaBarContentType: 'both', - mediaBarItemCount: 10, - mediaBarOverlayOpacity: 50, - mediaBarOverlayColor: 'gray', - mediaBarAutoAdvance: true, - mediaBarIntervalMs: 7000, - mediaBarTrailerPreview: true, - - showShuffleButton: true, - showGenresButton: true, - showFavoritesButton: true, - showCastButton: true, - showSyncPlayButton: true, - showLibrariesInToolbar: true, - shuffleContentType: 'both', - - seasonalSurprise: 'none', - backdropEnabled: true, - confirmExit: true, - - navbarPosition: 'top', - showClock: true, - use24HourClock: false, - - mdblistEnabled: false, - mdblistApiKey: '', - mdblistRatingSources: ['imdb', 'tmdb', 'tomatoes', 'metacritic'], - - tmdbApiKey: '', - tmdbEpisodeRatingsEnabled: false - }, - - colorOptions: { - 'gray': { name: 'Gray', hex: '#808080' }, - 'black': { name: 'Black', hex: '#000000' }, - 'dark_blue': { name: 'Dark Blue', hex: '#1A2332' }, - 'purple': { name: 'Purple', hex: '#4A148C' }, - 'teal': { name: 'Teal', hex: '#00695C' }, - 'navy': { name: 'Navy', hex: '#0D1B2A' }, - 'charcoal': { name: 'Charcoal', hex: '#36454F' }, - 'brown': { name: 'Brown', hex: '#3E2723' }, - 'dark_red': { name: 'Dark Red', hex: '#8B0000' }, - 'dark_green': { name: 'Dark Green', hex: '#0B4F0F' }, - 'slate': { name: 'Slate', hex: '#475569' }, - 'indigo': { name: 'Indigo', hex: '#1E3A8A' } - }, - - seasonalOptions: { - 'none': { name: 'None' }, - 'winter': { name: 'Winter' }, - 'spring': { name: 'Spring' }, - 'summer': { name: 'Summer' }, - 'fall': { name: 'Fall' }, - 'halloween': { name: 'Halloween' } - }, - - // ─── Profile Storage ──────────────────────────────────────────── - - getProfiles() { - try { - const stored = localStorage.getItem(this.PROFILES_KEY); - if (stored) { - return JSON.parse(stored); - } - } catch (e) { - console.error('[Moonfin] Failed to read profiles:', e); - } - return {}; - }, - - getProfile(profileName) { - const profiles = this.getProfiles(); - return profiles[profileName] || {}; - }, - - saveProfile(profileName, settings, syncToServer = true) { - try { - const profiles = this.getProfiles(); - profiles[profileName] = settings; - localStorage.setItem(this.PROFILES_KEY, JSON.stringify(profiles)); - - // Dispatch change event with resolved settings for current device - const resolved = this.getAll(); - window.dispatchEvent(new CustomEvent('moonfin-settings-changed', { detail: resolved })); - - if (syncToServer && this.syncState.serverAvailable && this.isSyncEnabled()) { - this.saveProfileToServer(profileName, settings); - } - } catch (e) { - console.error('[Moonfin] Failed to save profile:', e); - } - }, + STORAGE_KEY: "moonfin_settings", + PROFILES_KEY: "moonfin_profiles", + SNAPSHOT_KEY: "moonfin_sync_snapshot", + SYNC_PREF_KEY: "moonfin_sync_enabled", + CLIENT_ID: "moonfin-web", + + syncState: { + serverAvailable: null, + lastSyncTime: null, + lastSyncError: null, + syncing: false, + mdblistAvailable: false, + tmdbAvailable: false, + adminDefaults: null, + }, + + defaults: { + navbarEnabled: false, + detailsPageEnabled: false, + + mediaBarEnabled: false, + mediaBarContentType: "both", + mediaBarItemCount: 10, + mediaBarOverlayOpacity: 50, + mediaBarOverlayColor: "gray", + mediaBarAutoAdvance: true, + mediaBarIntervalMs: 7000, + mediaBarTrailerPreview: true, + mediaBarSourceType: "library", + mediaBarCollectionIds: [], + mediaBarShuffleItems: true, + + showShuffleButton: true, + showGenresButton: true, + showFavoritesButton: true, + showCastButton: true, + showSyncPlayButton: true, + showLibrariesInToolbar: true, + shuffleContentType: "both", + + seasonalSurprise: "none", + backdropEnabled: true, + confirmExit: true, + + navbarPosition: "top", + showClock: true, + use24HourClock: false, + + mdblistEnabled: false, + mdblistApiKey: "", + mdblistRatingSources: ["imdb", "tmdb", "tomatoes", "metacritic"], + + tmdbApiKey: "", + tmdbEpisodeRatingsEnabled: false, + }, + + colorOptions: { + gray: { name: "Gray", hex: "#808080" }, + black: { name: "Black", hex: "#000000" }, + dark_blue: { name: "Dark Blue", hex: "#1A2332" }, + purple: { name: "Purple", hex: "#4A148C" }, + teal: { name: "Teal", hex: "#00695C" }, + navy: { name: "Navy", hex: "#0D1B2A" }, + charcoal: { name: "Charcoal", hex: "#36454F" }, + brown: { name: "Brown", hex: "#3E2723" }, + dark_red: { name: "Dark Red", hex: "#8B0000" }, + dark_green: { name: "Dark Green", hex: "#0B4F0F" }, + slate: { name: "Slate", hex: "#475569" }, + indigo: { name: "Indigo", hex: "#1E3A8A" }, + }, + + seasonalOptions: { + none: { name: "None" }, + winter: { name: "Winter" }, + spring: { name: "Spring" }, + summer: { name: "Summer" }, + fall: { name: "Fall" }, + halloween: { name: "Halloween" }, + }, + + // ─── Profile Storage ──────────────────────────────────────────── + + getProfiles() { + try { + const stored = localStorage.getItem(this.PROFILES_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch (e) { + console.error("[Moonfin] Failed to read profiles:", e); + } + return {}; + }, + + getProfile(profileName) { + const profiles = this.getProfiles(); + return profiles[profileName] || {}; + }, + + saveProfile(profileName, settings, syncToServer = true) { + try { + const profiles = this.getProfiles(); + profiles[profileName] = settings; + localStorage.setItem(this.PROFILES_KEY, JSON.stringify(profiles)); + + // Dispatch change event with resolved settings for current device + const resolved = this.getAll(); + window.dispatchEvent( + new CustomEvent("moonfin-settings-changed", { detail: resolved }), + ); + + if ( + syncToServer && + this.syncState.serverAvailable && + this.isSyncEnabled() + ) { + this.saveProfileToServer(profileName, settings); + } + } catch (e) { + console.error("[Moonfin] Failed to save profile:", e); + } + }, - deleteProfile(profileName) { - if (profileName === 'global') return; - const profiles = this.getProfiles(); - delete profiles[profileName]; - localStorage.setItem(this.PROFILES_KEY, JSON.stringify(profiles)); + deleteProfile(profileName) { + if (profileName === "global") return; + const profiles = this.getProfiles(); + delete profiles[profileName]; + localStorage.setItem(this.PROFILES_KEY, JSON.stringify(profiles)); - if (this.syncState.serverAvailable && this.isSyncEnabled()) { - this.deleteProfileFromServer(profileName); - } - }, - - // ─── Resolution Chain ─────────────────────────────────────────── - - /** - * Gets resolved flat settings for the current device. - * Resolution: device profile → global → admin defaults → built-in defaults. - */ - getAll(profileOverride) { - const deviceProfile = profileOverride || Device.getProfileName(); - return this.resolveSettings(deviceProfile); - }, - - resolveSettings(profileName) { - const profiles = this.getProfiles(); - const global = profiles.global || {}; - const device = (profileName !== 'global') ? (profiles[profileName] || {}) : {}; - const adminDefaults = this.syncState.adminDefaults || {}; - - const resolved = {}; - const allKeys = Object.keys(this.defaults); - - for (const key of allKeys) { - // Resolution chain: device → global → admin defaults → built-in - if (device[key] !== undefined && device[key] !== null) { - resolved[key] = device[key]; - } else if (global[key] !== undefined && global[key] !== null) { - resolved[key] = global[key]; - } else if (adminDefaults[key] !== undefined && adminDefaults[key] !== null) { - resolved[key] = adminDefaults[key]; - } else { - resolved[key] = this.defaults[key]; - } - } + if (this.syncState.serverAvailable && this.isSyncEnabled()) { + this.deleteProfileFromServer(profileName); + } + }, + + // ─── Resolution Chain ─────────────────────────────────────────── + + /** + * Gets resolved flat settings for the current device. + * Resolution: device profile → global → admin defaults → built-in defaults. + */ + getAll(profileOverride) { + const deviceProfile = profileOverride || Device.getProfileName(); + return this.resolveSettings(deviceProfile); + }, + + resolveSettings(profileName) { + const profiles = this.getProfiles(); + const global = profiles.global || {}; + const device = profileName !== "global" ? profiles[profileName] || {} : {}; + const adminDefaults = this.syncState.adminDefaults || {}; + + const resolved = {}; + const allKeys = Object.keys(this.defaults); + + for (const key of allKeys) { + // Resolution chain: device → global → admin defaults → built-in + if (device[key] !== undefined && device[key] !== null) { + resolved[key] = device[key]; + } else if (global[key] !== undefined && global[key] !== null) { + resolved[key] = global[key]; + } else if ( + adminDefaults[key] !== undefined && + adminDefaults[key] !== null + ) { + resolved[key] = adminDefaults[key]; + } else { + resolved[key] = this.defaults[key]; + } + } - return resolved; - }, - - get(key, defaultValue = null) { - const settings = this.getAll(); - return key in settings ? settings[key] : (defaultValue !== null ? defaultValue : this.defaults[key]); - }, - - set(key, value, profileName) { - profileName = profileName || this._activeEditProfile || 'global'; - const profile = this.getProfile(profileName); - profile[key] = value; - this.saveProfile(profileName, profile); - }, - - saveAll(settings, syncToServer = true) { - this.saveProfile('global', settings, syncToServer); - }, - - reset(profileName) { - if (profileName && profileName !== 'global') { - this.deleteProfile(profileName); - } else { - // Reset all profiles - localStorage.removeItem(this.PROFILES_KEY); - localStorage.removeItem(this.SNAPSHOT_KEY); - if (this.syncState.serverAvailable && this.isSyncEnabled()) { - this.saveAllProfilesToServer({}); - } - } - }, + return resolved; + }, + + get(key, defaultValue = null) { + const settings = this.getAll(); + return key in settings + ? settings[key] + : defaultValue !== null + ? defaultValue + : this.defaults[key]; + }, + + set(key, value, profileName) { + profileName = profileName || this._activeEditProfile || "global"; + const profile = this.getProfile(profileName); + profile[key] = value; + this.saveProfile(profileName, profile); + }, + + saveAll(settings, syncToServer = true) { + this.saveProfile("global", settings, syncToServer); + }, + + reset(profileName) { + if (profileName && profileName !== "global") { + this.deleteProfile(profileName); + } else { + // Reset all profiles + localStorage.removeItem(this.PROFILES_KEY); + localStorage.removeItem(this.SNAPSHOT_KEY); + if (this.syncState.serverAvailable && this.isSyncEnabled()) { + this.saveAllProfilesToServer({}); + } + } + }, - // ─── Active Edit Profile ──────────────────────────────────────── + // ─── Active Edit Profile ──────────────────────────────────────── - _activeEditProfile: 'global', + _activeEditProfile: "global", - setActiveEditProfile(profileName) { - this._activeEditProfile = profileName; - }, + setActiveEditProfile(profileName) { + this._activeEditProfile = profileName; + }, - getActiveEditProfile() { - return this._activeEditProfile; - }, + getActiveEditProfile() { + return this._activeEditProfile; + }, - // ─── Sync Preference ──────────────────────────────────────────── + // ─── Sync Preference ──────────────────────────────────────────── - isSyncEnabled() { - try { - const val = localStorage.getItem(this.SYNC_PREF_KEY); - return val === null ? true : val === 'true'; - } catch (e) { - return true; - } - }, - - setSyncEnabled(enabled) { - localStorage.setItem(this.SYNC_PREF_KEY, String(enabled)); - }, - - // ─── Backward Compatibility ───────────────────────────────────── - - _migrateFromLegacy() { - try { - const legacy = localStorage.getItem(this.STORAGE_KEY); - const profiles = localStorage.getItem(this.PROFILES_KEY); - - if (legacy && !profiles) { - const legacySettings = JSON.parse(legacy); - console.log('[Moonfin] Migrating legacy settings to profile format'); - this.saveProfile('global', legacySettings, false); - // Keep the legacy key around for one session as backup - localStorage.setItem(this.STORAGE_KEY + '_backup', legacy); - localStorage.removeItem(this.STORAGE_KEY); - } - } catch (e) { - console.error('[Moonfin] Legacy migration failed:', e); - } - }, - - // ─── Color Helpers ────────────────────────────────────────────── - - getColorHex(colorKey) { - return this.colorOptions[colorKey]?.hex || this.colorOptions['gray'].hex; - }, - - getColorRgba(colorKey, opacity = 50) { - const hex = this.getColorHex(colorKey); - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `rgba(${r}, ${g}, ${b}, ${opacity / 100})`; - }, - - // ─── Server Communication ─────────────────────────────────────── - - async pingServer() { - try { - const serverUrl = window.ApiClient?.serverAddress?.() || ''; - const response = await fetch(`${serverUrl}/Moonfin/Ping`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...this.getAuthHeader() - } - }); - - if (response.ok) { - const data = API.toCamelCase(await response.json()); - this.syncState.serverAvailable = data.installed && data.settingsSyncEnabled; - this.syncState.mdblistAvailable = data.mdblistAvailable || false; - this.syncState.tmdbAvailable = data.tmdbAvailable || false; - - // Store admin defaults for the resolution chain - if (data.defaultSettings) { - this.syncState.adminDefaults = this._mapProfileFromServer(data.defaultSettings); - } - - console.log('[Moonfin] Server plugin detected:', data); - return data; - } - } catch (e) { - console.log('[Moonfin] Server plugin not available:', e.message); + isSyncEnabled() { + try { + const val = localStorage.getItem(this.SYNC_PREF_KEY); + return val === null ? true : val === "true"; + } catch (e) { + return true; + } + }, + + setSyncEnabled(enabled) { + localStorage.setItem(this.SYNC_PREF_KEY, String(enabled)); + }, + + // ─── Backward Compatibility ───────────────────────────────────── + + _migrateFromLegacy() { + try { + const legacy = localStorage.getItem(this.STORAGE_KEY); + const profiles = localStorage.getItem(this.PROFILES_KEY); + + if (legacy && !profiles) { + const legacySettings = JSON.parse(legacy); + console.log("[Moonfin] Migrating legacy settings to profile format"); + this.saveProfile("global", legacySettings, false); + // Keep the legacy key around for one session as backup + localStorage.setItem(this.STORAGE_KEY + "_backup", legacy); + localStorage.removeItem(this.STORAGE_KEY); + } + } catch (e) { + console.error("[Moonfin] Legacy migration failed:", e); + } + }, + + // ─── Color Helpers ────────────────────────────────────────────── + + getColorHex(colorKey) { + return this.colorOptions[colorKey]?.hex || this.colorOptions["gray"].hex; + }, + + getColorRgba(colorKey, opacity = 50) { + const hex = this.getColorHex(colorKey); + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${opacity / 100})`; + }, + + // ─── Server Communication ─────────────────────────────────────── + + async pingServer() { + try { + const serverUrl = window.ApiClient?.serverAddress?.() || ""; + const response = await fetch(`${serverUrl}/Moonfin/Ping`, { + method: "GET", + headers: { + "Content-Type": "application/json", + ...this.getAuthHeader(), + }, + }); + + if (response.ok) { + const data = API.toCamelCase(await response.json()); + this.syncState.serverAvailable = + data.installed && data.settingsSyncEnabled; + this.syncState.mdblistAvailable = data.mdblistAvailable || false; + this.syncState.tmdbAvailable = data.tmdbAvailable || false; + + // Store admin defaults for the resolution chain + if (data.defaultSettings) { + this.syncState.adminDefaults = this._mapProfileFromServer( + data.defaultSettings, + ); } - - this.syncState.serverAvailable = false; - return null; - }, - getAuthHeader() { - const token = window.ApiClient?.accessToken?.(); - if (token) { - return { 'Authorization': `MediaBrowser Token="${token}"` }; - } - return {}; - }, + console.log("[Moonfin] Server plugin detected:", data); + return data; + } + } catch (e) { + console.log("[Moonfin] Server plugin not available:", e.message); + } - async fetchFromServer() { - if (this.syncState.serverAvailable === false) { - return null; - } + this.syncState.serverAvailable = false; + return null; + }, - try { - const serverUrl = window.ApiClient?.serverAddress?.() || ''; - const response = await fetch(`${serverUrl}/Moonfin/Settings`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...this.getAuthHeader() - } - }); - - if (response.ok) { - const serverData = API.toCamelCase(await response.json()); - console.log('[Moonfin] Fetched settings from server'); - return this._mapEnvelopeFromServer(serverData); - } else if (response.status === 404) { - console.log('[Moonfin] No settings found on server'); - return null; - } - } catch (e) { - console.error('[Moonfin] Failed to fetch from server:', e); - this.syncState.lastSyncError = e.message; - } - + getAuthHeader() { + const token = window.ApiClient?.accessToken?.(); + if (token) { + return { Authorization: `MediaBrowser Token="${token}"` }; + } + return {}; + }, + + async fetchFromServer() { + if (this.syncState.serverAvailable === false) { + return null; + } + + try { + const serverUrl = window.ApiClient?.serverAddress?.() || ""; + const response = await fetch(`${serverUrl}/Moonfin/Settings`, { + method: "GET", + headers: { + "Content-Type": "application/json", + ...this.getAuthHeader(), + }, + }); + + if (response.ok) { + const serverData = API.toCamelCase(await response.json()); + console.log("[Moonfin] Fetched settings from server"); + return this._mapEnvelopeFromServer(serverData); + } else if (response.status === 404) { + console.log("[Moonfin] No settings found on server"); return null; - }, + } + } catch (e) { + console.error("[Moonfin] Failed to fetch from server:", e); + this.syncState.lastSyncError = e.message; + } - async saveAllProfilesToServer(profiles) { - if (this.syncState.serverAvailable === false || !this.isSyncEnabled()) { - return false; - } + return null; + }, - try { - this.syncState.syncing = true; - const serverUrl = window.ApiClient?.serverAddress?.() || ''; - - const envelope = this._mapEnvelopeToServer(profiles); - envelope.syncEnabled = this.isSyncEnabled(); - - const response = await fetch(`${serverUrl}/Moonfin/Settings`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.getAuthHeader() - }, - body: JSON.stringify({ - settings: envelope, - clientId: this.CLIENT_ID, - mergeMode: 'replace' - }) - }); - - if (response.ok) { - this.syncState.lastSyncTime = Date.now(); - this.syncState.lastSyncError = null; - console.log('[Moonfin] All profiles saved to server'); - return true; - } - } catch (e) { - console.error('[Moonfin] Failed to save to server:', e); - this.syncState.lastSyncError = e.message; - } finally { - this.syncState.syncing = false; - } - - return false; - }, + async saveAllProfilesToServer(profiles) { + if (this.syncState.serverAvailable === false || !this.isSyncEnabled()) { + return false; + } - async saveProfileToServer(profileName, profileSettings) { - if (this.syncState.serverAvailable === false || !this.isSyncEnabled()) { - return false; - } + try { + this.syncState.syncing = true; + const serverUrl = window.ApiClient?.serverAddress?.() || ""; + + const envelope = this._mapEnvelopeToServer(profiles); + envelope.syncEnabled = this.isSyncEnabled(); + + const response = await fetch(`${serverUrl}/Moonfin/Settings`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this.getAuthHeader(), + }, + body: JSON.stringify({ + settings: envelope, + clientId: this.CLIENT_ID, + mergeMode: "replace", + }), + }); + + if (response.ok) { + this.syncState.lastSyncTime = Date.now(); + this.syncState.lastSyncError = null; + console.log("[Moonfin] All profiles saved to server"); + return true; + } + } catch (e) { + console.error("[Moonfin] Failed to save to server:", e); + this.syncState.lastSyncError = e.message; + } finally { + this.syncState.syncing = false; + } - try { - this.syncState.syncing = true; - const serverUrl = window.ApiClient?.serverAddress?.() || ''; - const serverProfile = this._mapProfileToServer(profileSettings); - - const response = await fetch(`${serverUrl}/Moonfin/Settings/Profile/${profileName}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.getAuthHeader() - }, - body: JSON.stringify({ - profile: serverProfile, - clientId: this.CLIENT_ID - }) - }); - - if (response.ok) { - this.syncState.lastSyncTime = Date.now(); - this.syncState.lastSyncError = null; - console.log('[Moonfin] Profile "' + profileName + '" saved to server'); - return true; - } - } catch (e) { - console.error('[Moonfin] Failed to save profile to server:', e); - this.syncState.lastSyncError = e.message; - } finally { - this.syncState.syncing = false; - } - - return false; - }, + return false; + }, - async deleteProfileFromServer(profileName) { - if (this.syncState.serverAvailable === false || !this.isSyncEnabled()) { - return false; - } + async saveProfileToServer(profileName, profileSettings) { + if (this.syncState.serverAvailable === false || !this.isSyncEnabled()) { + return false; + } - try { - const serverUrl = window.ApiClient?.serverAddress?.() || ''; - await fetch(`${serverUrl}/Moonfin/Settings/Profile/${profileName}`, { - method: 'DELETE', - headers: this.getAuthHeader() - }); - } catch (e) { - console.error('[Moonfin] Failed to delete profile from server:', e); - } + try { + this.syncState.syncing = true; + const serverUrl = window.ApiClient?.serverAddress?.() || ""; + const serverProfile = this._mapProfileToServer(profileSettings); + + const response = await fetch( + `${serverUrl}/Moonfin/Settings/Profile/${profileName}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this.getAuthHeader(), + }, + body: JSON.stringify({ + profile: serverProfile, + clientId: this.CLIENT_ID, + }), + }, + ); + + if (response.ok) { + this.syncState.lastSyncTime = Date.now(); + this.syncState.lastSyncError = null; + console.log('[Moonfin] Profile "' + profileName + '" saved to server'); + return true; + } + } catch (e) { + console.error("[Moonfin] Failed to save profile to server:", e); + this.syncState.lastSyncError = e.message; + } finally { + this.syncState.syncing = false; + } - return false; - }, - - - // ─── Server ↔ Local Mapping ───────────────────────────────────── - - _mapProfileFromServer(serverProfile) { - if (!serverProfile) return {}; - var mapping = { - navbarEnabled: 'navbarEnabled', - detailsPageEnabled: 'detailsPageEnabled', - mediaBarEnabled: 'mediaBarEnabled', - mediaBarContentType: 'mediaBarContentType', - mediaBarItemCount: 'mediaBarItemCount', - mediaBarOverlayOpacity: 'mediaBarOpacity', - mediaBarOverlayColor: 'mediaBarOverlayColor', - mediaBarAutoAdvance: 'mediaBarAutoAdvance', - mediaBarIntervalMs: 'mediaBarIntervalMs', - mediaBarTrailerPreview: 'mediaBarTrailerPreview', - showShuffleButton: 'showShuffleButton', - showGenresButton: 'showGenresButton', - showFavoritesButton: 'showFavoritesButton', - showCastButton: 'showCastButton', - showSyncPlayButton: 'showSyncPlayButton', - showLibrariesInToolbar: 'showLibrariesInToolbar', - shuffleContentType: 'shuffleContentType', - seasonalSurprise: 'seasonalSurprise', - backdropEnabled: 'backdropEnabled', - confirmExit: 'confirmExit', - navbarPosition: 'navbarPosition', - showClock: 'showClock', - use24HourClock: 'use24HourClock', - mdblistEnabled: 'mdblistEnabled', - mdblistApiKey: 'mdblistApiKey', - mdblistRatingSources: 'mdblistRatingSources', - tmdbApiKey: 'tmdbApiKey', - tmdbEpisodeRatingsEnabled: 'tmdbEpisodeRatingsEnabled' - }; - // Only include properties that have actual values — prevents undefined/null - // from polluting merge operations and overwriting valid false values - var result = {}; - for (var localKey in mapping) { - var serverKey = mapping[localKey]; - var val = serverProfile[serverKey]; - if (val !== undefined && val !== null) { - result[localKey] = val; - } - } - return result; - }, - - _mapProfileToServer(localProfile) { - if (!localProfile) return {}; - return { - navbarEnabled: localProfile.navbarEnabled, - detailsPageEnabled: localProfile.detailsPageEnabled, - mediaBarEnabled: localProfile.mediaBarEnabled, - mediaBarContentType: localProfile.mediaBarContentType, - mediaBarItemCount: localProfile.mediaBarItemCount, - mediaBarOpacity: localProfile.mediaBarOverlayOpacity, - mediaBarOverlayColor: localProfile.mediaBarOverlayColor, - mediaBarAutoAdvance: localProfile.mediaBarAutoAdvance, - mediaBarIntervalMs: localProfile.mediaBarIntervalMs, - mediaBarTrailerPreview: localProfile.mediaBarTrailerPreview, - showShuffleButton: localProfile.showShuffleButton, - showGenresButton: localProfile.showGenresButton, - showFavoritesButton: localProfile.showFavoritesButton, - showCastButton: localProfile.showCastButton, - showSyncPlayButton: localProfile.showSyncPlayButton, - showLibrariesInToolbar: localProfile.showLibrariesInToolbar, - shuffleContentType: localProfile.shuffleContentType, - seasonalSurprise: localProfile.seasonalSurprise, - backdropEnabled: localProfile.backdropEnabled, - confirmExit: localProfile.confirmExit, - navbarPosition: localProfile.navbarPosition, - showClock: localProfile.showClock, - use24HourClock: localProfile.use24HourClock, - mdblistEnabled: localProfile.mdblistEnabled, - mdblistApiKey: localProfile.mdblistApiKey, - mdblistRatingSources: localProfile.mdblistRatingSources, - tmdbApiKey: localProfile.tmdbApiKey, - tmdbEpisodeRatingsEnabled: localProfile.tmdbEpisodeRatingsEnabled - }; - }, - - /** - * Maps server envelope (v2) to local profiles object. - * Also handles v1 legacy format from the server. - */ - _mapEnvelopeFromServer(serverData) { - // v2 profiled format - if (serverData.global || serverData.desktop || serverData.mobile || serverData.tv) { - const profiles = {}; - if (serverData.global) profiles.global = this._mapProfileFromServer(serverData.global); - if (serverData.desktop) profiles.desktop = this._mapProfileFromServer(serverData.desktop); - if (serverData.mobile) profiles.mobile = this._mapProfileFromServer(serverData.mobile); - if (serverData.tv) profiles.tv = this._mapProfileFromServer(serverData.tv); - return { - profiles: profiles, - syncEnabled: serverData.syncEnabled !== false - }; - } + return false; + }, - // v1 legacy flat format — treat as global - const mapped = this._mapProfileFromServer(serverData); - return { - profiles: { global: mapped }, - syncEnabled: true - }; - }, - - _mapEnvelopeToServer(profiles) { - const envelope = { schemaVersion: 2 }; - if (profiles.global) envelope.global = this._mapProfileToServer(profiles.global); - if (profiles.desktop) envelope.desktop = this._mapProfileToServer(profiles.desktop); - if (profiles.mobile) envelope.mobile = this._mapProfileToServer(profiles.mobile); - if (profiles.tv) envelope.tv = this._mapProfileToServer(profiles.tv); - return envelope; - }, - - // ─── Sync Snapshots ───────────────────────────────────────────── - - getSnapshot() { - try { - const stored = localStorage.getItem(this.SNAPSHOT_KEY); - if (stored) { - return JSON.parse(stored); - } - } catch (e) { - console.error('[Moonfin] Failed to read sync snapshot:', e); - } - return null; - }, + async deleteProfileFromServer(profileName) { + if (this.syncState.serverAvailable === false || !this.isSyncEnabled()) { + return false; + } - saveSnapshot(profiles) { - try { - localStorage.setItem(this.SNAPSHOT_KEY, JSON.stringify(profiles)); - } catch (e) { - console.error('[Moonfin] Failed to save sync snapshot:', e); - } - }, - - // ─── Three-Way Merge ──────────────────────────────────────────── - - threeWayMergeProfiles(localProfiles, serverProfiles, snapshotProfiles) { - const merged = {}; - const allProfileNames = new Set([ - ...Object.keys(localProfiles || {}), - ...Object.keys(serverProfiles || {}), - ...Object.keys(snapshotProfiles || {}) - ]); - - for (const name of allProfileNames) { - merged[name] = this._threeWayMergeFlat( - localProfiles[name] || {}, - serverProfiles[name] || {}, - snapshotProfiles[name] || {} - ); - } + try { + const serverUrl = window.ApiClient?.serverAddress?.() || ""; + await fetch(`${serverUrl}/Moonfin/Settings/Profile/${profileName}`, { + method: "DELETE", + headers: this.getAuthHeader(), + }); + } catch (e) { + console.error("[Moonfin] Failed to delete profile from server:", e); + } - return merged; - }, - - _threeWayMergeFlat(local, server, snapshot) { - const merged = {}; - const allKeys = new Set([...Object.keys(local), ...Object.keys(server), ...Object.keys(this.defaults)]); - - for (const key of allKeys) { - const localVal = local[key]; - const serverVal = server[key]; - const snapVal = snapshot[key]; - - const localChanged = !this._deepEqual(localVal, snapVal); - const serverChanged = !this._deepEqual(serverVal, snapVal); - - if (localChanged && !serverChanged) { - if (localVal !== undefined) merged[key] = localVal; - } else if (serverChanged && !localChanged) { - if (serverVal !== undefined) merged[key] = serverVal; - } else if (localChanged && serverChanged) { - if (localVal !== undefined) merged[key] = localVal; - console.log('[Moonfin] Merge conflict on "' + key + '" — local wins'); - } else { - if (localVal !== undefined) merged[key] = localVal; - } - } + return false; + }, + + // ─── Server ↔ Local Mapping ───────────────────────────────────── + + _mapProfileFromServer(serverProfile) { + if (!serverProfile) return {}; + var mapping = { + navbarEnabled: "navbarEnabled", + detailsPageEnabled: "detailsPageEnabled", + mediaBarEnabled: "mediaBarEnabled", + mediaBarContentType: "mediaBarContentType", + mediaBarItemCount: "mediaBarItemCount", + mediaBarOverlayOpacity: "mediaBarOpacity", + mediaBarOverlayColor: "mediaBarOverlayColor", + mediaBarAutoAdvance: "mediaBarAutoAdvance", + mediaBarIntervalMs: "mediaBarIntervalMs", + mediaBarTrailerPreview: "mediaBarTrailerPreview", + mediaBarSourceType: "mediaBarSourceType", + mediaBarCollectionIds: "mediaBarCollectionIds", + mediaBarShuffleItems: "mediaBarShuffleItems", + showShuffleButton: "showShuffleButton", + showGenresButton: "showGenresButton", + showFavoritesButton: "showFavoritesButton", + showCastButton: "showCastButton", + showSyncPlayButton: "showSyncPlayButton", + showLibrariesInToolbar: "showLibrariesInToolbar", + shuffleContentType: "shuffleContentType", + seasonalSurprise: "seasonalSurprise", + backdropEnabled: "backdropEnabled", + confirmExit: "confirmExit", + navbarPosition: "navbarPosition", + showClock: "showClock", + use24HourClock: "use24HourClock", + mdblistEnabled: "mdblistEnabled", + mdblistApiKey: "mdblistApiKey", + mdblistRatingSources: "mdblistRatingSources", + tmdbApiKey: "tmdbApiKey", + tmdbEpisodeRatingsEnabled: "tmdbEpisodeRatingsEnabled", + }; + // Only include properties that have actual values — prevents undefined/null + // from polluting merge operations and overwriting valid false values + var result = {}; + for (var localKey in mapping) { + var serverKey = mapping[localKey]; + var val = serverProfile[serverKey]; + if (val !== undefined && val !== null) { + result[localKey] = val; + } + } + return result; + }, + + _mapProfileToServer(localProfile) { + if (!localProfile) return {}; + return { + navbarEnabled: localProfile.navbarEnabled, + detailsPageEnabled: localProfile.detailsPageEnabled, + mediaBarEnabled: localProfile.mediaBarEnabled, + mediaBarContentType: localProfile.mediaBarContentType, + mediaBarItemCount: localProfile.mediaBarItemCount, + mediaBarOpacity: localProfile.mediaBarOverlayOpacity, + mediaBarOverlayColor: localProfile.mediaBarOverlayColor, + mediaBarAutoAdvance: localProfile.mediaBarAutoAdvance, + mediaBarIntervalMs: localProfile.mediaBarIntervalMs, + mediaBarTrailerPreview: localProfile.mediaBarTrailerPreview, + mediaBarSourceType: localProfile.mediaBarSourceType, + mediaBarCollectionIds: localProfile.mediaBarCollectionIds, + mediaBarShuffleItems: localProfile.mediaBarShuffleItems, + showShuffleButton: localProfile.showShuffleButton, + showGenresButton: localProfile.showGenresButton, + showFavoritesButton: localProfile.showFavoritesButton, + showCastButton: localProfile.showCastButton, + showSyncPlayButton: localProfile.showSyncPlayButton, + showLibrariesInToolbar: localProfile.showLibrariesInToolbar, + shuffleContentType: localProfile.shuffleContentType, + seasonalSurprise: localProfile.seasonalSurprise, + backdropEnabled: localProfile.backdropEnabled, + confirmExit: localProfile.confirmExit, + navbarPosition: localProfile.navbarPosition, + showClock: localProfile.showClock, + use24HourClock: localProfile.use24HourClock, + mdblistEnabled: localProfile.mdblistEnabled, + mdblistApiKey: localProfile.mdblistApiKey, + mdblistRatingSources: localProfile.mdblistRatingSources, + tmdbApiKey: localProfile.tmdbApiKey, + tmdbEpisodeRatingsEnabled: localProfile.tmdbEpisodeRatingsEnabled, + }; + }, + + /** + * Maps server envelope (v2) to local profiles object. + * Also handles v1 legacy format from the server. + */ + _mapEnvelopeFromServer(serverData) { + // v2 profiled format + if ( + serverData.global || + serverData.desktop || + serverData.mobile || + serverData.tv + ) { + const profiles = {}; + if (serverData.global) + profiles.global = this._mapProfileFromServer(serverData.global); + if (serverData.desktop) + profiles.desktop = this._mapProfileFromServer(serverData.desktop); + if (serverData.mobile) + profiles.mobile = this._mapProfileFromServer(serverData.mobile); + if (serverData.tv) + profiles.tv = this._mapProfileFromServer(serverData.tv); + return { + profiles: profiles, + syncEnabled: serverData.syncEnabled !== false, + }; + } - return merged; - }, - - _deepEqual(a, b) { - if (a === b) return true; - if (a == null || b == null) return a == b; - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (!this._deepEqual(a[i], b[i])) return false; - } - return true; - } - if (typeof a === 'object' && typeof b === 'object') { - const ka = Object.keys(a), kb = Object.keys(b); - if (ka.length !== kb.length) return false; - for (const k of ka) { - if (!this._deepEqual(a[k], b[k])) return false; - } - return true; - } - return false; - }, - - // ─── Full Sync ────────────────────────────────────────────────── - - async sync(forceFromServer = false) { - console.log('[Moonfin] Starting settings sync...' + (forceFromServer ? ' (server wins)' : '')); - - const pingResult = await this.pingServer(); - if (!pingResult?.installed || !pingResult?.settingsSyncEnabled) { - console.log('[Moonfin] Server sync not available'); - return; - } + // v1 legacy flat format — treat as global + const mapped = this._mapProfileFromServer(serverData); + return { + profiles: { global: mapped }, + syncEnabled: true, + }; + }, + + _mapEnvelopeToServer(profiles) { + const envelope = { schemaVersion: 2 }; + if (profiles.global) + envelope.global = this._mapProfileToServer(profiles.global); + if (profiles.desktop) + envelope.desktop = this._mapProfileToServer(profiles.desktop); + if (profiles.mobile) + envelope.mobile = this._mapProfileToServer(profiles.mobile); + if (profiles.tv) envelope.tv = this._mapProfileToServer(profiles.tv); + return envelope; + }, + + // ─── Sync Snapshots ───────────────────────────────────────────── + + getSnapshot() { + try { + const stored = localStorage.getItem(this.SNAPSHOT_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch (e) { + console.error("[Moonfin] Failed to read sync snapshot:", e); + } + return null; + }, + + saveSnapshot(profiles) { + try { + localStorage.setItem(this.SNAPSHOT_KEY, JSON.stringify(profiles)); + } catch (e) { + console.error("[Moonfin] Failed to save sync snapshot:", e); + } + }, + + // ─── Three-Way Merge ──────────────────────────────────────────── + + threeWayMergeProfiles(localProfiles, serverProfiles, snapshotProfiles) { + const merged = {}; + const allProfileNames = new Set([ + ...Object.keys(localProfiles || {}), + ...Object.keys(serverProfiles || {}), + ...Object.keys(snapshotProfiles || {}), + ]); + + for (const name of allProfileNames) { + merged[name] = this._threeWayMergeFlat( + localProfiles[name] || {}, + serverProfiles[name] || {}, + snapshotProfiles[name] || {}, + ); + } - if (!this.isSyncEnabled()) { - console.log('[Moonfin] User has disabled sync'); - return; - } + return merged; + }, + + _threeWayMergeFlat(local, server, snapshot) { + const merged = {}; + const allKeys = new Set([ + ...Object.keys(local), + ...Object.keys(server), + ...Object.keys(this.defaults), + ]); + + for (const key of allKeys) { + const localVal = local[key]; + const serverVal = server[key]; + const snapVal = snapshot[key]; + + const localChanged = !this._deepEqual(localVal, snapVal); + const serverChanged = !this._deepEqual(serverVal, snapVal); + + if (localChanged && !serverChanged) { + if (localVal !== undefined) merged[key] = localVal; + } else if (serverChanged && !localChanged) { + if (serverVal !== undefined) merged[key] = serverVal; + } else if (localChanged && serverChanged) { + if (localVal !== undefined) merged[key] = localVal; + console.log('[Moonfin] Merge conflict on "' + key + '" — local wins'); + } else { + if (localVal !== undefined) merged[key] = localVal; + } + } - const localProfiles = this.getProfiles(); - const hasLocalProfiles = Object.keys(localProfiles).length > 0; - const serverResult = await this.fetchFromServer(); - const serverProfiles = serverResult?.profiles || null; - const snapshot = this.getSnapshot(); - - let merged; - - if (forceFromServer && serverProfiles) { - // Server wins — overwrite local with server profiles - merged = {}; - const allNames = new Set([...Object.keys(localProfiles), ...Object.keys(serverProfiles)]); - for (const name of allNames) { - merged[name] = { ...(localProfiles[name] || {}), ...(serverProfiles[name] || {}) }; - } - console.log('[Moonfin] Applied server profiles (manual sync)'); - } else if (serverProfiles && hasLocalProfiles && snapshot) { - merged = this.threeWayMergeProfiles(localProfiles, serverProfiles, snapshot); - console.log('[Moonfin] Three-way merged profiles'); - } else if (serverProfiles && hasLocalProfiles && !snapshot) { - // First sync — local wins for conflicts - merged = {}; - const allNames = new Set([...Object.keys(serverProfiles), ...Object.keys(localProfiles)]); - for (const name of allNames) { - merged[name] = { ...(serverProfiles[name] || {}), ...(localProfiles[name] || {}) }; - } - console.log('[Moonfin] First sync — local wins, pushed to server'); - } else if (serverProfiles && !hasLocalProfiles) { - merged = serverProfiles; - console.log('[Moonfin] Restored profiles from server (fresh install)'); - } else if (hasLocalProfiles) { - merged = localProfiles; - console.log('[Moonfin] Pushed local profiles to server'); - } else { - return; - } + return merged; + }, + + _deepEqual(a, b) { + if (a === b) return true; + if (a == null || b == null) return a == b; + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!this._deepEqual(a[i], b[i])) return false; + } + return true; + } + if (typeof a === "object" && typeof b === "object") { + const ka = Object.keys(a), + kb = Object.keys(b); + if (ka.length !== kb.length) return false; + for (const k of ka) { + if (!this._deepEqual(a[k], b[k])) return false; + } + return true; + } + return false; + }, - // Save locally without triggering sync - try { - localStorage.setItem(this.PROFILES_KEY, JSON.stringify(merged)); - window.dispatchEvent(new CustomEvent('moonfin-settings-changed', { detail: this.getAll() })); - } catch (e) { - console.error('[Moonfin] Failed to save merged profiles:', e); - } + // ─── Full Sync ────────────────────────────────────────────────── - // Update sync preference from server if available - if (serverResult && serverResult.syncEnabled !== undefined) { - this.setSyncEnabled(serverResult.syncEnabled); - } + async sync(forceFromServer = false) { + console.log( + "[Moonfin] Starting settings sync..." + + (forceFromServer ? " (server wins)" : ""), + ); + + const pingResult = await this.pingServer(); + if (!pingResult?.installed || !pingResult?.settingsSyncEnabled) { + console.log("[Moonfin] Server sync not available"); + return; + } + + if (!this.isSyncEnabled()) { + console.log("[Moonfin] User has disabled sync"); + return; + } - await this.saveAllProfilesToServer(merged); - this.saveSnapshot(merged); - }, + const localProfiles = this.getProfiles(); + const hasLocalProfiles = Object.keys(localProfiles).length > 0; + const serverResult = await this.fetchFromServer(); + const serverProfiles = serverResult?.profiles || null; + const snapshot = this.getSnapshot(); + + let merged; + + if (forceFromServer && serverProfiles) { + // Server wins — overwrite local with server profiles + merged = {}; + const allNames = new Set([ + ...Object.keys(localProfiles), + ...Object.keys(serverProfiles), + ]); + for (const name of allNames) { + merged[name] = { + ...(localProfiles[name] || {}), + ...(serverProfiles[name] || {}), + }; + } + console.log("[Moonfin] Applied server profiles (manual sync)"); + } else if (serverProfiles && hasLocalProfiles && snapshot) { + merged = this.threeWayMergeProfiles( + localProfiles, + serverProfiles, + snapshot, + ); + console.log("[Moonfin] Three-way merged profiles"); + } else if (serverProfiles && hasLocalProfiles && !snapshot) { + // First sync — local wins for conflicts + merged = {}; + const allNames = new Set([ + ...Object.keys(serverProfiles), + ...Object.keys(localProfiles), + ]); + for (const name of allNames) { + merged[name] = { + ...(serverProfiles[name] || {}), + ...(localProfiles[name] || {}), + }; + } + console.log("[Moonfin] First sync — local wins, pushed to server"); + } else if (serverProfiles && !hasLocalProfiles) { + merged = serverProfiles; + console.log("[Moonfin] Restored profiles from server (fresh install)"); + } else if (hasLocalProfiles) { + merged = localProfiles; + console.log("[Moonfin] Pushed local profiles to server"); + } else { + return; + } - initSync() { - if (this._initialSyncDone) return; - this._initialSyncDone = true; + // Save locally without triggering sync + try { + localStorage.setItem(this.PROFILES_KEY, JSON.stringify(merged)); + window.dispatchEvent( + new CustomEvent("moonfin-settings-changed", { detail: this.getAll() }), + ); + } catch (e) { + console.error("[Moonfin] Failed to save merged profiles:", e); + } - // Migrate legacy flat settings - this._migrateFromLegacy(); + // Update sync preference from server if available + if (serverResult && serverResult.syncEnabled !== undefined) { + this.setSyncEnabled(serverResult.syncEnabled); + } + await this.saveAllProfilesToServer(merged); + this.saveSnapshot(merged); + }, + + initSync() { + if (this._initialSyncDone) return; + this._initialSyncDone = true; + + // Migrate legacy flat settings + this._migrateFromLegacy(); + + if (window.ApiClient?.isLoggedIn?.()) { + setTimeout(() => this.sync(), 2000); + } else { + const onLogin = () => { if (window.ApiClient?.isLoggedIn?.()) { - setTimeout(() => this.sync(), 2000); - } else { - const onLogin = () => { - if (window.ApiClient?.isLoggedIn?.()) { - document.removeEventListener('viewshow', onLogin); - setTimeout(() => this.sync(), 2000); - } - }; - document.addEventListener('viewshow', onLogin); + document.removeEventListener("viewshow", onLogin); + setTimeout(() => this.sync(), 2000); } - }, - - getSyncStatus() { - return { - available: this.syncState.serverAvailable, - lastSync: this.syncState.lastSyncTime, - error: this.syncState.lastSyncError, - syncing: this.syncState.syncing - }; + }; + document.addEventListener("viewshow", onLogin); } + }, + + getSyncStatus() { + return { + available: this.syncState.serverAvailable, + lastSync: this.syncState.lastSyncTime, + error: this.syncState.lastSyncError, + syncing: this.syncState.syncing, + }; + }, }; From 23dc3051e581ec78bac4258fc78c2686df2064bf Mon Sep 17 00:00:00 2001 From: enyineer Date: Fri, 13 Mar 2026 22:17:36 +0100 Subject: [PATCH 02/11] feat: add optional library selection for media bar Allow users to optionally select specific Jellyfin libraries when using Library (Random) mode. When no libraries are selected, all libraries are used (preserving existing default behavior). - Add mediaBarLibraryIds to backend models and settings service - Extend getRandomItems() API to support parentId filtering per library - Add library picker UI in user settings and admin config page - Reuse collection picker styling for consistent UX --- backend/Models/MoonfinSettingsProfile.cs | 3 + backend/Models/MoonfinUserSettings.cs | 2 + backend/Pages/configPage.html | 63 ++++++++++- backend/Services/MoonfinSettingsService.cs | 1 + frontend/src/components/mediabar.js | 1 + frontend/src/components/settings.js | 117 ++++++++++++++++++++- frontend/src/utils/api.js | 40 ++++++- frontend/src/utils/storage.js | 3 + 8 files changed, 221 insertions(+), 9 deletions(-) diff --git a/backend/Models/MoonfinSettingsProfile.cs b/backend/Models/MoonfinSettingsProfile.cs index bd8b7a1..7ed08f5 100644 --- a/backend/Models/MoonfinSettingsProfile.cs +++ b/backend/Models/MoonfinSettingsProfile.cs @@ -117,6 +117,9 @@ public class MoonfinSettingsProfile [JsonPropertyName("mediaBarShuffleItems")] public bool? MediaBarShuffleItems { get; set; } + [JsonPropertyName("mediaBarLibraryIds")] + public List? MediaBarLibraryIds { get; set; } + [JsonPropertyName("seasonalSurprise")] public string? SeasonalSurprise { get; set; } diff --git a/backend/Models/MoonfinUserSettings.cs b/backend/Models/MoonfinUserSettings.cs index b5dd916..0488e90 100644 --- a/backend/Models/MoonfinUserSettings.cs +++ b/backend/Models/MoonfinUserSettings.cs @@ -106,6 +106,8 @@ public class MoonfinUserSettings public List? MediaBarCollectionIds { get; set; } [JsonPropertyName("mediaBarShuffleItems")] public bool? MediaBarShuffleItems { get; set; } + [JsonPropertyName("mediaBarLibraryIds")] + public List? MediaBarLibraryIds { get; set; } [JsonPropertyName("seasonalSurprise")] public string? SeasonalSurprise { get; set; } [JsonPropertyName("backdropEnabled")] diff --git a/backend/Pages/configPage.html b/backend/Pages/configPage.html index 30053e4..2a7c2a2 100644 --- a/backend/Pages/configPage.html +++ b/backend/Pages/configPage.html @@ -168,6 +168,16 @@

UI Features<

+
" + '
Loading...
'; + + API.getUserViews() + .then(function (views) { + if (!self.dialog) return; + + // Filter to media libraries (Movies, TV Shows, Music, etc.) + var libraries = []; + for (var i = 0; i < views.length; i++) { + var ct = views[i].CollectionType; + if (ct === "movies" || ct === "tvshows" || ct === "music" || ct === "mixed") { + libraries.push(views[i]); + } + } + + if (libraries.length === 0) { + picker.innerHTML = + '
No media libraries found.
'; + return; + } + + var settings = Storage.getAll(); + var selectedIds = settings.mediaBarLibraryIds || []; + var html = ""; + + for (var j = 0; j < libraries.length; j++) { + var lib = libraries[j]; + var isChecked = selectedIds.indexOf(lib.Id) !== -1; + var typeBadge = (lib.CollectionType || "library").charAt(0).toUpperCase() + + (lib.CollectionType || "library").slice(1); + + html += + '"; + } + + picker.innerHTML = html; + + // Event delegation for library checkboxes + picker.addEventListener("change", function (e) { + var cb = e.target; + if (!cb.dataset.libraryId) return; + + var currentIds = Storage.get("mediaBarLibraryIds") || []; + currentIds = currentIds.slice(); + var id = cb.dataset.libraryId; + var idx = currentIds.indexOf(id); + + if (cb.checked && idx === -1) { + currentIds.push(id); + } else if (!cb.checked && idx !== -1) { + currentIds.splice(idx, 1); + } + + self.saveSetting("mediaBarLibraryIds", currentIds); + + var label = cb.closest(".moonfin-collection-item"); + if (label) + label.classList.toggle( + "moonfin-collection-item-active", + cb.checked, + ); + + self.showToast( + cb.checked ? "Library added" : "Library removed", + ); + }); + }) + .catch(function (e) { + console.error("[Moonfin] Failed to load library picker:", e); + picker.innerHTML = + '
Failed to load libraries.
'; + }); + }, + updateJellyseerrSsoSection: function () { var self = this; var wrapper = this.dialog @@ -1208,12 +1319,16 @@ var Settings = { collectionOpts.style.display = isCollection ? "" : "none"; if (isCollection) { self.loadCollectionPicker(); + } else { + self.loadLibraryPicker(); } }); - // Load picker on init if collection mode is active + // Load pickers on init based on active source mode if (sourceTypeSelect.value === "collection") { self.loadCollectionPicker(); + } else { + self.loadLibraryPicker(); } } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index b730433..e00eaa3 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -49,7 +49,7 @@ const API = { const api = this.getApiClient(); if (!api) return []; - const { contentType = "both", limit = 10 } = options; + const { contentType = "both", limit = 10, libraryIds = [] } = options; try { const userId = api.getCurrentUserId(); @@ -62,11 +62,10 @@ const API = { includeItemTypes.push("Series"); } - const params = { + const baseParams = { userId: userId, includeItemTypes: includeItemTypes.join(","), sortBy: "Random", - limit: limit, recursive: true, hasThemeSong: false, hasThemeVideo: false, @@ -76,7 +75,40 @@ const API = { enableImageTypes: "Backdrop,Logo,Primary", }; - const result = await api.getItems(userId, params); + // When specific libraries are selected, query each and merge + if (libraryIds && libraryIds.length > 0) { + var allItems = []; + var seenIds = {}; + + for (var i = 0; i < libraryIds.length; i++) { + var params = Object.assign({}, baseParams, { + parentId: libraryIds[i], + limit: limit, + }); + var libResult = await api.getItems(userId, params); + var items = libResult.Items || []; + for (var j = 0; j < items.length; j++) { + if (!seenIds[items[j].Id]) { + seenIds[items[j].Id] = true; + allItems.push(items[j]); + } + } + } + + // Shuffle the merged results + for (var k = allItems.length - 1; k > 0; k--) { + var r = Math.floor(Math.random() * (k + 1)); + var temp = allItems[k]; + allItems[k] = allItems[r]; + allItems[r] = temp; + } + + return allItems.slice(0, limit); + } + + // Default: all libraries + baseParams.limit = limit; + const result = await api.getItems(userId, baseParams); return result.Items || []; } catch (e) { console.error("[Moonfin] Failed to get random items:", e); diff --git a/frontend/src/utils/storage.js b/frontend/src/utils/storage.js index d320714..611764d 100644 --- a/frontend/src/utils/storage.js +++ b/frontend/src/utils/storage.js @@ -30,6 +30,7 @@ const Storage = { mediaBarSourceType: "library", mediaBarCollectionIds: [], mediaBarShuffleItems: true, + mediaBarLibraryIds: [], showShuffleButton: true, showGenresButton: true, @@ -462,6 +463,7 @@ const Storage = { mediaBarSourceType: "mediaBarSourceType", mediaBarCollectionIds: "mediaBarCollectionIds", mediaBarShuffleItems: "mediaBarShuffleItems", + mediaBarLibraryIds: "mediaBarLibraryIds", showShuffleButton: "showShuffleButton", showGenresButton: "showGenresButton", showFavoritesButton: "showFavoritesButton", @@ -511,6 +513,7 @@ const Storage = { mediaBarSourceType: localProfile.mediaBarSourceType, mediaBarCollectionIds: localProfile.mediaBarCollectionIds, mediaBarShuffleItems: localProfile.mediaBarShuffleItems, + mediaBarLibraryIds: localProfile.mediaBarLibraryIds, showShuffleButton: localProfile.showShuffleButton, showGenresButton: localProfile.showGenresButton, showFavoritesButton: localProfile.showFavoritesButton, From 907433e93617a990d6f791d56a4bdaeed710be51 Mon Sep 17 00:00:00 2001 From: enyineer Date: Sat, 14 Mar 2026 09:47:35 +0100 Subject: [PATCH 03/11] feat: remove mediaBarContentType and add server-side MediaBar endpoint - Remove mediaBarContentType from backend models, service, frontend storage/settings/mediabar/api, and README - Add GET /Moonfin/MediaBar?profile= endpoint that resolves user settings and queries ILibraryManager for media bar items server-side - Frontend calls unified endpoint first with client-side fallback - Update README with new library selection settings and MediaBar endpoint --- README.md | 6 +- backend/Api/MoonfinController.cs | 215 ++++++++++++++++++++- backend/Models/MoonfinSettingsProfile.cs | 3 - backend/Models/MoonfinUserSettings.cs | 3 +- backend/Services/MoonfinSettingsService.cs | 2 +- frontend/src/components/mediabar.js | 23 ++- frontend/src/components/settings.js | 13 +- frontend/src/utils/api.js | 41 +++- frontend/src/utils/storage.js | 3 - 9 files changed, 271 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 154f077..a781ee3 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ Output: `Moonfin.Server-{VERSION}.zip` in the repo root. | `/Moonfin/Assets/{fileName}` | GET | Yes | Serve embedded rating icons | | `/Moonfin/MDBList/Batch` | POST | Yes | Batch fetch ratings for multiple items | | `/Moonfin/MDBList/{imdbId}` | GET | Yes | Get MDBList ratings for a single item | +| `/Moonfin/MediaBar` | GET | Yes | Get resolved media bar content for the current user | | `/Moonfin/TMDB/Episode/{seriesId}/{seasonNumber}/{episodeNumber}` | GET | Yes | Get TMDB episode rating | ### Jellyseerr/Seerr Config Response @@ -274,7 +275,10 @@ Settings stored on the server per-user and shared across all Moonfin clients. Ea | `showLibrariesInToolbar` | bool | Show library buttons in toolbar | | `shuffleContentType` | string | Shuffle content type (`movies`, `tv`, `both`) | | `mediaBarEnabled` | bool | Enable featured media bar | -| `mediaBarContentType` | string | Media bar content type (`movies`, `tv`, `both`) | +| `mediaBarSourceType` | string | Media bar content source (`library`, `collection`) | +| `mediaBarLibraryIds` | list | Library IDs to pull media bar items from (empty = all libraries) | +| `mediaBarCollectionIds` | list | Collection/playlist IDs for media bar (when source is `collection`) | +| `mediaBarShuffleItems` | bool | Shuffle items in media bar | | `mediaBarItemCount` | int | Number of items in media bar | | `mediaBarOpacity` | int | Media bar overlay opacity (0–100) | | `mediaBarOverlayColor` | string | Media bar overlay color key | diff --git a/backend/Api/MoonfinController.cs b/backend/Api/MoonfinController.cs index 0c6c770..9046246 100644 --- a/backend/Api/MoonfinController.cs +++ b/backend/Api/MoonfinController.cs @@ -1,5 +1,9 @@ using System.Net.Mime; using System.Text.Json; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -18,6 +22,7 @@ public class MoonfinController : ControllerBase { private readonly MoonfinSettingsService _settingsService; private readonly IHttpClientFactory _httpClientFactory; + private readonly ILibraryManager _libraryManager; // Cache for auto-detected variant private static string? _cachedVariant; @@ -25,10 +30,14 @@ public class MoonfinController : ControllerBase private static DateTime _variantCacheExpiry = DateTime.MinValue; private static readonly SemaphoreSlim _variantLock = new(1, 1); - public MoonfinController(MoonfinSettingsService settingsService, IHttpClientFactory httpClientFactory) + public MoonfinController( + MoonfinSettingsService settingsService, + IHttpClientFactory httpClientFactory, + ILibraryManager libraryManager) { _settingsService = settingsService; _httpClientFactory = httpClientFactory; + _libraryManager = libraryManager; } /// @@ -417,6 +426,210 @@ public ActionResult CheckMySettingsExist() return NotFound(); } + /// + /// Gets resolved media bar content for the current user. + /// Combines user settings resolution with server-side item queries so all clients + /// (web, Android, TV) get identical results from a single call. + /// + /// Device profile name: desktop, mobile, tv, or global. + /// Media bar items as Jellyfin BaseItemDto objects. + [HttpGet("MediaBar")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task GetMediaBarItems( + [FromQuery] string profile = "global") + { + var userId = this.GetUserIdFromClaims(); + if (userId == null) + { + return Unauthorized(new { Error = "User not authenticated" }); + } + + // Resolve settings: device profile → global → admin defaults + var resolved = await _settingsService.GetResolvedProfileAsync(userId.Value, profile); + var settings = resolved ?? MoonfinPlugin.Instance?.Configuration?.DefaultUserSettings ?? new MoonfinSettingsProfile(); + + var sourceType = settings.MediaBarSourceType ?? "library"; + var limit = settings.MediaBarItemCount ?? 10; + var shuffle = settings.MediaBarShuffleItems ?? true; + + List items; + + if (sourceType == "collection" && settings.MediaBarCollectionIds is { Count: > 0 }) + { + items = GetCollectionItems(settings.MediaBarCollectionIds, limit, shuffle); + } + else + { + items = GetLibraryItems(settings.MediaBarLibraryIds, limit); + } + + var dtos = items.Select(MapItemToDto).ToList(); + + return Ok(new + { + Items = dtos, + TotalRecordCount = dtos.Count + }); + } + + /// + /// Maps a BaseItem to a lightweight DTO matching Jellyfin's BaseItemDto shape. + /// Uses only stable BaseItem properties to avoid version-specific API issues. + /// + private static object MapItemToDto(BaseItem item) + { + // Build image tags dict + var imageTags = new Dictionary(); + var imageInfo = item.GetImageInfo(ImageType.Primary, 0); + if (imageInfo != null) + { + imageTags["Primary"] = GetTag(imageInfo); + } + var logoInfo = item.GetImageInfo(ImageType.Logo, 0); + if (logoInfo != null) + { + imageTags["Logo"] = GetTag(logoInfo); + } + + // Build backdrop tags array + var backdropTags = new List(); + var backdropImages = item.GetImages(ImageType.Backdrop).ToList(); + foreach (var bd in backdropImages) + { + backdropTags.Add(GetTag(bd)); + } + + return new + { + item.Id, + item.Name, + Type = item.GetBaseItemKind().ToString(), + item.ProductionYear, + item.OfficialRating, + item.RunTimeTicks, + item.Genres, + item.Overview, + item.CommunityRating, + item.CriticRating, + ImageTags = imageTags, + BackdropImageTags = backdropTags + }; + } + + /// + /// Gets a stable tag string from an ItemImageInfo for cache-busting image URLs. + /// + private static string GetTag(ItemImageInfo info) + { + return info.DateModified.Ticks.ToString("X"); + } + + /// + /// Queries random Movie/Series items, optionally filtered to specific libraries. + /// + private List GetLibraryItems(List? libraryIds, int limit) + { + if (libraryIds is { Count: > 0 }) + { + // Query each library separately and merge + var allItems = new List(); + var seenIds = new HashSet(); + + foreach (var libId in libraryIds) + { + if (!Guid.TryParse(libId, out var parentGuid)) continue; + + var query = new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Movie, BaseItemKind.Series], + ParentId = parentGuid, + Recursive = true + }; + + var result = _libraryManager.GetItemsResult(query); + foreach (var item in result.Items) + { + if (seenIds.Add(item.Id)) + { + allItems.Add(item); + } + } + } + + // Shuffle merged results and take limit + var rng = new Random(); + for (var i = allItems.Count - 1; i > 0; i--) + { + var j = rng.Next(i + 1); + (allItems[i], allItems[j]) = (allItems[j], allItems[i]); + } + + return allItems.Take(limit).ToList(); + } + + // Default: all libraries + var defaultQuery = new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Movie, BaseItemKind.Series], + Recursive = true + }; + + var allDefault = _libraryManager.GetItemsResult(defaultQuery).Items.ToList(); + + // Shuffle in-memory and take limit + var rng2 = new Random(); + for (var i = allDefault.Count - 1; i > 0; i--) + { + var j = rng2.Next(i + 1); + (allDefault[i], allDefault[j]) = (allDefault[j], allDefault[i]); + } + + return allDefault.Take(limit).ToList(); + } + + /// + /// Queries items from specified collections/playlists, merges and optionally shuffles. + /// + private List GetCollectionItems(List collectionIds, int limit, bool shuffle) + { + var allItems = new List(); + var seenIds = new HashSet(); + + foreach (var colId in collectionIds) + { + if (!Guid.TryParse(colId, out var parentGuid)) continue; + + var query = new InternalItemsQuery + { + ParentId = parentGuid, + Recursive = true + }; + + var result = _libraryManager.GetItemsResult(query); + foreach (var item in result.Items) + { + if (seenIds.Add(item.Id)) + { + allItems.Add(item); + } + } + } + + if (shuffle) + { + var rng = new Random(); + for (var i = allItems.Count - 1; i > 0; i--) + { + var j = rng.Next(i + 1); + (allItems[i], allItems[j]) = (allItems[j], allItems[i]); + } + } + + return allItems.Take(limit).ToList(); + } + /// /// Gets the Jellyseerr configuration (admin URL + user enablement). /// diff --git a/backend/Models/MoonfinSettingsProfile.cs b/backend/Models/MoonfinSettingsProfile.cs index 7ed08f5..b295427 100644 --- a/backend/Models/MoonfinSettingsProfile.cs +++ b/backend/Models/MoonfinSettingsProfile.cs @@ -87,9 +87,6 @@ public class MoonfinSettingsProfile [JsonPropertyName("mediaBarEnabled")] public bool? MediaBarEnabled { get; set; } - [JsonPropertyName("mediaBarContentType")] - public string? MediaBarContentType { get; set; } - [JsonPropertyName("mediaBarItemCount")] public int? MediaBarItemCount { get; set; } diff --git a/backend/Models/MoonfinUserSettings.cs b/backend/Models/MoonfinUserSettings.cs index 0488e90..77ba7a9 100644 --- a/backend/Models/MoonfinUserSettings.cs +++ b/backend/Models/MoonfinUserSettings.cs @@ -86,8 +86,7 @@ public class MoonfinUserSettings public bool? ConfirmExit { get; set; } [JsonPropertyName("mediaBarEnabled")] public bool? MediaBarEnabled { get; set; } - [JsonPropertyName("mediaBarContentType")] - public string? MediaBarContentType { get; set; } + [JsonPropertyName("mediaBarItemCount")] public int? MediaBarItemCount { get; set; } [JsonPropertyName("mediaBarOpacity")] diff --git a/backend/Services/MoonfinSettingsService.cs b/backend/Services/MoonfinSettingsService.cs index 64283d6..24dffd8 100644 --- a/backend/Services/MoonfinSettingsService.cs +++ b/backend/Services/MoonfinSettingsService.cs @@ -272,7 +272,7 @@ private void ClearLegacyFields(MoonfinUserSettings settings) settings.EnableFolderView = null; settings.ConfirmExit = null; settings.MediaBarEnabled = null; - settings.MediaBarContentType = null; + settings.MediaBarItemCount = null; settings.MediaBarOpacity = null; settings.MediaBarOverlayColor = null; diff --git a/frontend/src/components/mediabar.js b/frontend/src/components/mediabar.js index 4bff0f4..b1279da 100644 --- a/frontend/src/components/mediabar.js +++ b/frontend/src/components/mediabar.js @@ -35,6 +35,13 @@ var MediaBar = { this.setupEventListeners(); this.initialized = true; + // Track current content settings to avoid redundant reloads + this._lastItemCount = settings.mediaBarItemCount; + this._lastSourceType = settings.mediaBarSourceType; + this._lastCollectionIds = settings.mediaBarCollectionIds; + this._lastShuffleItems = settings.mediaBarShuffleItems; + this._lastLibraryIds = settings.mediaBarLibraryIds; + this._loadContentAsync(settings); }, @@ -157,11 +164,16 @@ var MediaBar = { async loadContent() { var settings = Storage.getAll(); - if ( + // Try the unified server-side endpoint first + var serverItems = await API.getMediaBarItems(Device.getProfileName()); + if (serverItems) { + this.items = serverItems; + } else if ( settings.mediaBarSourceType === "collection" && settings.mediaBarCollectionIds && settings.mediaBarCollectionIds.length > 0 ) { + // Fallback: client-side collection fetching this.items = await API.getCollectionItems( settings.mediaBarCollectionIds, { @@ -170,8 +182,8 @@ var MediaBar = { }, ); } else { + // Fallback: client-side random items this.items = await API.getRandomItems({ - contentType: settings.mediaBarContentType, limit: settings.mediaBarItemCount, libraryIds: settings.mediaBarLibraryIds || [], }); @@ -847,18 +859,19 @@ var MediaBar = { } if ( - this._lastContentType !== settings.mediaBarContentType || this._lastItemCount !== settings.mediaBarItemCount || this._lastSourceType !== settings.mediaBarSourceType || this._lastShuffleItems !== settings.mediaBarShuffleItems || JSON.stringify(this._lastCollectionIds) !== - JSON.stringify(settings.mediaBarCollectionIds) + JSON.stringify(settings.mediaBarCollectionIds) || + JSON.stringify(this._lastLibraryIds) !== + JSON.stringify(settings.mediaBarLibraryIds) ) { - this._lastContentType = settings.mediaBarContentType; this._lastItemCount = settings.mediaBarItemCount; this._lastSourceType = settings.mediaBarSourceType; this._lastCollectionIds = settings.mediaBarCollectionIds; this._lastShuffleItems = settings.mediaBarShuffleItems; + this._lastLibraryIds = settings.mediaBarLibraryIds; this.loadContent(); } }, diff --git a/frontend/src/components/settings.js b/frontend/src/components/settings.js index 19c8093..2518154 100644 --- a/frontend/src/components/settings.js +++ b/frontend/src/components/settings.js @@ -205,7 +205,7 @@ var Settings = { var existing = document.querySelector(".moonfin-settings-dialog"); if (existing) existing.remove(); - var settings = Storage.getAll(); + var settings = Storage.getAll(Storage.getActiveEditProfile()); var self = this; this.dialog = document.createElement("div"); @@ -255,17 +255,6 @@ var Settings = { '
' + - this.createSelectCard( - "mediaBarContentType", - "Content Type", - "What type of content to show in the media bar", - [ - { value: "both", label: "Movies & TV Shows" }, - { value: "movies", label: "Movies Only" }, - { value: "tv", label: "TV Shows Only" }, - ], - settings.mediaBarContentType, - ) + '
' + '
' + '
Libraries
' + diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index e00eaa3..ea38c6c 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -45,26 +45,47 @@ const API = { } }, + async getMediaBarItems(profile) { + const api = this.getApiClient(); + if (!api) return null; + + try { + const serverUrl = api.serverAddress?.() || ""; + const token = api.accessToken?.(); + const headers = token + ? { Authorization: 'MediaBrowser Token="' + token + '"' } + : {}; + + const profileParam = profile || "global"; + const response = await fetch( + serverUrl + "/Moonfin/MediaBar?profile=" + encodeURIComponent(profileParam), + { method: "GET", headers: headers }, + ); + + if (response.ok) { + const data = await response.json(); + return data.Items || data.items || []; + } + + return null; + } catch (e) { + console.warn("[Moonfin] MediaBar endpoint not available, falling back:", e); + return null; + } + }, + async getRandomItems(options = {}) { const api = this.getApiClient(); if (!api) return []; - const { contentType = "both", limit = 10, libraryIds = [] } = options; + const { limit = 10, libraryIds = [] } = options; try { const userId = api.getCurrentUserId(); - let includeItemTypes = []; - if (contentType === "movies" || contentType === "both") { - includeItemTypes.push("Movie"); - } - if (contentType === "tv" || contentType === "both") { - includeItemTypes.push("Series"); - } - const baseParams = { userId: userId, - includeItemTypes: includeItemTypes.join(","), + includeItemTypes: "Movie,Series", sortBy: "Random", recursive: true, hasThemeSong: false, diff --git a/frontend/src/utils/storage.js b/frontend/src/utils/storage.js index 611764d..54bc2c1 100644 --- a/frontend/src/utils/storage.js +++ b/frontend/src/utils/storage.js @@ -20,7 +20,6 @@ const Storage = { detailsPageEnabled: false, mediaBarEnabled: false, - mediaBarContentType: "both", mediaBarItemCount: 10, mediaBarOverlayOpacity: 50, mediaBarOverlayColor: "gray", @@ -453,7 +452,6 @@ const Storage = { navbarEnabled: "navbarEnabled", detailsPageEnabled: "detailsPageEnabled", mediaBarEnabled: "mediaBarEnabled", - mediaBarContentType: "mediaBarContentType", mediaBarItemCount: "mediaBarItemCount", mediaBarOverlayOpacity: "mediaBarOpacity", mediaBarOverlayColor: "mediaBarOverlayColor", @@ -503,7 +501,6 @@ const Storage = { navbarEnabled: localProfile.navbarEnabled, detailsPageEnabled: localProfile.detailsPageEnabled, mediaBarEnabled: localProfile.mediaBarEnabled, - mediaBarContentType: localProfile.mediaBarContentType, mediaBarItemCount: localProfile.mediaBarItemCount, mediaBarOpacity: localProfile.mediaBarOverlayOpacity, mediaBarOverlayColor: localProfile.mediaBarOverlayColor, From e4bed26f65b1741b38acd137b8e578cb3f1e003b Mon Sep 17 00:00:00 2001 From: enyineer Date: Sat, 14 Mar 2026 10:33:44 +0100 Subject: [PATCH 04/11] fix: resolve collection items using Folder.GetChildren for BoxSet/Playlist --- backend/Api/MoonfinController.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/Api/MoonfinController.cs b/backend/Api/MoonfinController.cs index 9046246..e23d4ae 100644 --- a/backend/Api/MoonfinController.cs +++ b/backend/Api/MoonfinController.cs @@ -601,16 +601,17 @@ private List GetCollectionItems(List collectionIds, int limit, { if (!Guid.TryParse(colId, out var parentGuid)) continue; - var query = new InternalItemsQuery - { - ParentId = parentGuid, - Recursive = true - }; + // Get the collection/playlist as a Folder to access LinkedChildren + var parent = _libraryManager.GetItemById(parentGuid); + if (parent is not Folder folder) continue; - var result = _libraryManager.GetItemsResult(query); - foreach (var item in result.Items) + // Access LinkedChildren (data property, no method signature issues) + // then resolve each linked item individually via GetItemById (proven stable) + foreach (var linkedChild in folder.LinkedChildren) { - if (seenIds.Add(item.Id)) + if (!linkedChild.ItemId.HasValue) continue; + var item = _libraryManager.GetItemById(linkedChild.ItemId.Value); + if (item != null && seenIds.Add(item.Id)) { allItems.Add(item); } From a4db6997927872b6e4d9d409813e348512992ef3 Mon Sep 17 00:00:00 2001 From: enyineer Date: Sun, 15 Mar 2026 00:18:21 +0100 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20address=20review=20comments?= =?UTF-8?q?=20=E2=80=94=20Random.Shared,=20partial=20shuffle,=20formatting?= =?UTF-8?q?=20revert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace new Random() with Random.Shared via shared ShuffleAndTake() helper - Implement partial Fisher-Yates: O(limit) instead of O(n) for shuffle - Add .editorconfig, .prettierrc, .prettierignore for consistent formatting - Reformat JS files with prettier to match upstream single-quote/4-space style - Re-indent configPage.html to match upstream 4-space style - Revert all formatting-only changes from previous commits --- .editorconfig | 22 + .prettierignore | 1 + .prettierrc | 10 + backend/Api/MoonfinController.cs | 55 +- backend/Pages/configPage.html | 890 ++++---- frontend/src/components/mediabar.js | 1663 ++++++++------- frontend/src/components/settings.js | 2993 +++++++++++++-------------- frontend/src/styles/settings.css | 841 ++++---- frontend/src/utils/api.js | 1495 +++++++------ frontend/src/utils/storage.js | 1484 +++++++------ 10 files changed, 4585 insertions(+), 4869 deletions(-) create mode 100644 .editorconfig create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..001286e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.cs] +indent_style = space +indent_size = 4 + +[*.{js,html,css}] +indent_style = space +indent_size = 4 + +[*.json] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2d19fc7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +*.html diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1817f29 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "tabWidth": 4, + "useTabs": false, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 120, + "semi": true, + "bracketSpacing": true, + "endOfLine": "lf" +} diff --git a/backend/Api/MoonfinController.cs b/backend/Api/MoonfinController.cs index e23d4ae..966fd32 100644 --- a/backend/Api/MoonfinController.cs +++ b/backend/Api/MoonfinController.cs @@ -558,14 +558,8 @@ private List GetLibraryItems(List? libraryIds, int limit) } } - // Shuffle merged results and take limit - var rng = new Random(); - for (var i = allItems.Count - 1; i > 0; i--) - { - var j = rng.Next(i + 1); - (allItems[i], allItems[j]) = (allItems[j], allItems[i]); - } - + // Partial Fisher-Yates: only shuffle `limit` elements from the full list + ShuffleAndTake(allItems, limit); return allItems.Take(limit).ToList(); } @@ -578,14 +572,7 @@ private List GetLibraryItems(List? libraryIds, int limit) var allDefault = _libraryManager.GetItemsResult(defaultQuery).Items.ToList(); - // Shuffle in-memory and take limit - var rng2 = new Random(); - for (var i = allDefault.Count - 1; i > 0; i--) - { - var j = rng2.Next(i + 1); - (allDefault[i], allDefault[j]) = (allDefault[j], allDefault[i]); - } - + ShuffleAndTake(allDefault, limit); return allDefault.Take(limit).ToList(); } @@ -620,17 +607,41 @@ private List GetCollectionItems(List collectionIds, int limit, if (shuffle) { - var rng = new Random(); - for (var i = allItems.Count - 1; i > 0; i--) - { - var j = rng.Next(i + 1); - (allItems[i], allItems[j]) = (allItems[j], allItems[i]); - } + ShuffleAndTake(allItems, limit); } return allItems.Take(limit).ToList(); } + /// + /// Shuffles items in-place using partial or full Fisher-Yates depending on limit. + /// When limit is smaller than the list, only the first `limit` positions are filled + /// with uniformly random picks from the entire list (O(limit) instead of O(n)). + /// + private static void ShuffleAndTake(List items, int limit) + { + if (items.Count <= 1) return; + + if (limit < items.Count) + { + // Partial Fisher-Yates: fill first `limit` slots from the full range + for (var i = 0; i < limit; i++) + { + var j = Random.Shared.Next(i, items.Count); + (items[i], items[j]) = (items[j], items[i]); + } + } + else + { + // Full Fisher-Yates shuffle + for (var i = items.Count - 1; i > 0; i--) + { + var j = Random.Shared.Next(i + 1); + (items[i], items[j]) = (items[j], items[i]); + } + } + } + /// /// Gets the Jellyseerr configuration (admin URL + user enablement). /// diff --git a/backend/Pages/configPage.html b/backend/Pages/configPage.html index 2a7c2a2..3d210a6 100644 --- a/backend/Pages/configPage.html +++ b/backend/Pages/configPage.html @@ -2,479 +2,479 @@ - - Moonfin + + Moonfin -
-
-
-
-
-

Moonfin Settings

-
+
+
+
+ +
+

Moonfin Settings

+
-
-

General

-
- -
- Allow Moonfin clients to sync their settings across devices via this server. -
-
-
+
+

General

+
+ +
+ Allow Moonfin clients to sync their settings across devices via this server. +
+
+
-
-

Jellyseerr / Seerr Integration

-
- -
- Enable Jellyseerr or Seerr integration for all users. Users can configure their own authentication - method in their client settings. -
-
-
- - -
- The URL of your Jellyseerr or Seerr instance. This URL is used for server-to-server API proxying. -
-
-
- - -
- Optional override. The name shown in the UI (e.g., "Requests", "Media Requests"). - Leave blank to auto-detect "Jellyseerr" or "Seerr" based on the configured URL. - The icon is always auto-detected regardless of this setting. -
-
-
- - -
- Optional: When set, the client-side iframe loads directly from this URL - instead of through the proxy. Use this if you're running Seerr v3 (Next.js) behind a reverse proxy - and experiencing issues with the default proxy mode. -

- When to use: If Seerr works when accessed directly but not through Moonfin's proxy, - set this to your public Seerr URL (e.g., https://seerr.yourdomain.com). - SSO API calls will still go through the Jellyfin server. -
-
-
- SSO Proxy: When Jellyseerr is enabled, Moonfin provides single sign-on. - Users authenticate once with their Jellyfin credentials, and the session is stored server-side. - All Moonfin clients (web, Android, Roku, webOS, Tizen, etc) can access Jellyseerr through - /Moonfin/Jellyseerr/Api/ - without re-authenticating. -
-
+
+

Jellyseerr / Seerr Integration

+
+ +
+ Enable Jellyseerr or Seerr integration for all users. Users can configure their own authentication + method in their client settings. +
+
+
+ + +
+ The URL of your Jellyseerr or Seerr instance. This URL is used for server-to-server API proxying. +
+
+
+ + +
+ Optional override. The name shown in the UI (e.g., "Requests", "Media Requests"). + Leave blank to auto-detect "Jellyseerr" or "Seerr" based on the configured URL. + The icon is always auto-detected regardless of this setting. +
+
+
+ + +
+ Optional: When set, the client-side iframe loads directly from this URL + instead of through the proxy. Use this if you're running Seerr v3 (Next.js) behind a reverse proxy + and experiencing issues with the default proxy mode. +

+ When to use: If Seerr works when accessed directly but not through Moonfin's proxy, + set this to your public Seerr URL (e.g., https://seerr.yourdomain.com). + SSO API calls will still go through the Jellyfin server. +
+
+
+ SSO Proxy: When Jellyseerr is enabled, Moonfin provides single sign-on. + Users authenticate once with their Jellyfin credentials, and the session is stored server-side. + All Moonfin clients (web, Android, Roku, webOS, Tizen, etc) can access Jellyseerr through + /Moonfin/Jellyseerr/Api/ + without re-authenticating. +
+
-
-

MDBList Integration

-
- - -
- Provide a server-wide MDBList API key - that all users can use for ratings (IMDb, Rotten Tomatoes, Metacritic, etc.). - Users who set their own key in their Moonfin client settings will use that instead. -
-
-
+
+

MDBList Integration

+
+ + +
+ Provide a server-wide MDBList API key + that all users can use for ratings (IMDb, Rotten Tomatoes, Metacritic, etc.). + Users who set their own key in their Moonfin client settings will use that instead. +
+
+
-
-

TMDB Integration

-
- - -
- Provide a server-wide TMDB API key - that all users can use for episode ratings. - Users who set their own key in their Moonfin client settings will use that instead. - Supports both v3 API keys and v4 Bearer tokens. -
-
-
+
+

TMDB Integration

+
+ + +
+ Provide a server-wide TMDB API key + that all users can use for episode ratings. + Users who set their own key in their Moonfin client settings will use that instead. + Supports both v3 API keys and v4 Bearer tokens. +
+
+
-
-

Default User Settings

-
- Configure default Moonfin settings for all users. New users and users who haven't customized - a setting will see these values. Users can always override defaults in their own Moonfin settings panel. -
+
+

Default User Settings

+
+ Configure default Moonfin settings for all users. New users and users who haven't customized + a setting will see these values. Users can always override defaults in their own Moonfin settings panel. +
-
- How it works: These are the lowest-priority fallback. Resolution order: - User's device profile → User's global settings → These admin defaults → - Built-in defaults. -
+
+ How it works: These are the lowest-priority fallback. Resolution order: + User's device profile → User's global settings → These admin defaults → + Built-in defaults. +
-

UI Features

-
- -
Enable the custom Moonfin navigation bar by default.
-
-
- - -
-
- -
Enable the featured media carousel on the home page by default.
-
-
- - -
Default content source for the media bar. Users can override this.
-
- - -
- -
Randomize the order of items from collections/playlists by default.
-
-
- -
Use the custom Moonfin details page by default.
-
+

UI Features

+
+ +
Enable the custom Moonfin navigation bar by default.
+
+
+ + +
+
+ +
Enable the featured media carousel on the home page by default.
+
+
+ + +
Default content source for the media bar. Users can override this.
+
+ + +
+ +
Randomize the order of items from collections/playlists by default.
+
+
+ +
Use the custom Moonfin details page by default.
+
-

Toolbar Buttons

-
- -
Show clock in navigation bar by default.
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
+

Toolbar Buttons

+
+ +
Show clock in navigation bar by default.
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
-

Integrations

-
- -
Enable MDBList ratings by default (requires server-wide or user API key). -
-
-
- -
Enable TMDB episode ratings by default (requires server-wide or user API - key).
-
-
+

Integrations

+
+ +
Enable MDBList ratings by default (requires server-wide or user API key). +
+
+
+ +
Enable TMDB episode ratings by default (requires server-wide or user API + key).
+
+
-
-

Web Plugin Status

-
-

Moonfin uses the - File - Transformation - plugin to inject its web UI. -

-

- Setup: -

-
    -
  1. Install the File Transformation plugin (add - https://www.iamparadox.dev/jellyfin/plugins/manifest.json to your plugin repositories) -
  2. -
  3. Restart Jellyfin
  4. -
  5. Force refresh your browser (Ctrl+Shift+R)
  6. -
-

- UI not loading? Go to Dashboard > Scheduled Tasks and run the - Moonfin Startup task once, then refresh your browser. -

-
-
+
+

Web Plugin Status

+
+

Moonfin uses the + File + Transformation + plugin to inject its web UI. +

+

+ Setup: +

+
    +
  1. Install the File Transformation plugin (add + https://www.iamparadox.dev/jellyfin/plugins/manifest.json to your plugin repositories) +
  2. +
  3. Restart Jellyfin
  4. +
  5. Force refresh your browser (Ctrl+Shift+R)
  6. +
+

+ UI not loading? Go to Dashboard > Scheduled Tasks and run the + Moonfin Startup task once, then refresh your browser. +

+
+
-
- -
- -
-
+
+ +
+ +
+
- -
+ +
\ No newline at end of file diff --git a/frontend/src/components/mediabar.js b/frontend/src/components/mediabar.js index b1279da..1767836 100644 --- a/frontend/src/components/mediabar.js +++ b/frontend/src/components/mediabar.js @@ -1,913 +1,866 @@ var MediaBar = { - container: null, - initialized: false, - items: [], - currentIndex: 0, - isPaused: false, - autoAdvanceTimer: null, - isVisible: true, - - _trailerState: "idle", - _trailerPlayer: null, - _trailerRevealTimer: null, - _trailerVideoId: null, - _sponsorSegments: [], - _trailerRevealMs: 4000, - _ytApiReady: false, - _ytApiLoading: false, - - async init() { - var settings = Storage.getAll(); - if (!settings.mediaBarEnabled) { - document.body.classList.remove("moonfin-mediabar-active"); - return; - } + container: null, + initialized: false, + items: [], + currentIndex: 0, + isPaused: false, + autoAdvanceTimer: null, + isVisible: true, + + _trailerState: 'idle', + _trailerPlayer: null, + _trailerRevealTimer: null, + _trailerVideoId: null, + _sponsorSegments: [], + _trailerRevealMs: 4000, + _ytApiReady: false, + _ytApiLoading: false, + + async init() { + var settings = Storage.getAll(); + if (!settings.mediaBarEnabled) { + document.body.classList.remove('moonfin-mediabar-active'); + return; + } - if (this.initialized) return; + if (this.initialized) return; - this.createMediaBar(); - this.container.classList.add("loading"); + this.createMediaBar(); + this.container.classList.add('loading'); - if (Plugin.isHomePage()) { - document.body.classList.add("moonfin-mediabar-active"); - } + if (Plugin.isHomePage()) { + document.body.classList.add('moonfin-mediabar-active'); + } - this.setupEventListeners(); - this.initialized = true; - - // Track current content settings to avoid redundant reloads - this._lastItemCount = settings.mediaBarItemCount; - this._lastSourceType = settings.mediaBarSourceType; - this._lastCollectionIds = settings.mediaBarCollectionIds; - this._lastShuffleItems = settings.mediaBarShuffleItems; - this._lastLibraryIds = settings.mediaBarLibraryIds; - - this._loadContentAsync(settings); - }, - - _loadContentAsync(settings) { - var self = this; - this.waitForApi() - .then(function () { - return self.loadContent(); - }) - .then(function () { - if (self.items.length > 0) { - self.container.classList.remove("loading"); - if (settings.mediaBarAutoAdvance) { - self.startAutoAdvance(); - } + this.setupEventListeners(); + this.initialized = true; + + // Track current content settings to avoid redundant reloads + this._lastItemCount = settings.mediaBarItemCount; + this._lastSourceType = settings.mediaBarSourceType; + this._lastCollectionIds = settings.mediaBarCollectionIds; + this._lastShuffleItems = settings.mediaBarShuffleItems; + this._lastLibraryIds = settings.mediaBarLibraryIds; + + this._loadContentAsync(settings); + }, + + _loadContentAsync(settings) { + var self = this; + this.waitForApi() + .then(function () { + return self.loadContent(); + }) + .then(function () { + if (self.items.length > 0) { + self.container.classList.remove('loading'); + if (settings.mediaBarAutoAdvance) { + self.startAutoAdvance(); + } + } else { + document.body.classList.remove('moonfin-mediabar-active'); + self.container.classList.add('empty'); + } + }) + .catch(function (e) { + console.error('[Moonfin] MediaBar: Failed to load content -', e.message); + document.body.classList.remove('moonfin-mediabar-active'); + if (self.container) self.container.classList.add('empty'); + }); + }, + + waitForApi() { + return new Promise(function (resolve, reject) { + var attempts = 0; + var maxAttempts = 50; + + var check = function () { + var api = API.getApiClient(); + if (api) { + try { + var userId = api.getCurrentUserId(); + if (userId) { + resolve(); + return; + } + } catch (e) { + // Not authenticated yet + } + } + + if (attempts >= maxAttempts) { + reject(new Error('API timeout')); + } else { + attempts++; + setTimeout(check, 100); + } + }; + check(); + }); + }, + + createMediaBar() { + var existing = document.querySelector('.moonfin-mediabar'); + if (existing) { + existing.remove(); + } + + var settings = Storage.getAll(); + var overlayColor = Storage.getColorRgba(settings.mediaBarOverlayColor, settings.mediaBarOverlayOpacity); + + this.container = document.createElement('div'); + this.container.className = 'moonfin-mediabar'; + this.container.innerHTML = + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
'; + + document.body.appendChild(this.container); + }, + + async loadContent() { + var settings = Storage.getAll(); + + // Try the unified server-side endpoint first + var serverItems = await API.getMediaBarItems(Device.getProfileName()); + if (serverItems) { + this.items = serverItems; + } else if ( + settings.mediaBarSourceType === 'collection' && + settings.mediaBarCollectionIds && + settings.mediaBarCollectionIds.length > 0 + ) { + // Fallback: client-side collection fetching + this.items = await API.getCollectionItems(settings.mediaBarCollectionIds, { + limit: settings.mediaBarItemCount, + shuffle: settings.mediaBarShuffleItems !== false + }); } else { - document.body.classList.remove("moonfin-mediabar-active"); - self.container.classList.add("empty"); - } - }) - .catch(function (e) { - console.error( - "[Moonfin] MediaBar: Failed to load content -", - e.message, - ); - document.body.classList.remove("moonfin-mediabar-active"); - if (self.container) self.container.classList.add("empty"); - }); - }, - - waitForApi() { - return new Promise(function (resolve, reject) { - var attempts = 0; - var maxAttempts = 50; - - var check = function () { - var api = API.getApiClient(); - if (api) { - try { - var userId = api.getCurrentUserId(); - if (userId) { - resolve(); - return; - } - } catch (e) { - // Not authenticated yet - } + // Fallback: client-side random items + this.items = await API.getRandomItems({ + limit: settings.mediaBarItemCount, + libraryIds: settings.mediaBarLibraryIds || [] + }); } - if (attempts >= maxAttempts) { - reject(new Error("API timeout")); + if (this.items.length > 0) { + this.updateDisplay(); + this.updateDots(); } else { - attempts++; - setTimeout(check, 100); - } - }; - check(); - }); - }, - - createMediaBar() { - var existing = document.querySelector(".moonfin-mediabar"); - if (existing) { - existing.remove(); - } + this.container.classList.add('empty'); + } + }, - var settings = Storage.getAll(); - var overlayColor = Storage.getColorRgba( - settings.mediaBarOverlayColor, - settings.mediaBarOverlayOpacity, - ); - - this.container = document.createElement("div"); - this.container.className = "moonfin-mediabar"; - this.container.innerHTML = - '
' + - '
' + - '
' + - "
" + - '
' + - '
' + - '
' + - '
' + - '' + - "
" + - '
' + - '" + - '
' + - '
' + - "
" + - "
" + - '
' + - '" + - '" + - "
" + - '
' + - '
' + - "
"; - - document.body.appendChild(this.container); - }, - - async loadContent() { - var settings = Storage.getAll(); - - // Try the unified server-side endpoint first - var serverItems = await API.getMediaBarItems(Device.getProfileName()); - if (serverItems) { - this.items = serverItems; - } else if ( - settings.mediaBarSourceType === "collection" && - settings.mediaBarCollectionIds && - settings.mediaBarCollectionIds.length > 0 - ) { - // Fallback: client-side collection fetching - this.items = await API.getCollectionItems( - settings.mediaBarCollectionIds, - { - limit: settings.mediaBarItemCount, - shuffle: settings.mediaBarShuffleItems !== false, - }, - ); - } else { - // Fallback: client-side random items - this.items = await API.getRandomItems({ - limit: settings.mediaBarItemCount, - libraryIds: settings.mediaBarLibraryIds || [], - }); - } + updateDisplay() { + var item = this.items[this.currentIndex]; + if (!item) return; - if (this.items.length > 0) { - this.updateDisplay(); - this.updateDots(); - } else { - this.container.classList.add("empty"); - } - }, + this.stopTrailer(); - updateDisplay() { - var item = this.items[this.currentIndex]; - if (!item) return; + var backdropUrl = API.getImageUrl(item, 'Backdrop', { maxWidth: 1920 }); + this.updateBackdrop(backdropUrl); - this.stopTrailer(); + var logoUrl = API.getImageUrl(item, 'Logo', { maxWidth: 500 }); + var logoContainer = this.container.querySelector('.moonfin-mediabar-logo-container'); + var logoImg = this.container.querySelector('.moonfin-mediabar-logo'); - var backdropUrl = API.getImageUrl(item, "Backdrop", { maxWidth: 1920 }); - this.updateBackdrop(backdropUrl); + if (logoUrl) { + logoImg.src = logoUrl; + logoImg.alt = item.Name; + logoContainer.classList.remove('hidden'); + } else { + logoContainer.classList.add('hidden'); + } - var logoUrl = API.getImageUrl(item, "Logo", { maxWidth: 500 }); - var logoContainer = this.container.querySelector( - ".moonfin-mediabar-logo-container", - ); - var logoImg = this.container.querySelector(".moonfin-mediabar-logo"); + var yearEl = this.container.querySelector('.moonfin-mediabar-year'); + var ratingBadge = this.container.querySelector('.moonfin-mediabar-rating-badge'); + var runtimeEl = this.container.querySelector('.moonfin-mediabar-runtime'); + var genresEl = this.container.querySelector('.moonfin-mediabar-genres'); + var ratingsEl = this.container.querySelector('.moonfin-mediabar-ratings'); + var overviewEl = this.container.querySelector('.moonfin-mediabar-overview'); - if (logoUrl) { - logoImg.src = logoUrl; - logoImg.alt = item.Name; - logoContainer.classList.remove("hidden"); - } else { - logoContainer.classList.add("hidden"); - } + yearEl.textContent = item.ProductionYear || ''; - var yearEl = this.container.querySelector(".moonfin-mediabar-year"); - var ratingBadge = this.container.querySelector( - ".moonfin-mediabar-rating-badge", - ); - var runtimeEl = this.container.querySelector(".moonfin-mediabar-runtime"); - var genresEl = this.container.querySelector(".moonfin-mediabar-genres"); - var ratingsEl = this.container.querySelector(".moonfin-mediabar-ratings"); - var overviewEl = this.container.querySelector(".moonfin-mediabar-overview"); - - yearEl.textContent = item.ProductionYear || ""; - - if (item.OfficialRating) { - ratingBadge.textContent = item.OfficialRating; - ratingBadge.classList.remove("hidden"); - } else { - ratingBadge.textContent = ""; - ratingBadge.classList.add("hidden"); - } + if (item.OfficialRating) { + ratingBadge.textContent = item.OfficialRating; + ratingBadge.classList.remove('hidden'); + } else { + ratingBadge.textContent = ''; + ratingBadge.classList.add('hidden'); + } - if (item.RunTimeTicks) { - var minutes = Math.round(item.RunTimeTicks / 600000000); - var hours = Math.floor(minutes / 60); - var mins = minutes % 60; - runtimeEl.textContent = - hours > 0 ? hours + "h " + mins + "m" : mins + "m"; - } else { - runtimeEl.textContent = ""; - } + if (item.RunTimeTicks) { + var minutes = Math.round(item.RunTimeTicks / 600000000); + var hours = Math.floor(minutes / 60); + var mins = minutes % 60; + runtimeEl.textContent = hours > 0 ? hours + 'h ' + mins + 'm' : mins + 'm'; + } else { + runtimeEl.textContent = ''; + } - if (item.Genres && item.Genres.length > 0) { - genresEl.textContent = item.Genres.slice(0, 3).join(" \u2022 "); - } else { - genresEl.textContent = ""; - } + if (item.Genres && item.Genres.length > 0) { + genresEl.textContent = item.Genres.slice(0, 3).join(' \u2022 '); + } else { + genresEl.textContent = ''; + } - var ratingParts = []; - if (item.CommunityRating) { - ratingParts.push("\u2605 " + item.CommunityRating.toFixed(1)); - } - if (item.CriticRating) { - ratingParts.push("\uD83C\uDF45 " + item.CriticRating + "%"); - } - ratingsEl.textContent = ratingParts.join(" \u2022 "); - - if (MdbList.isEnabled()) { - var currentIdx = this.currentIndex; - MdbList.fetchRatings(item).then(function (mdbRatings) { - if (MediaBar.currentIndex !== currentIdx) return; - if (mdbRatings && mdbRatings.length > 0) { - var mdbHtml = MdbList.buildRatingsHtml(mdbRatings, "compact"); - if (mdbHtml) { - ratingsEl.innerHTML = mdbHtml; - } - } - }); - } + var ratingParts = []; + if (item.CommunityRating) { + ratingParts.push('\u2605 ' + item.CommunityRating.toFixed(1)); + } + if (item.CriticRating) { + ratingParts.push('\uD83C\uDF45 ' + item.CriticRating + '%'); + } + ratingsEl.textContent = ratingParts.join(' \u2022 '); + + if (MdbList.isEnabled()) { + var currentIdx = this.currentIndex; + MdbList.fetchRatings(item).then(function (mdbRatings) { + if (MediaBar.currentIndex !== currentIdx) return; + if (mdbRatings && mdbRatings.length > 0) { + var mdbHtml = MdbList.buildRatingsHtml(mdbRatings, 'compact'); + if (mdbHtml) { + ratingsEl.innerHTML = mdbHtml; + } + } + }); + } - if (item.Overview) { - var tmp = document.createElement("div"); - tmp.innerHTML = item.Overview; - overviewEl.textContent = tmp.textContent || tmp.innerText || ""; - } else { - overviewEl.textContent = ""; - } + if (item.Overview) { + var tmp = document.createElement('div'); + tmp.innerHTML = item.Overview; + overviewEl.textContent = tmp.textContent || tmp.innerText || ''; + } else { + overviewEl.textContent = ''; + } - this.updateActiveDot(); + this.updateActiveDot(); - var settings = Storage.getAll(); - if (settings.mediaBarTrailerPreview) { - var currentIdx = this.currentIndex; - this.fetchAndPlayTrailer(item, currentIdx); - } - }, - - async fetchAndPlayTrailer(item, expectedIndex) { - if (item.RemoteTrailers) { - var videoId = this.extractYouTubeId(item.RemoteTrailers); - if (videoId && this.currentIndex === expectedIndex) { - this.startTrailerPreview(videoId); - } - return; - } + var settings = Storage.getAll(); + if (settings.mediaBarTrailerPreview) { + var currentIdx = this.currentIndex; + this.fetchAndPlayTrailer(item, currentIdx); + } + }, - var trailers = await API.getItemTrailers(item.Id); - if (this.currentIndex !== expectedIndex) return; - item.RemoteTrailers = trailers; - var videoId = this.extractYouTubeId(trailers); - if (videoId) { - this.startTrailerPreview(videoId); - } - }, + async fetchAndPlayTrailer(item, expectedIndex) { + if (item.RemoteTrailers) { + var videoId = this.extractYouTubeId(item.RemoteTrailers); + if (videoId && this.currentIndex === expectedIndex) { + this.startTrailerPreview(videoId); + } + return; + } - extractYouTubeId(trailers) { - if (!trailers || trailers.length === 0) return null; + var trailers = await API.getItemTrailers(item.Id); + if (this.currentIndex !== expectedIndex) return; + item.RemoteTrailers = trailers; + var videoId = this.extractYouTubeId(trailers); + if (videoId) { + this.startTrailerPreview(videoId); + } + }, - for (var i = 0; i < trailers.length; i++) { - var url = trailers[i].Url || trailers[i].url || ""; - var match = url.match( - /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/, - ); - if (match) return match[1]; - } - return null; - }, - - startTrailerPreview(videoId) { - var self = this; - this._trailerState = "resolving"; - this._trailerVideoId = videoId; - - this._ensureYTApi(function () { - if ( - self._trailerState !== "resolving" || - self._trailerVideoId !== videoId - ) - return; - self - .fetchSponsorSegments(videoId) - .then(function (segments) { - self._sponsorSegments = segments; - self._loadYTPlayer(videoId); - }) - .catch(function () { - self._sponsorSegments = []; - self._loadYTPlayer(videoId); - }); - }); - }, + extractYouTubeId(trailers) { + if (!trailers || trailers.length === 0) return null; - _ensureYTApi(callback) { - if (this._ytApiReady && window.YT && window.YT.Player) { - callback(); - return; - } - var self = this; - if (!this._ytApiLoading) { - this._ytApiLoading = true; - var tag = document.createElement("script"); - tag.src = "https://www.youtube.com/iframe_api"; - document.head.appendChild(tag); - } - var checkInterval = setInterval(function () { - if (window.YT && window.YT.Player) { - clearInterval(checkInterval); - self._ytApiReady = true; - self._ytApiLoading = false; - callback(); - } - }, 100); - setTimeout(function () { - clearInterval(checkInterval); - }, 10000); - }, - - _loadYTPlayer(videoId) { - if (this._trailerState !== "resolving") return; - - var self = this; - var startTime = this.getTrailerStartTime(this._sponsorSegments); - var trailerContainer = this.container.querySelector( - ".moonfin-mediabar-trailer-container", - ); - - if (this._trailerPlayer) { - try { - this._trailerPlayer.destroy(); - } catch (e) {} - this._trailerPlayer = null; - } + for (var i = 0; i < trailers.length; i++) { + var url = trailers[i].Url || trailers[i].url || ''; + var match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/); + if (match) return match[1]; + } + return null; + }, + + startTrailerPreview(videoId) { + var self = this; + this._trailerState = 'resolving'; + this._trailerVideoId = videoId; + + this._ensureYTApi(function () { + if (self._trailerState !== 'resolving' || self._trailerVideoId !== videoId) return; + self.fetchSponsorSegments(videoId) + .then(function (segments) { + self._sponsorSegments = segments; + self._loadYTPlayer(videoId); + }) + .catch(function () { + self._sponsorSegments = []; + self._loadYTPlayer(videoId); + }); + }); + }, - var playerDiv = document.createElement("div"); - playerDiv.id = "moonfin-yt-player-" + Date.now(); - playerDiv.className = "moonfin-mediabar-trailer-iframe"; - trailerContainer.innerHTML = ""; - trailerContainer.appendChild(playerDiv); - - this._trailerState = "playing"; - this.stopAutoAdvance(); - - try { - this._trailerPlayer = new YT.Player(playerDiv.id, { - videoId: videoId, - playerVars: { - autoplay: 1, - mute: 1, - controls: 0, - start: Math.floor(startTime), - rel: 0, - modestbranding: 1, - playsinline: 1, - showinfo: 0, - iv_load_policy: 3, - disablekb: 1, - fs: 0, - origin: window.location.origin, - }, - events: { - onReady: function (event) { - event.target.mute(); - event.target.playVideo(); - self._trailerRevealTimer = setTimeout(function () { - if (self._trailerState === "playing") { - var iframe = trailerContainer.querySelector("iframe"); - if (iframe) iframe.classList.add("visible"); - self.container.classList.add("trailer-active"); - } - }, self._trailerRevealMs); - }, - onStateChange: function (event) { - if (event.data === 0) { - self.stopTrailer(); - } - }, - onError: function (event) { - console.warn( - "[Moonfin] MediaBar: YouTube player error:", - event.data, - ); - self._trailerState = "unavailable"; - self.stopTrailer(); - }, - }, - }); - } catch (e) { - console.warn("[Moonfin] MediaBar: Failed to create YouTube player:", e); - this._trailerState = "unavailable"; - } - }, - - fetchSponsorSegments(videoId) { - return new Promise(function (resolve) { - var url = - "https://sponsor.ajay.app/api/skipSegments?videoID=" + - videoId + - '&categories=["sponsor","selfpromo","intro","outro","interaction","music_offtopic"]'; - - fetch(url) - .then(function (resp) { - if (!resp.ok) { - resolve([]); - return; - } - return resp.json(); - }) - .then(function (data) { - if (!Array.isArray(data)) { - resolve([]); + _ensureYTApi(callback) { + if (this._ytApiReady && window.YT && window.YT.Player) { + callback(); return; - } - var segments = []; - for (var i = 0; i < data.length; i++) { - if (data[i].segment && data[i].segment.length === 2) { - segments.push({ - start: data[i].segment[0], - end: data[i].segment[1], - }); + } + var self = this; + if (!this._ytApiLoading) { + this._ytApiLoading = true; + var tag = document.createElement('script'); + tag.src = 'https://www.youtube.com/iframe_api'; + document.head.appendChild(tag); + } + var checkInterval = setInterval(function () { + if (window.YT && window.YT.Player) { + clearInterval(checkInterval); + self._ytApiReady = true; + self._ytApiLoading = false; + callback(); } - } - resolve(segments); - }) - .catch(function () { - resolve([]); + }, 100); + setTimeout(function () { + clearInterval(checkInterval); + }, 10000); + }, + + _loadYTPlayer(videoId) { + if (this._trailerState !== 'resolving') return; + + var self = this; + var startTime = this.getTrailerStartTime(this._sponsorSegments); + var trailerContainer = this.container.querySelector('.moonfin-mediabar-trailer-container'); + + if (this._trailerPlayer) { + try { + this._trailerPlayer.destroy(); + } catch (e) {} + this._trailerPlayer = null; + } + + var playerDiv = document.createElement('div'); + playerDiv.id = 'moonfin-yt-player-' + Date.now(); + playerDiv.className = 'moonfin-mediabar-trailer-iframe'; + trailerContainer.innerHTML = ''; + trailerContainer.appendChild(playerDiv); + + this._trailerState = 'playing'; + this.stopAutoAdvance(); + + try { + this._trailerPlayer = new YT.Player(playerDiv.id, { + videoId: videoId, + playerVars: { + autoplay: 1, + mute: 1, + controls: 0, + start: Math.floor(startTime), + rel: 0, + modestbranding: 1, + playsinline: 1, + showinfo: 0, + iv_load_policy: 3, + disablekb: 1, + fs: 0, + origin: window.location.origin + }, + events: { + onReady: function (event) { + event.target.mute(); + event.target.playVideo(); + self._trailerRevealTimer = setTimeout(function () { + if (self._trailerState === 'playing') { + var iframe = trailerContainer.querySelector('iframe'); + if (iframe) iframe.classList.add('visible'); + self.container.classList.add('trailer-active'); + } + }, self._trailerRevealMs); + }, + onStateChange: function (event) { + if (event.data === 0) { + self.stopTrailer(); + } + }, + onError: function (event) { + console.warn('[Moonfin] MediaBar: YouTube player error:', event.data); + self._trailerState = 'unavailable'; + self.stopTrailer(); + } + } + }); + } catch (e) { + console.warn('[Moonfin] MediaBar: Failed to create YouTube player:', e); + this._trailerState = 'unavailable'; + } + }, + + fetchSponsorSegments(videoId) { + return new Promise(function (resolve) { + var url = + 'https://sponsor.ajay.app/api/skipSegments?videoID=' + + videoId + + '&categories=["sponsor","selfpromo","intro","outro","interaction","music_offtopic"]'; + + fetch(url) + .then(function (resp) { + if (!resp.ok) { + resolve([]); + return; + } + return resp.json(); + }) + .then(function (data) { + if (!Array.isArray(data)) { + resolve([]); + return; + } + var segments = []; + for (var i = 0; i < data.length; i++) { + if (data[i].segment && data[i].segment.length === 2) { + segments.push({ + start: data[i].segment[0], + end: data[i].segment[1] + }); + } + } + resolve(segments); + }) + .catch(function () { + resolve([]); + }); }); - }); - }, - - getTrailerStartTime(segments) { - var startTime = 0; - if (!segments || segments.length === 0) return startTime; - - var sorted = segments.slice().sort(function (a, b) { - return a.start - b.start; - }); - for (var i = 0; i < sorted.length; i++) { - if (sorted[i].start <= startTime + 1) { - startTime = Math.max(startTime, sorted[i].end); - } - } - return Math.max(startTime, 5); - }, + }, - stopTrailer() { - if (this._trailerRevealTimer) { - clearTimeout(this._trailerRevealTimer); - this._trailerRevealTimer = null; - } + getTrailerStartTime(segments) { + var startTime = 0; + if (!segments || segments.length === 0) return startTime; - if (this.container) this.container.classList.remove("trailer-active"); + var sorted = segments.slice().sort(function (a, b) { + return a.start - b.start; + }); + for (var i = 0; i < sorted.length; i++) { + if (sorted[i].start <= startTime + 1) { + startTime = Math.max(startTime, sorted[i].end); + } + } + return Math.max(startTime, 5); + }, - if (this._trailerPlayer) { - try { - this._trailerPlayer.destroy(); - } catch (e) {} - this._trailerPlayer = null; - } + stopTrailer() { + if (this._trailerRevealTimer) { + clearTimeout(this._trailerRevealTimer); + this._trailerRevealTimer = null; + } - var trailerContainer = this.container - ? this.container.querySelector(".moonfin-mediabar-trailer-container") - : null; - if (trailerContainer) trailerContainer.innerHTML = ""; + if (this.container) this.container.classList.remove('trailer-active'); - this._trailerState = "idle"; - this._trailerVideoId = null; - this._sponsorSegments = []; + if (this._trailerPlayer) { + try { + this._trailerPlayer.destroy(); + } catch (e) {} + this._trailerPlayer = null; + } - if (!this.isPaused) { - var settings = Storage.getAll(); - if (settings.mediaBarAutoAdvance && !this.autoAdvanceTimer) { - this.startAutoAdvance(); - } - } - }, + var trailerContainer = this.container + ? this.container.querySelector('.moonfin-mediabar-trailer-container') + : null; + if (trailerContainer) trailerContainer.innerHTML = ''; - updateBackdrop(url) { - var current = this.container.querySelector( - ".moonfin-mediabar-backdrop-current", - ); - var next = this.container.querySelector(".moonfin-mediabar-backdrop-next"); + this._trailerState = 'idle'; + this._trailerVideoId = null; + this._sponsorSegments = []; - if (!url) { - current.style.backgroundImage = ""; - return; - } + if (!this.isPaused) { + var settings = Storage.getAll(); + if (settings.mediaBarAutoAdvance && !this.autoAdvanceTimer) { + this.startAutoAdvance(); + } + } + }, - if (this._crossfadeTimer) { - clearTimeout(this._crossfadeTimer); - this._crossfadeTimer = null; - } + updateBackdrop(url) { + var current = this.container.querySelector('.moonfin-mediabar-backdrop-current'); + var next = this.container.querySelector('.moonfin-mediabar-backdrop-next'); - var img = new Image(); - var self = this; - var doSwap = function () { - next.style.transition = "none"; - next.classList.remove("active"); - next.style.backgroundImage = "url('" + url + "')"; - - void next.offsetWidth; - next.style.transition = ""; - next.classList.add("active"); - - self._crossfadeTimer = setTimeout(function () { - current.style.backgroundImage = "url('" + url + "')"; - next.style.transition = "none"; - next.classList.remove("active"); - void next.offsetWidth; - next.style.transition = ""; - self._crossfadeTimer = null; - }, 500); - }; - - img.onload = doSwap; - img.onerror = doSwap; - setTimeout(function () { - if (!img.complete) doSwap(); - }, 300); - img.src = url; - - this.preloadAdjacent(); - }, - - preloadAdjacent() { - if (!this.items || this.items.length < 2) return; - var nextIdx = (this.currentIndex + 1) % this.items.length; - var prevIdx = - (this.currentIndex - 1 + this.items.length) % this.items.length; - var nextUrl = API.getImageUrl(this.items[nextIdx], "Backdrop", { - maxWidth: 1920, - }); - var prevUrl = API.getImageUrl(this.items[prevIdx], "Backdrop", { - maxWidth: 1920, - }); - if (nextUrl) { - var i1 = new Image(); - i1.src = nextUrl; - } - if (prevUrl) { - var i2 = new Image(); - i2.src = prevUrl; - } - }, - - updateDots() { - var dotsContainer = this.container.querySelector(".moonfin-mediabar-dots"); - var html = ""; - for (var i = 0; i < this.items.length; i++) { - html += - ''; - } - dotsContainer.innerHTML = html; - }, + if (!url) { + current.style.backgroundImage = ''; + return; + } - updateActiveDot() { - var dots = this.container.querySelectorAll(".moonfin-mediabar-dot"); - for (var i = 0; i < dots.length; i++) { - dots[i].classList.toggle("active", i === this.currentIndex); - } - }, - - nextSlide() { - this.currentIndex = (this.currentIndex + 1) % this.items.length; - this.updateDisplay(); - this.resetAutoAdvance(); - }, - - prevSlide() { - this.currentIndex = - (this.currentIndex - 1 + this.items.length) % this.items.length; - this.updateDisplay(); - this.resetAutoAdvance(); - }, - - goToSlide(index) { - if (index >= 0 && index < this.items.length) { - this.currentIndex = index; - this.updateDisplay(); - this.resetAutoAdvance(); - } - }, + if (this._crossfadeTimer) { + clearTimeout(this._crossfadeTimer); + this._crossfadeTimer = null; + } - togglePause() { - this.isPaused = !this.isPaused; - this.container.classList.toggle("paused", this.isPaused); + var img = new Image(); + var self = this; + var doSwap = function () { + next.style.transition = 'none'; + next.classList.remove('active'); + next.style.backgroundImage = "url('" + url + "')"; + + void next.offsetWidth; + next.style.transition = ''; + next.classList.add('active'); + + self._crossfadeTimer = setTimeout(function () { + current.style.backgroundImage = "url('" + url + "')"; + next.style.transition = 'none'; + next.classList.remove('active'); + void next.offsetWidth; + next.style.transition = ''; + self._crossfadeTimer = null; + }, 500); + }; + + img.onload = doSwap; + img.onerror = doSwap; + setTimeout(function () { + if (!img.complete) doSwap(); + }, 300); + img.src = url; + + this.preloadAdjacent(); + }, + + preloadAdjacent() { + if (!this.items || this.items.length < 2) return; + var nextIdx = (this.currentIndex + 1) % this.items.length; + var prevIdx = (this.currentIndex - 1 + this.items.length) % this.items.length; + var nextUrl = API.getImageUrl(this.items[nextIdx], 'Backdrop', { + maxWidth: 1920 + }); + var prevUrl = API.getImageUrl(this.items[prevIdx], 'Backdrop', { + maxWidth: 1920 + }); + if (nextUrl) { + var i1 = new Image(); + i1.src = nextUrl; + } + if (prevUrl) { + var i2 = new Image(); + i2.src = prevUrl; + } + }, + + updateDots() { + var dotsContainer = this.container.querySelector('.moonfin-mediabar-dots'); + var html = ''; + for (var i = 0; i < this.items.length; i++) { + html += + ''; + } + dotsContainer.innerHTML = html; + }, - if (this.isPaused) { - this.stopAutoAdvance(); - } else { - this.startAutoAdvance(); - } - }, - - startAutoAdvance() { - var self = this; - var settings = Storage.getAll(); - if (!settings.mediaBarAutoAdvance) return; - - this.autoAdvanceTimer = setInterval(function () { - if (!self.isPaused && self.isVisible && self._trailerState === "idle") { - self.nextSlide(); - } - }, settings.mediaBarIntervalMs); - }, - - stopAutoAdvance() { - if (this.autoAdvanceTimer) { - clearInterval(this.autoAdvanceTimer); - this.autoAdvanceTimer = null; - } - }, + updateActiveDot() { + var dots = this.container.querySelectorAll('.moonfin-mediabar-dot'); + for (var i = 0; i < dots.length; i++) { + dots[i].classList.toggle('active', i === this.currentIndex); + } + }, + + nextSlide() { + this.currentIndex = (this.currentIndex + 1) % this.items.length; + this.updateDisplay(); + this.resetAutoAdvance(); + }, + + prevSlide() { + this.currentIndex = (this.currentIndex - 1 + this.items.length) % this.items.length; + this.updateDisplay(); + this.resetAutoAdvance(); + }, + + goToSlide(index) { + if (index >= 0 && index < this.items.length) { + this.currentIndex = index; + this.updateDisplay(); + this.resetAutoAdvance(); + } + }, - resetAutoAdvance() { - this.stopAutoAdvance(); - if (!this.isPaused) { - this.startAutoAdvance(); - } - }, + togglePause() { + this.isPaused = !this.isPaused; + this.container.classList.toggle('paused', this.isPaused); - ensureInDOM() { - if (this.container && !document.body.contains(this.container)) { - document.body.appendChild(this.container); - } - }, - - setupEventListeners() { - var self = this; - - this.container - .querySelector(".moonfin-mediabar-prev") - .addEventListener("click", function (e) { - e.stopPropagation(); - self.prevSlide(); - }); - - this.container - .querySelector(".moonfin-mediabar-next") - .addEventListener("click", function (e) { - e.stopPropagation(); - self.nextSlide(); - }); - - this.container - .querySelector(".moonfin-mediabar-dots") - .addEventListener("click", function (e) { - e.stopPropagation(); - var dot = e.target.closest(".moonfin-mediabar-dot"); - if (dot) { - self.goToSlide(parseInt(dot.dataset.index, 10)); - } - }); - - this.container.addEventListener("click", function (e) { - if ( - e.target.closest( - ".moonfin-mediabar-nav-btn, .moonfin-mediabar-dots, .moonfin-mediabar-dots-wrap", - ) - ) { - return; - } - var item = self.items[self.currentIndex]; - if (item) { - if (Storage.get("detailsPageEnabled")) { - Details.showDetails(item.Id, item.Type); + if (this.isPaused) { + this.stopAutoAdvance(); } else { - API.navigateToItem(item.Id); - } - } - }); - - var touchStartX = 0; - var touchStartY = 0; - var touchMoved = false; - - this.container.addEventListener( - "touchstart", - function (e) { - var touch = e.touches[0]; - touchStartX = touch.clientX; - touchStartY = touch.clientY; - touchMoved = false; - }, - { passive: true }, - ); - - this.container.addEventListener( - "touchmove", - function (e) { - if (!touchStartX) return; - var dx = Math.abs(e.touches[0].clientX - touchStartX); - var dy = Math.abs(e.touches[0].clientY - touchStartY); - if (dx > 10 || dy > 10) touchMoved = true; - if (dx > dy && dx > 10) e.preventDefault(); - }, - { passive: false }, - ); - - this.container.addEventListener( - "touchend", - function (e) { - if (!touchMoved) { - touchStartX = 0; - return; - } - var dx = e.changedTouches[0].clientX - touchStartX; - if (Math.abs(dx) >= 50) { - if (dx < 0) self.nextSlide(); - else self.prevSlide(); - } - touchStartX = 0; - touchMoved = false; - }, - { passive: true }, - ); - - this.container.addEventListener("keydown", function (e) { - switch (e.key) { - case "ArrowLeft": - self.prevSlide(); - e.preventDefault(); - break; - case "ArrowRight": - self.nextSlide(); - e.preventDefault(); - break; - case " ": - self.togglePause(); - e.preventDefault(); - break; - case "Enter": - var item = self.items[self.currentIndex]; - if (item) { - if (Storage.get("detailsPageEnabled")) { - Details.showDetails(item.Id, item.Type); - } else { - API.navigateToItem(item.Id); + this.startAutoAdvance(); + } + }, + + startAutoAdvance() { + var self = this; + var settings = Storage.getAll(); + if (!settings.mediaBarAutoAdvance) return; + + this.autoAdvanceTimer = setInterval(function () { + if (!self.isPaused && self.isVisible && self._trailerState === 'idle') { + self.nextSlide(); } - } - e.preventDefault(); - break; - } - }); - - this.container.addEventListener("mouseenter", function () { - self.container.classList.add("focused"); - }); - - this.container.addEventListener("mouseleave", function () { - self.container.classList.remove("focused"); - }); - - document.addEventListener("visibilitychange", function () { - self.isVisible = !document.hidden; - if (document.hidden) { - self.stopTrailer(); - } - }); - - window.addEventListener("moonfin-settings-changed", function (e) { - self.applySettings(e.detail); - }); - }, - - applySettings(settings) { - if (!this.container) return; - - if (!settings.mediaBarEnabled) { - this.hide(); - return; - } else { - this.show(); - } + }, settings.mediaBarIntervalMs); + }, - var overlayColor = Storage.getColorRgba( - settings.mediaBarOverlayColor, - settings.mediaBarOverlayOpacity, - ); + stopAutoAdvance() { + if (this.autoAdvanceTimer) { + clearInterval(this.autoAdvanceTimer); + this.autoAdvanceTimer = null; + } + }, - var infoBox = this.container.querySelector(".moonfin-mediabar-info"); - if (infoBox) infoBox.style.background = overlayColor; + resetAutoAdvance() { + this.stopAutoAdvance(); + if (!this.isPaused) { + this.startAutoAdvance(); + } + }, - this.container - .querySelectorAll(".moonfin-mediabar-nav-btn") - .forEach(function (btn) { - btn.style.background = overlayColor; - }); + ensureInDOM() { + if (this.container && !document.body.contains(this.container)) { + document.body.appendChild(this.container); + } + }, - var dotsWrap = this.container.querySelector(".moonfin-mediabar-dots-wrap"); - if (dotsWrap) dotsWrap.style.background = overlayColor; + setupEventListeners() { + var self = this; - this.updateDots(); - this.resetAutoAdvance(); + this.container.querySelector('.moonfin-mediabar-prev').addEventListener('click', function (e) { + e.stopPropagation(); + self.prevSlide(); + }); - if (!settings.mediaBarTrailerPreview) { - this.stopTrailer(); - } + this.container.querySelector('.moonfin-mediabar-next').addEventListener('click', function (e) { + e.stopPropagation(); + self.nextSlide(); + }); - if ( - this._lastItemCount !== settings.mediaBarItemCount || - this._lastSourceType !== settings.mediaBarSourceType || - this._lastShuffleItems !== settings.mediaBarShuffleItems || - JSON.stringify(this._lastCollectionIds) !== - JSON.stringify(settings.mediaBarCollectionIds) || - JSON.stringify(this._lastLibraryIds) !== - JSON.stringify(settings.mediaBarLibraryIds) - ) { - this._lastItemCount = settings.mediaBarItemCount; - this._lastSourceType = settings.mediaBarSourceType; - this._lastCollectionIds = settings.mediaBarCollectionIds; - this._lastShuffleItems = settings.mediaBarShuffleItems; - this._lastLibraryIds = settings.mediaBarLibraryIds; - this.loadContent(); - } - }, - - show() { - if (this.container) { - this.container.classList.remove("disabled"); - if (Plugin.isHomePage()) { - document.body.classList.add("moonfin-mediabar-active"); - } - } - }, + this.container.querySelector('.moonfin-mediabar-dots').addEventListener('click', function (e) { + e.stopPropagation(); + var dot = e.target.closest('.moonfin-mediabar-dot'); + if (dot) { + self.goToSlide(parseInt(dot.dataset.index, 10)); + } + }); - hide() { - if (this.container) { - this.container.classList.add("disabled"); - document.body.classList.remove("moonfin-mediabar-active"); - this.stopTrailer(); - } - }, - - async refresh() { - this.currentIndex = 0; - await this.loadContent(); - }, - - destroy() { - this.stopAutoAdvance(); - this.stopTrailer(); - if (this.container) { - this.container.remove(); - this.container = null; + this.container.addEventListener('click', function (e) { + if (e.target.closest('.moonfin-mediabar-nav-btn, .moonfin-mediabar-dots, .moonfin-mediabar-dots-wrap')) { + return; + } + var item = self.items[self.currentIndex]; + if (item) { + if (Storage.get('detailsPageEnabled')) { + Details.showDetails(item.Id, item.Type); + } else { + API.navigateToItem(item.Id); + } + } + }); + + var touchStartX = 0; + var touchStartY = 0; + var touchMoved = false; + + this.container.addEventListener( + 'touchstart', + function (e) { + var touch = e.touches[0]; + touchStartX = touch.clientX; + touchStartY = touch.clientY; + touchMoved = false; + }, + { passive: true } + ); + + this.container.addEventListener( + 'touchmove', + function (e) { + if (!touchStartX) return; + var dx = Math.abs(e.touches[0].clientX - touchStartX); + var dy = Math.abs(e.touches[0].clientY - touchStartY); + if (dx > 10 || dy > 10) touchMoved = true; + if (dx > dy && dx > 10) e.preventDefault(); + }, + { passive: false } + ); + + this.container.addEventListener( + 'touchend', + function (e) { + if (!touchMoved) { + touchStartX = 0; + return; + } + var dx = e.changedTouches[0].clientX - touchStartX; + if (Math.abs(dx) >= 50) { + if (dx < 0) self.nextSlide(); + else self.prevSlide(); + } + touchStartX = 0; + touchMoved = false; + }, + { passive: true } + ); + + this.container.addEventListener('keydown', function (e) { + switch (e.key) { + case 'ArrowLeft': + self.prevSlide(); + e.preventDefault(); + break; + case 'ArrowRight': + self.nextSlide(); + e.preventDefault(); + break; + case ' ': + self.togglePause(); + e.preventDefault(); + break; + case 'Enter': + var item = self.items[self.currentIndex]; + if (item) { + if (Storage.get('detailsPageEnabled')) { + Details.showDetails(item.Id, item.Type); + } else { + API.navigateToItem(item.Id); + } + } + e.preventDefault(); + break; + } + }); + + this.container.addEventListener('mouseenter', function () { + self.container.classList.add('focused'); + }); + + this.container.addEventListener('mouseleave', function () { + self.container.classList.remove('focused'); + }); + + document.addEventListener('visibilitychange', function () { + self.isVisible = !document.hidden; + if (document.hidden) { + self.stopTrailer(); + } + }); + + window.addEventListener('moonfin-settings-changed', function (e) { + self.applySettings(e.detail); + }); + }, + + applySettings(settings) { + if (!this.container) return; + + if (!settings.mediaBarEnabled) { + this.hide(); + return; + } else { + this.show(); + } + + var overlayColor = Storage.getColorRgba(settings.mediaBarOverlayColor, settings.mediaBarOverlayOpacity); + + var infoBox = this.container.querySelector('.moonfin-mediabar-info'); + if (infoBox) infoBox.style.background = overlayColor; + + this.container.querySelectorAll('.moonfin-mediabar-nav-btn').forEach(function (btn) { + btn.style.background = overlayColor; + }); + + var dotsWrap = this.container.querySelector('.moonfin-mediabar-dots-wrap'); + if (dotsWrap) dotsWrap.style.background = overlayColor; + + this.updateDots(); + this.resetAutoAdvance(); + + if (!settings.mediaBarTrailerPreview) { + this.stopTrailer(); + } + + if ( + this._lastItemCount !== settings.mediaBarItemCount || + this._lastSourceType !== settings.mediaBarSourceType || + this._lastShuffleItems !== settings.mediaBarShuffleItems || + JSON.stringify(this._lastCollectionIds) !== JSON.stringify(settings.mediaBarCollectionIds) || + JSON.stringify(this._lastLibraryIds) !== JSON.stringify(settings.mediaBarLibraryIds) + ) { + this._lastItemCount = settings.mediaBarItemCount; + this._lastSourceType = settings.mediaBarSourceType; + this._lastCollectionIds = settings.mediaBarCollectionIds; + this._lastShuffleItems = settings.mediaBarShuffleItems; + this._lastLibraryIds = settings.mediaBarLibraryIds; + this.loadContent(); + } + }, + + show() { + if (this.container) { + this.container.classList.remove('disabled'); + if (Plugin.isHomePage()) { + document.body.classList.add('moonfin-mediabar-active'); + } + } + }, + + hide() { + if (this.container) { + this.container.classList.add('disabled'); + document.body.classList.remove('moonfin-mediabar-active'); + this.stopTrailer(); + } + }, + + async refresh() { + this.currentIndex = 0; + await this.loadContent(); + }, + + destroy() { + this.stopAutoAdvance(); + this.stopTrailer(); + if (this.container) { + this.container.remove(); + this.container = null; + } + document.body.classList.remove('moonfin-mediabar-active'); + this.initialized = false; + this.items = []; + this.currentIndex = 0; } - document.body.classList.remove("moonfin-mediabar-active"); - this.initialized = false; - this.items = []; - this.currentIndex = 0; - }, }; diff --git a/frontend/src/components/settings.js b/frontend/src/components/settings.js index 2518154..dce202e 100644 --- a/frontend/src/components/settings.js +++ b/frontend/src/components/settings.js @@ -1,1649 +1,1478 @@ var Settings = { - dialog: null, - isOpen: false, - _toastTimeout: null, - - show: function () { - if (this.isOpen) return; - - this.createDialog(); - // Trigger animation after append - var self = this; - requestAnimationFrame(function () { - requestAnimationFrame(function () { - if (self.dialog) { - self.dialog.classList.add("open"); + dialog: null, + isOpen: false, + _toastTimeout: null, + + show: function () { + if (this.isOpen) return; + + this.createDialog(); + // Trigger animation after append + var self = this; + requestAnimationFrame(function () { + requestAnimationFrame(function () { + if (self.dialog) { + self.dialog.classList.add('open'); + } + }); + }); + this.isOpen = true; + history.pushState({ moonfinSettings: true }, ''); + if (window.Moonfin && window.Moonfin.Plugin) window.Moonfin.Plugin._overlayHistoryDepth++; + else if (typeof Plugin !== 'undefined') Plugin._overlayHistoryDepth++; + }, + + hide: function (skipHistoryBack) { + if (!this.isOpen) return; + var self = this; + + this.isOpen = false; + + this.dialog.classList.remove('open'); + setTimeout(function () { + if (self.dialog) { + self.dialog.remove(); + self.dialog = null; + } + }, 300); + + if (!skipHistoryBack) { + try { + history.back(); + } catch (e) {} } - }); - }); - this.isOpen = true; - history.pushState({ moonfinSettings: true }, ""); - if (window.Moonfin && window.Moonfin.Plugin) - window.Moonfin.Plugin._overlayHistoryDepth++; - else if (typeof Plugin !== "undefined") Plugin._overlayHistoryDepth++; - }, - - hide: function (skipHistoryBack) { - if (!this.isOpen) return; - var self = this; - - this.isOpen = false; - - this.dialog.classList.remove("open"); - setTimeout(function () { - if (self.dialog) { - self.dialog.remove(); - self.dialog = null; - } - }, 300); - - if (!skipHistoryBack) { - try { - history.back(); - } catch (e) {} - } - }, - - showToast: function (message) { - var existing = document.querySelector(".moonfin-toast"); - if (existing) existing.remove(); - - var toast = document.createElement("div"); - toast.className = "moonfin-toast"; - toast.textContent = message; - document.body.appendChild(toast); - - requestAnimationFrame(function () { - toast.classList.add("visible"); - }); - - if (this._toastTimeout) clearTimeout(this._toastTimeout); - this._toastTimeout = setTimeout(function () { - toast.classList.remove("visible"); - setTimeout(function () { - toast.remove(); - }, 300); - }, 2000); - }, - - saveSetting: function (name, value) { - var profileName = Storage.getActiveEditProfile(); - var profile = Storage.getProfile(profileName); - profile[name] = value; - Storage.saveProfile(profileName, profile); - var safeValue = - name.toLowerCase().indexOf("apikey") !== -1 || - name.toLowerCase().indexOf("token") !== -1 - ? "***" - : value; - console.log( - '[Moonfin] Setting saved to profile "' + profileName + '":', - name, - "=", - safeValue, - ); - }, - - createToggleCard: function (id, title, description, checked) { - return ( - '
' + - '" + - "
" - ); - }, - - createSelectCard: function (id, title, description, options, currentValue) { - var optionsHtml = ""; - for (var i = 0; i < options.length; i++) { - var opt = options[i]; - optionsHtml += - '"; - } + }, - return ( - '
' + - '
' + - '
' + - title + - "
" + - (description - ? '
' + description + "
" - : "") + - "
" + - '" + - "
" - ); - }, - - createRangeCard: function ( - id, - title, - description, - min, - max, - step, - currentValue, - suffix, - ) { - return ( - '
' + - '
' + - '
' + - title + - ' ' + - currentValue + - (suffix || "") + - "
" + - (description - ? '
' + description + "
" - : "") + - "
" + - '' + - "
" - ); - }, - - createSection: function (icon, title, contentHtml, openByDefault) { - return ( - '
" + - '' + - (icon ? icon + " " : "") + - title + - "" + - '
' + - contentHtml + - "
" + - "
" - ); - }, - - createDialog: function () { - var existing = document.querySelector(".moonfin-settings-dialog"); - if (existing) existing.remove(); - - var settings = Storage.getAll(Storage.getActiveEditProfile()); - var self = this; - - this.dialog = document.createElement("div"); - this.dialog.className = "moonfin-settings-dialog"; - - var uiContent = - this.createToggleCard( - "navbarEnabled", - "Navigation Bar", - "Show the custom navigation bar with quick access buttons", - settings.navbarEnabled, - ) + - this.createSelectCard( - "navbarPosition", - "Navbar Position", - "Show the navigation bar at the top or as a left sidebar", - [ - { value: "top", label: "Top" }, - { value: "left", label: "Left (Sidebar)" }, - ], - settings.navbarPosition, - ) + - this.createToggleCard( - "mediaBarEnabled", - "Media Bar", - "Show the featured media carousel on the home page", - settings.mediaBarEnabled, - ) + - this.createToggleCard( - "detailsPageEnabled", - "Details Page", - "Use the custom Moonfin details page instead of the default Jellyfin one", - settings.detailsPageEnabled, - ); - - var mediaBarContent = - this.createSelectCard( - "mediaBarSourceType", - "Content Source", - "Where to pull media bar items from", - [ - { value: "library", label: "Library (Random)" }, - { value: "collection", label: "Collections / Playlists" }, - ], - settings.mediaBarSourceType, - ) + - '
' + - '
' + - '
' + - '
Libraries
' + - '
Optionally select which libraries to use. Leave all unchecked to use all libraries.
' + - "
" + - '
' + - '
Loading...
' + - "
" + - "
" + - "
" + - '
' + - '
' + - '
' + - '
Collections / Playlists
' + - '
Select which collections or playlists to show in the media bar
' + - "
" + - '
' + - '
Loading...
' + - "
" + - "
" + - this.createToggleCard( - "mediaBarShuffleItems", - "Shuffle Items", - "Randomize the order of items from selected collections", - settings.mediaBarShuffleItems !== false, - ) + - "
" + - this.createSelectCard( - "mediaBarItemCount", - "Number of Items", - "How many items to display", - [ - { value: "5", label: "5" }, - { value: "10", label: "10" }, - { value: "15", label: "15" }, - { value: "20", label: "20" }, - ], - settings.mediaBarItemCount, - ) + - this.createToggleCard( - "mediaBarTrailerPreview", - "Trailer Preview", - "Automatically play muted trailer previews in the media bar background", - settings.mediaBarTrailerPreview, - ); - - var colorOptions = []; - var colorKeys = Object.keys(Storage.colorOptions); - for (var i = 0; i < colorKeys.length; i++) { - colorOptions.push({ - value: colorKeys[i], - label: Storage.colorOptions[colorKeys[i]].name, - }); - } + showToast: function (message) { + var existing = document.querySelector('.moonfin-toast'); + if (existing) existing.remove(); - var overlayContent = - this.createSelectCard( - "mediaBarOverlayColor", - "Overlay Color", - "Color of the gradient overlay on media bar items", - colorOptions, - settings.mediaBarOverlayColor, - ) + - '
' + - this.createRangeCard( - "mediaBarOverlayOpacity", - "Overlay Opacity", - "Transparency of the gradient overlay", - 0, - 100, - 5, - settings.mediaBarOverlayOpacity, - "%", - ); - - var toolbarContent = - this.createToggleCard( - "showShuffleButton", - "Shuffle Button", - "Show random content button in the toolbar", - settings.showShuffleButton, - ) + - this.createSelectCard( - "shuffleContentType", - "Shuffle Content Type", - "What type of content to shuffle", - [ - { value: "both", label: "Movies & TV Shows" }, - { value: "movies", label: "Movies Only" }, - { value: "tv", label: "TV Shows Only" }, - ], - settings.shuffleContentType, - ) + - this.createToggleCard( - "showGenresButton", - "Genres Button", - "Show genres dropdown in the toolbar", - settings.showGenresButton, - ) + - this.createToggleCard( - "showFavoritesButton", - "Favorites Button", - "Show favorites button in the toolbar", - settings.showFavoritesButton, - ) + - this.createToggleCard( - "showCastButton", - "Cast Button", - "Show Chromecast button in the toolbar", - settings.showCastButton, - ) + - this.createToggleCard( - "showSyncPlayButton", - "SyncPlay Button", - "Show SyncPlay button in the toolbar", - settings.showSyncPlayButton, - ) + - this.createToggleCard( - "showLibrariesInToolbar", - "Library Shortcuts", - "Show library quick links in the toolbar", - settings.showLibrariesInToolbar, - ); - - var seasonalOptions = []; - var seasonKeys = Object.keys(Storage.seasonalOptions); - for (var j = 0; j < seasonKeys.length; j++) { - seasonalOptions.push({ - value: seasonKeys[j], - label: Storage.seasonalOptions[seasonKeys[j]].name, - }); - } + var toast = document.createElement('div'); + toast.className = 'moonfin-toast'; + toast.textContent = message; + document.body.appendChild(toast); - var displayContent = - this.createToggleCard( - "showClock", - "Clock", - "Show a clock in the navigation bar", - settings.showClock, - ) + - this.createToggleCard( - "use24HourClock", - "24-Hour Format", - "Use 24-hour time format instead of 12-hour", - settings.use24HourClock, - ) + - this.createSelectCard( - "seasonalSurprise", - "Seasonal Effect", - "Add a seasonal visual effect to the interface", - seasonalOptions, - settings.seasonalSurprise, - ); - - var jellyseerrContent = - '
' + - '
' + - '' + - 'Checking...' + - "
" + - "
" + - '" + - '"; - - var mdblistSources = [ - { key: "imdb", label: "IMDb" }, - { key: "tmdb", label: "TMDb" }, - { key: "trakt", label: "Trakt" }, - { key: "tomatoes", label: "Rotten Tomatoes (Critics)" }, - { key: "popcorn", label: "Rotten Tomatoes (Audience)" }, - { key: "metacritic", label: "Metacritic" }, - { key: "metacriticuser", label: "Metacritic User" }, - { key: "letterboxd", label: "Letterboxd" }, - { key: "rogerebert", label: "Roger Ebert" }, - { key: "myanimelist", label: "MyAnimeList" }, - { key: "anilist", label: "AniList" }, - ]; - var selectedSources = settings.mdblistRatingSources || [ - "imdb", - "tmdb", - "tomatoes", - "metacritic", - ]; - var serverUrl = - (window.ApiClient && window.ApiClient.serverAddress - ? window.ApiClient.serverAddress() - : "") || ""; - var sourceIconFiles = { - imdb: "imdb.svg", - tmdb: "tmdb.svg", - trakt: "trakt.svg", - tomatoes: "rt-fresh.svg", - popcorn: "rt-audience-up.svg", - metacritic: "metacritic.svg", - metacriticuser: "metacritic-user.svg", - letterboxd: "letterboxd.svg", - rogerebert: "rogerebert.svg", - myanimelist: "mal.svg", - anilist: "anilist.svg", - }; - - // Build ordered list: enabled sources first (in saved order), then disabled - var orderedSources = []; - for (var oi = 0; oi < selectedSources.length; oi++) { - for (var oj = 0; oj < mdblistSources.length; oj++) { - if (mdblistSources[oj].key === selectedSources[oi]) { - orderedSources.push({ - key: mdblistSources[oj].key, - label: mdblistSources[oj].label, - enabled: true, - }); - break; - } - } - } - for (var uk = 0; uk < mdblistSources.length; uk++) { - if (selectedSources.indexOf(mdblistSources[uk].key) === -1) { - orderedSources.push({ - key: mdblistSources[uk].key, - label: mdblistSources[uk].label, - enabled: false, + requestAnimationFrame(function () { + toast.classList.add('visible'); }); - } - } - - var sourceItems = ""; - for (var si = 0; si < orderedSources.length; si++) { - var src = orderedSources[si]; - var iconUrl = - serverUrl + - "/Moonfin/Assets/" + - (sourceIconFiles[src.key] || "imdb.svg"); - sourceItems += - '
' + - '' + - '' + - "" + - '' +
-        src.label +
-        '' + - '' + - src.label + - "" + - '" + - "
"; - } - var tmdbContent = - this.createToggleCard( - "tmdbEpisodeRatingsEnabled", - "Enable Episode Ratings", - "Show TMDB ratings for individual TV episodes on the details page", - settings.tmdbEpisodeRatingsEnabled, - ) + - '
' + - (Storage.syncState.tmdbAvailable - ? '
' + - "Your server admin has provided a server-wide TMDB API key. You can leave the field below blank to use it, or enter your own key." + - "
" - : "") + - '
' + - '' + - '' + - '
Get a free API key at themoviedb.org/settings/api
' + - "
" + - "
"; - - var mdblistContent = - this.createToggleCard( - "mdblistEnabled", - "Enable MDBList Ratings", - "Show ratings from MDBList (IMDb, Rotten Tomatoes, Metacritic, etc.) on media bar and item details", - settings.mdblistEnabled, - ) + - '
' + - this.createToggleCard( - "mdblistShowRatingNames", - "Show Rating Source Names", - "Display the name of each rating source below its score", - settings.mdblistShowRatingNames, - ) + - (Storage.syncState.mdblistAvailable - ? '
' + - "Your server admin has provided a server-wide MDBList API key. You can leave the field below blank to use it, or enter your own key." + - "
" - : "") + - '
' + - '' + - '' + - '
Get your free API key at mdblist.com/preferences
' + - "
" + - '
' + - '' + - '

Drag to reorder. Click the icon on the right to enable or disable a source.

' + - '
' + - sourceItems + - "
" + - "
" + - "
"; - - var currentDeviceProfile = Device.getProfileName(); - var profileLabels = { - global: "All Devices", - desktop: "Desktop", - mobile: "Mobile", - tv: "TV", - }; - var profileTabsHtml = '
'; - var profileNames = ["global", "desktop", "mobile", "tv"]; - for (var pi = 0; pi < profileNames.length; pi++) { - var pn = profileNames[pi]; - var isActive = pn === "global"; - var isCurrent = pn === currentDeviceProfile; - profileTabsHtml += - '"; - } - profileTabsHtml += "
"; - var profileInfoHtml = - '
' + - ' ' + - '"All Devices" settings apply everywhere. Device profiles override only the settings you change.' + - "
"; - - this.dialog.innerHTML = - '
' + - '
' + - '
' + - '
' + - "

Moonfin

" + - 'Settings' + - "
" + - '" + - "
" + - profileTabsHtml + - profileInfoHtml + - '
' + - this.createSection("", "Moonfin UI", uiContent, true) + - this.createSection("", "Media Bar", mediaBarContent) + - this.createSection("", "Overlay Appearance", overlayContent) + - this.createSection("", "Toolbar Buttons", toolbarContent) + - this.createSection("", "Display", displayContent) + - this.createSection("", "TMDB Episode Ratings", tmdbContent) + - this.createSection("", "MDBList Ratings", mdblistContent) + - '" + - "
" + - '" + - "
"; - - document.body.appendChild(this.dialog); - Storage.setActiveEditProfile("global"); - this.setupEventListeners(); - this.updateSyncStatus(); - this.updateJellyseerrSsoSection(); - }, - - refreshFormValues: function (profileName) { - if (!this.dialog) return; - var resolved = Storage.resolveSettings(profileName); - var raw = profileName !== "global" ? Storage.getProfile(profileName) : null; - - // Update checkboxes - var checkboxes = this.dialog.querySelectorAll( - 'input[type="checkbox"][name]', - ); - for (var i = 0; i < checkboxes.length; i++) { - var name = checkboxes[i].name; - if (name in resolved) { - checkboxes[i].checked = resolved[name]; - // Visual indicator: dim if inherited from global/defaults on a device profile - var isInherited = - raw !== null && (raw[name] === undefined || raw[name] === null); - var card = checkboxes[i].closest(".moonfin-toggle-card"); - if (card) { - card.classList.toggle("moonfin-inherited", isInherited); - } - } - } - - // Update selects - var selects = this.dialog.querySelectorAll("select[name]"); - for (var j = 0; j < selects.length; j++) { - var sName = selects[j].name; - if (sName in resolved) { - selects[j].value = String(resolved[sName]); - var sCard = selects[j].closest(".moonfin-select-card"); - if (sCard && raw !== null) { - sCard.classList.toggle( - "moonfin-inherited", - raw[sName] === undefined || raw[sName] === null, - ); + if (this._toastTimeout) clearTimeout(this._toastTimeout); + this._toastTimeout = setTimeout(function () { + toast.classList.remove('visible'); + setTimeout(function () { + toast.remove(); + }, 300); + }, 2000); + }, + + saveSetting: function (name, value) { + var profileName = Storage.getActiveEditProfile(); + var profile = Storage.getProfile(profileName); + profile[name] = value; + Storage.saveProfile(profileName, profile); + var safeValue = + name.toLowerCase().indexOf('apikey') !== -1 || name.toLowerCase().indexOf('token') !== -1 ? '***' : value; + console.log('[Moonfin] Setting saved to profile "' + profileName + '":', name, '=', safeValue); + }, + + createToggleCard: function (id, title, description, checked) { + return ( + '
' + + '' + + '
' + ); + }, + + createSelectCard: function (id, title, description, options, currentValue) { + var optionsHtml = ''; + for (var i = 0; i < options.length; i++) { + var opt = options[i]; + optionsHtml += + ''; } - } - } - // Update ranges - var ranges = this.dialog.querySelectorAll('input[type="range"][name]'); - for (var k = 0; k < ranges.length; k++) { - var rName = ranges[k].name; - if (rName in resolved) { - ranges[k].value = resolved[rName]; - var valueSpan = this.dialog.querySelector( - '.moonfin-range-value[data-for="' + rName + '"]', + return ( + '
' + + '
' + + '
' + + title + + '
' + + (description ? '
' + description + '
' : '') + + '
' + + '' + + '
' ); - if (valueSpan) valueSpan.textContent = resolved[rName] + "%"; - } - } - - // Update text/password inputs - var textInputs = [ - { id: "moonfin-mdblistApiKey", key: "mdblistApiKey" }, - { id: "moonfin-tmdbApiKey", key: "tmdbApiKey" }, - ]; - for (var ti = 0; ti < textInputs.length; ti++) { - var inp = this.dialog.querySelector("#" + textInputs[ti].id); - if (inp) inp.value = resolved[textInputs[ti].key] || ""; - } + }, + + createRangeCard: function (id, title, description, min, max, step, currentValue, suffix) { + return ( + '
' + + '
' + + '
' + + title + + ' ' + + currentValue + + (suffix || '') + + '
' + + (description ? '
' + description + '
' : '') + + '
' + + '' + + '
' + ); + }, + + createSection: function (icon, title, contentHtml, openByDefault) { + return ( + '
' + + '' + + (icon ? icon + ' ' : '') + + title + + '' + + '
' + + contentHtml + + '
' + + '
' + ); + }, + + createDialog: function () { + var existing = document.querySelector('.moonfin-settings-dialog'); + if (existing) existing.remove(); + + var settings = Storage.getAll(Storage.getActiveEditProfile()); + var self = this; + + this.dialog = document.createElement('div'); + this.dialog.className = 'moonfin-settings-dialog'; + + var uiContent = + this.createToggleCard( + 'navbarEnabled', + 'Navigation Bar', + 'Show the custom navigation bar with quick access buttons', + settings.navbarEnabled + ) + + this.createSelectCard( + 'navbarPosition', + 'Navbar Position', + 'Show the navigation bar at the top or as a left sidebar', + [ + { value: 'top', label: 'Top' }, + { value: 'left', label: 'Left (Sidebar)' } + ], + settings.navbarPosition + ) + + this.createToggleCard( + 'mediaBarEnabled', + 'Media Bar', + 'Show the featured media carousel on the home page', + settings.mediaBarEnabled + ) + + this.createToggleCard( + 'detailsPageEnabled', + 'Details Page', + 'Use the custom Moonfin details page instead of the default Jellyfin one', + settings.detailsPageEnabled + ); - // Update color preview - var colorPreview = this.dialog.querySelector("#moonfin-color-preview"); - if (colorPreview) { - colorPreview.style.background = Storage.getColorHex( - resolved.mediaBarOverlayColor, - ); - } + var mediaBarContent = + this.createSelectCard( + 'mediaBarSourceType', + 'Content Source', + 'Where to pull media bar items from', + [ + { value: 'library', label: 'Library (Random)' }, + { value: 'collection', label: 'Collections / Playlists' } + ], + settings.mediaBarSourceType + ) + + '
' + + '
' + + '
' + + '
Libraries
' + + '
Optionally select which libraries to use. Leave all unchecked to use all libraries.
' + + '
' + + '
' + + '
Loading...
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
Collections / Playlists
' + + '
Select which collections or playlists to show in the media bar
' + + '
' + + '
' + + '
Loading...
' + + '
' + + '
' + + this.createToggleCard( + 'mediaBarShuffleItems', + 'Shuffle Items', + 'Randomize the order of items from selected collections', + settings.mediaBarShuffleItems !== false + ) + + '
' + + this.createSelectCard( + 'mediaBarItemCount', + 'Number of Items', + 'How many items to display', + [ + { value: '5', label: '5' }, + { value: '10', label: '10' }, + { value: '15', label: '15' }, + { value: '20', label: '20' } + ], + settings.mediaBarItemCount + ) + + this.createToggleCard( + 'mediaBarTrailerPreview', + 'Trailer Preview', + 'Automatically play muted trailer previews in the media bar background', + settings.mediaBarTrailerPreview + ); - // Toggle config sub-sections - var mdblistConfig = this.dialog.querySelector(".moonfin-mdblist-config"); - if (mdblistConfig) - mdblistConfig.style.display = resolved.mdblistEnabled ? "" : "none"; - var tmdbConfig = this.dialog.querySelector(".moonfin-tmdb-config"); - if (tmdbConfig) - tmdbConfig.style.display = resolved.tmdbEpisodeRatingsEnabled - ? "" - : "none"; - - // Toggle media bar source sub-sections - var isCollection = resolved.mediaBarSourceType === "collection"; - var libraryOpts = this.dialog.querySelector( - ".moonfin-mediabar-library-options", - ); - var collectionOpts = this.dialog.querySelector( - ".moonfin-mediabar-collection-options", - ); - if (libraryOpts) libraryOpts.style.display = isCollection ? "none" : ""; - if (collectionOpts) - collectionOpts.style.display = isCollection ? "" : "none"; - if (isCollection) this.loadCollectionPicker(); - if (!isCollection) this.loadLibraryPicker(); - - // Update profile info text - var infoText = this.dialog.querySelector(".moonfin-profile-info-text"); - if (infoText) { - if (profileName === "global") { - infoText.textContent = - '"All Devices" settings apply everywhere. Device profiles override only the settings you change.'; - } else { - var label = profileName.charAt(0).toUpperCase() + profileName.slice(1); - infoText.textContent = - "Editing " + - label + - ' overrides. Dimmed settings are inherited from "All Devices". Changes here only affect ' + - label + - " devices."; - } - } - }, - - loadCollectionPicker: function () { - var self = this; - var picker = this.dialog - ? this.dialog.querySelector("#moonfin-collection-picker") - : null; - if (!picker) return; - - picker.innerHTML = - '
Loading...
'; - - API.getCollectionsAndPlaylists() - .then(function (items) { - if (!self.dialog) return; - if (!items || items.length === 0) { - picker.innerHTML = - '
No collections or playlists found on this server.
'; - return; + var colorOptions = []; + var colorKeys = Object.keys(Storage.colorOptions); + for (var i = 0; i < colorKeys.length; i++) { + colorOptions.push({ + value: colorKeys[i], + label: Storage.colorOptions[colorKeys[i]].name + }); } - var settings = Storage.getAll(); - var selectedIds = settings.mediaBarCollectionIds || []; - var serverUrl = - (window.ApiClient && window.ApiClient.serverAddress - ? window.ApiClient.serverAddress() - : "") || ""; - var html = ""; - - for (var i = 0; i < items.length; i++) { - var item = items[i]; - var isChecked = selectedIds.indexOf(item.Id) !== -1; - var typeBadge = item.Type === "BoxSet" ? "Collection" : "Playlist"; - var posterUrl = ""; - if (item.ImageTags && item.ImageTags.Primary) { - posterUrl = - serverUrl + - "/Items/" + - item.Id + - "/Images/Primary?maxWidth=60&quality=80&tag=" + - item.ImageTags.Primary; - } - - html += - '"; + var overlayContent = + this.createSelectCard( + 'mediaBarOverlayColor', + 'Overlay Color', + 'Color of the gradient overlay on media bar items', + colorOptions, + settings.mediaBarOverlayColor + ) + + '
' + + this.createRangeCard( + 'mediaBarOverlayOpacity', + 'Overlay Opacity', + 'Transparency of the gradient overlay', + 0, + 100, + 5, + settings.mediaBarOverlayOpacity, + '%' + ); + + var toolbarContent = + this.createToggleCard( + 'showShuffleButton', + 'Shuffle Button', + 'Show random content button in the toolbar', + settings.showShuffleButton + ) + + this.createSelectCard( + 'shuffleContentType', + 'Shuffle Content Type', + 'What type of content to shuffle', + [ + { value: 'both', label: 'Movies & TV Shows' }, + { value: 'movies', label: 'Movies Only' }, + { value: 'tv', label: 'TV Shows Only' } + ], + settings.shuffleContentType + ) + + this.createToggleCard( + 'showGenresButton', + 'Genres Button', + 'Show genres dropdown in the toolbar', + settings.showGenresButton + ) + + this.createToggleCard( + 'showFavoritesButton', + 'Favorites Button', + 'Show favorites button in the toolbar', + settings.showFavoritesButton + ) + + this.createToggleCard( + 'showCastButton', + 'Cast Button', + 'Show Chromecast button in the toolbar', + settings.showCastButton + ) + + this.createToggleCard( + 'showSyncPlayButton', + 'SyncPlay Button', + 'Show SyncPlay button in the toolbar', + settings.showSyncPlayButton + ) + + this.createToggleCard( + 'showLibrariesInToolbar', + 'Library Shortcuts', + 'Show library quick links in the toolbar', + settings.showLibrariesInToolbar + ); + + var seasonalOptions = []; + var seasonKeys = Object.keys(Storage.seasonalOptions); + for (var j = 0; j < seasonKeys.length; j++) { + seasonalOptions.push({ + value: seasonKeys[j], + label: Storage.seasonalOptions[seasonKeys[j]].name + }); } - picker.innerHTML = html; - - // Event delegation for checkboxes - picker.addEventListener("change", function (e) { - var cb = e.target; - if (!cb.dataset.collectionId) return; - - var currentIds = Storage.get("mediaBarCollectionIds") || []; - // Ensure we're working with a copy - currentIds = currentIds.slice(); - var id = cb.dataset.collectionId; - var idx = currentIds.indexOf(id); - - if (cb.checked && idx === -1) { - currentIds.push(id); - } else if (!cb.checked && idx !== -1) { - currentIds.splice(idx, 1); - } - - self.saveSetting("mediaBarCollectionIds", currentIds); - - // Update visual state - var label = cb.closest(".moonfin-collection-item"); - if (label) - label.classList.toggle( - "moonfin-collection-item-active", - cb.checked, + var displayContent = + this.createToggleCard('showClock', 'Clock', 'Show a clock in the navigation bar', settings.showClock) + + this.createToggleCard( + 'use24HourClock', + '24-Hour Format', + 'Use 24-hour time format instead of 12-hour', + settings.use24HourClock + ) + + this.createSelectCard( + 'seasonalSurprise', + 'Seasonal Effect', + 'Add a seasonal visual effect to the interface', + seasonalOptions, + settings.seasonalSurprise ); - self.showToast( - cb.checked ? "Added to media bar" : "Removed from media bar", - ); - }); - }) - .catch(function (e) { - console.error("[Moonfin] Failed to load collection picker:", e); - picker.innerHTML = - '
Failed to load collections.
'; - }); - }, - - loadLibraryPicker: function () { - var self = this; - var picker = this.dialog - ? this.dialog.querySelector("#moonfin-library-picker") - : null; - if (!picker) return; - - picker.innerHTML = - '
Loading...
'; - - API.getUserViews() - .then(function (views) { - if (!self.dialog) return; - - // Filter to media libraries (Movies, TV Shows, Music, etc.) - var libraries = []; - for (var i = 0; i < views.length; i++) { - var ct = views[i].CollectionType; - if (ct === "movies" || ct === "tvshows" || ct === "music" || ct === "mixed") { - libraries.push(views[i]); - } + var jellyseerrContent = + '
' + + '
' + + '' + + 'Checking...' + + '
' + + '
' + + '' + + ''; + + var mdblistSources = [ + { key: 'imdb', label: 'IMDb' }, + { key: 'tmdb', label: 'TMDb' }, + { key: 'trakt', label: 'Trakt' }, + { key: 'tomatoes', label: 'Rotten Tomatoes (Critics)' }, + { key: 'popcorn', label: 'Rotten Tomatoes (Audience)' }, + { key: 'metacritic', label: 'Metacritic' }, + { key: 'metacriticuser', label: 'Metacritic User' }, + { key: 'letterboxd', label: 'Letterboxd' }, + { key: 'rogerebert', label: 'Roger Ebert' }, + { key: 'myanimelist', label: 'MyAnimeList' }, + { key: 'anilist', label: 'AniList' } + ]; + var selectedSources = settings.mdblistRatingSources || ['imdb', 'tmdb', 'tomatoes', 'metacritic']; + var serverUrl = + (window.ApiClient && window.ApiClient.serverAddress ? window.ApiClient.serverAddress() : '') || ''; + var sourceIconFiles = { + imdb: 'imdb.svg', + tmdb: 'tmdb.svg', + trakt: 'trakt.svg', + tomatoes: 'rt-fresh.svg', + popcorn: 'rt-audience-up.svg', + metacritic: 'metacritic.svg', + metacriticuser: 'metacritic-user.svg', + letterboxd: 'letterboxd.svg', + rogerebert: 'rogerebert.svg', + myanimelist: 'mal.svg', + anilist: 'anilist.svg' + }; + + // Build ordered list: enabled sources first (in saved order), then disabled + var orderedSources = []; + for (var oi = 0; oi < selectedSources.length; oi++) { + for (var oj = 0; oj < mdblistSources.length; oj++) { + if (mdblistSources[oj].key === selectedSources[oi]) { + orderedSources.push({ + key: mdblistSources[oj].key, + label: mdblistSources[oj].label, + enabled: true + }); + break; + } + } + } + for (var uk = 0; uk < mdblistSources.length; uk++) { + if (selectedSources.indexOf(mdblistSources[uk].key) === -1) { + orderedSources.push({ + key: mdblistSources[uk].key, + label: mdblistSources[uk].label, + enabled: false + }); + } } - if (libraries.length === 0) { - picker.innerHTML = - '
No media libraries found.
'; - return; + var sourceItems = ''; + for (var si = 0; si < orderedSources.length; si++) { + var src = orderedSources[si]; + var iconUrl = serverUrl + '/Moonfin/Assets/' + (sourceIconFiles[src.key] || 'imdb.svg'); + sourceItems += + '
' + + '' + + '' + + '' + + '' +
+                src.label +
+                '' + + '' + + src.label + + '' + + '' + + '
'; } - var settings = Storage.getAll(); - var selectedIds = settings.mediaBarLibraryIds || []; - var html = ""; + var tmdbContent = + this.createToggleCard( + 'tmdbEpisodeRatingsEnabled', + 'Enable Episode Ratings', + 'Show TMDB ratings for individual TV episodes on the details page', + settings.tmdbEpisodeRatingsEnabled + ) + + '
' + + (Storage.syncState.tmdbAvailable + ? '
' + + 'Your server admin has provided a server-wide TMDB API key. You can leave the field below blank to use it, or enter your own key.' + + '
' + : '') + + '
' + + '' + + '' + + '
Get a free API key at themoviedb.org/settings/api
' + + '
' + + '
'; + + var mdblistContent = + this.createToggleCard( + 'mdblistEnabled', + 'Enable MDBList Ratings', + 'Show ratings from MDBList (IMDb, Rotten Tomatoes, Metacritic, etc.) on media bar and item details', + settings.mdblistEnabled + ) + + '
' + + this.createToggleCard( + 'mdblistShowRatingNames', + 'Show Rating Source Names', + 'Display the name of each rating source below its score', + settings.mdblistShowRatingNames + ) + + (Storage.syncState.mdblistAvailable + ? '
' + + 'Your server admin has provided a server-wide MDBList API key. You can leave the field below blank to use it, or enter your own key.' + + '
' + : '') + + '
' + + '' + + '' + + '
Get your free API key at mdblist.com/preferences
' + + '
' + + '
' + + '' + + '

Drag to reorder. Click the icon on the right to enable or disable a source.

' + + '
' + + sourceItems + + '
' + + '
' + + '
'; + + var currentDeviceProfile = Device.getProfileName(); + var profileLabels = { + global: 'All Devices', + desktop: 'Desktop', + mobile: 'Mobile', + tv: 'TV' + }; + var profileTabsHtml = '
'; + var profileNames = ['global', 'desktop', 'mobile', 'tv']; + for (var pi = 0; pi < profileNames.length; pi++) { + var pn = profileNames[pi]; + var isActive = pn === 'global'; + var isCurrent = pn === currentDeviceProfile; + profileTabsHtml += + ''; + } + profileTabsHtml += '
'; + var profileInfoHtml = + '
' + + ' ' + + '"All Devices" settings apply everywhere. Device profiles override only the settings you change.' + + '
'; + + this.dialog.innerHTML = + '
' + + '
' + + '
' + + '
' + + '

Moonfin

' + + 'Settings' + + '
' + + '' + + '
' + + profileTabsHtml + + profileInfoHtml + + '
' + + this.createSection('', 'Moonfin UI', uiContent, true) + + this.createSection('', 'Media Bar', mediaBarContent) + + this.createSection('', 'Overlay Appearance', overlayContent) + + this.createSection('', 'Toolbar Buttons', toolbarContent) + + this.createSection('', 'Display', displayContent) + + this.createSection('', 'TMDB Episode Ratings', tmdbContent) + + this.createSection('', 'MDBList Ratings', mdblistContent) + + '' + + '
' + + '' + + '
'; + + document.body.appendChild(this.dialog); + Storage.setActiveEditProfile('global'); + this.setupEventListeners(); + this.updateSyncStatus(); + this.updateJellyseerrSsoSection(); + }, + + refreshFormValues: function (profileName) { + if (!this.dialog) return; + var resolved = Storage.resolveSettings(profileName); + var raw = profileName !== 'global' ? Storage.getProfile(profileName) : null; + + // Update checkboxes + var checkboxes = this.dialog.querySelectorAll('input[type="checkbox"][name]'); + for (var i = 0; i < checkboxes.length; i++) { + var name = checkboxes[i].name; + if (name in resolved) { + checkboxes[i].checked = resolved[name]; + // Visual indicator: dim if inherited from global/defaults on a device profile + var isInherited = raw !== null && (raw[name] === undefined || raw[name] === null); + var card = checkboxes[i].closest('.moonfin-toggle-card'); + if (card) { + card.classList.toggle('moonfin-inherited', isInherited); + } + } + } - for (var j = 0; j < libraries.length; j++) { - var lib = libraries[j]; - var isChecked = selectedIds.indexOf(lib.Id) !== -1; - var typeBadge = (lib.CollectionType || "library").charAt(0).toUpperCase() + - (lib.CollectionType || "library").slice(1); + // Update selects + var selects = this.dialog.querySelectorAll('select[name]'); + for (var j = 0; j < selects.length; j++) { + var sName = selects[j].name; + if (sName in resolved) { + selects[j].value = String(resolved[sName]); + var sCard = selects[j].closest('.moonfin-select-card'); + if (sCard && raw !== null) { + sCard.classList.toggle('moonfin-inherited', raw[sName] === undefined || raw[sName] === null); + } + } + } - html += - '"; + // Update ranges + var ranges = this.dialog.querySelectorAll('input[type="range"][name]'); + for (var k = 0; k < ranges.length; k++) { + var rName = ranges[k].name; + if (rName in resolved) { + ranges[k].value = resolved[rName]; + var valueSpan = this.dialog.querySelector('.moonfin-range-value[data-for="' + rName + '"]'); + if (valueSpan) valueSpan.textContent = resolved[rName] + '%'; + } } - picker.innerHTML = html; + // Update text/password inputs + var textInputs = [ + { id: 'moonfin-mdblistApiKey', key: 'mdblistApiKey' }, + { id: 'moonfin-tmdbApiKey', key: 'tmdbApiKey' } + ]; + for (var ti = 0; ti < textInputs.length; ti++) { + var inp = this.dialog.querySelector('#' + textInputs[ti].id); + if (inp) inp.value = resolved[textInputs[ti].key] || ''; + } - // Event delegation for library checkboxes - picker.addEventListener("change", function (e) { - var cb = e.target; - if (!cb.dataset.libraryId) return; + // Update color preview + var colorPreview = this.dialog.querySelector('#moonfin-color-preview'); + if (colorPreview) { + colorPreview.style.background = Storage.getColorHex(resolved.mediaBarOverlayColor); + } - var currentIds = Storage.get("mediaBarLibraryIds") || []; - currentIds = currentIds.slice(); - var id = cb.dataset.libraryId; - var idx = currentIds.indexOf(id); + // Toggle config sub-sections + var mdblistConfig = this.dialog.querySelector('.moonfin-mdblist-config'); + if (mdblistConfig) mdblistConfig.style.display = resolved.mdblistEnabled ? '' : 'none'; + var tmdbConfig = this.dialog.querySelector('.moonfin-tmdb-config'); + if (tmdbConfig) tmdbConfig.style.display = resolved.tmdbEpisodeRatingsEnabled ? '' : 'none'; + + // Toggle media bar source sub-sections + var isCollection = resolved.mediaBarSourceType === 'collection'; + var libraryOpts = this.dialog.querySelector('.moonfin-mediabar-library-options'); + var collectionOpts = this.dialog.querySelector('.moonfin-mediabar-collection-options'); + if (libraryOpts) libraryOpts.style.display = isCollection ? 'none' : ''; + if (collectionOpts) collectionOpts.style.display = isCollection ? '' : 'none'; + if (isCollection) this.loadCollectionPicker(); + if (!isCollection) this.loadLibraryPicker(); + + // Update profile info text + var infoText = this.dialog.querySelector('.moonfin-profile-info-text'); + if (infoText) { + if (profileName === 'global') { + infoText.textContent = + '"All Devices" settings apply everywhere. Device profiles override only the settings you change.'; + } else { + var label = profileName.charAt(0).toUpperCase() + profileName.slice(1); + infoText.textContent = + 'Editing ' + + label + + ' overrides. Dimmed settings are inherited from "All Devices". Changes here only affect ' + + label + + ' devices.'; + } + } + }, + + loadCollectionPicker: function () { + var self = this; + var picker = this.dialog ? this.dialog.querySelector('#moonfin-collection-picker') : null; + if (!picker) return; + + picker.innerHTML = '
Loading...
'; + + API.getCollectionsAndPlaylists() + .then(function (items) { + if (!self.dialog) return; + if (!items || items.length === 0) { + picker.innerHTML = + '
No collections or playlists found on this server.
'; + return; + } + + var settings = Storage.getAll(); + var selectedIds = settings.mediaBarCollectionIds || []; + var serverUrl = + (window.ApiClient && window.ApiClient.serverAddress ? window.ApiClient.serverAddress() : '') || ''; + var html = ''; + + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var isChecked = selectedIds.indexOf(item.Id) !== -1; + var typeBadge = item.Type === 'BoxSet' ? 'Collection' : 'Playlist'; + var posterUrl = ''; + if (item.ImageTags && item.ImageTags.Primary) { + posterUrl = + serverUrl + + '/Items/' + + item.Id + + '/Images/Primary?maxWidth=60&quality=80&tag=' + + item.ImageTags.Primary; + } + + html += + ''; + } + + picker.innerHTML = html; + + // Event delegation for checkboxes + picker.addEventListener('change', function (e) { + var cb = e.target; + if (!cb.dataset.collectionId) return; + + var currentIds = Storage.get('mediaBarCollectionIds') || []; + // Ensure we're working with a copy + currentIds = currentIds.slice(); + var id = cb.dataset.collectionId; + var idx = currentIds.indexOf(id); + + if (cb.checked && idx === -1) { + currentIds.push(id); + } else if (!cb.checked && idx !== -1) { + currentIds.splice(idx, 1); + } + + self.saveSetting('mediaBarCollectionIds', currentIds); + + // Update visual state + var label = cb.closest('.moonfin-collection-item'); + if (label) label.classList.toggle('moonfin-collection-item-active', cb.checked); + + self.showToast(cb.checked ? 'Added to media bar' : 'Removed from media bar'); + }); + }) + .catch(function (e) { + console.error('[Moonfin] Failed to load collection picker:', e); + picker.innerHTML = '
Failed to load collections.
'; + }); + }, + + loadLibraryPicker: function () { + var self = this; + var picker = this.dialog ? this.dialog.querySelector('#moonfin-library-picker') : null; + if (!picker) return; + + picker.innerHTML = '
Loading...
'; + + API.getUserViews() + .then(function (views) { + if (!self.dialog) return; + + // Filter to media libraries (Movies, TV Shows, Music, etc.) + var libraries = []; + for (var i = 0; i < views.length; i++) { + var ct = views[i].CollectionType; + if (ct === 'movies' || ct === 'tvshows' || ct === 'music' || ct === 'mixed') { + libraries.push(views[i]); + } + } + + if (libraries.length === 0) { + picker.innerHTML = '
No media libraries found.
'; + return; + } + + var settings = Storage.getAll(); + var selectedIds = settings.mediaBarLibraryIds || []; + var html = ''; + + for (var j = 0; j < libraries.length; j++) { + var lib = libraries[j]; + var isChecked = selectedIds.indexOf(lib.Id) !== -1; + var typeBadge = + (lib.CollectionType || 'library').charAt(0).toUpperCase() + + (lib.CollectionType || 'library').slice(1); + + html += + ''; + } + + picker.innerHTML = html; + + // Event delegation for library checkboxes + picker.addEventListener('change', function (e) { + var cb = e.target; + if (!cb.dataset.libraryId) return; + + var currentIds = Storage.get('mediaBarLibraryIds') || []; + currentIds = currentIds.slice(); + var id = cb.dataset.libraryId; + var idx = currentIds.indexOf(id); + + if (cb.checked && idx === -1) { + currentIds.push(id); + } else if (!cb.checked && idx !== -1) { + currentIds.splice(idx, 1); + } + + self.saveSetting('mediaBarLibraryIds', currentIds); + + var label = cb.closest('.moonfin-collection-item'); + if (label) label.classList.toggle('moonfin-collection-item-active', cb.checked); + + self.showToast(cb.checked ? 'Library added' : 'Library removed'); + }); + }) + .catch(function (e) { + console.error('[Moonfin] Failed to load library picker:', e); + picker.innerHTML = '
Failed to load libraries.
'; + }); + }, + + updateJellyseerrSsoSection: function () { + var self = this; + var wrapper = this.dialog ? this.dialog.querySelector('.moonfin-settings-jellyseerr-wrapper') : null; + if (!wrapper) return Promise.resolve(); + + // Always fetch fresh config to catch admin changes + return Jellyseerr.fetchConfig().then(function () { + if (!Jellyseerr.config || !Jellyseerr.config.enabled || !Jellyseerr.config.url) { + console.log('[Moonfin] Jellyseerr not configured, hiding section. Config:', Jellyseerr.config); + wrapper.style.display = 'none'; + return; + } - if (cb.checked && idx === -1) { - currentIds.push(id); - } else if (!cb.checked && idx !== -1) { - currentIds.splice(idx, 1); - } + wrapper.style.display = ''; + + var indicator = wrapper.querySelector('.moonfin-jellyseerr-sso-indicator'); + var text = wrapper.querySelector('.moonfin-jellyseerr-sso-text'); + var loginGroup = wrapper.querySelector('.moonfin-jellyseerr-login-group'); + var signedInGroup = wrapper.querySelector('.moonfin-jellyseerr-signedIn-group'); + + return Jellyseerr.checkSsoStatus().then(function () { + if (Jellyseerr.ssoStatus && Jellyseerr.ssoStatus.authenticated) { + indicator.className = 'moonfin-jellyseerr-sso-indicator connected'; + var displayName = Jellyseerr.ssoStatus.displayName || 'Unknown'; + text.textContent = 'Signed in as ' + displayName; + loginGroup.style.display = 'none'; + signedInGroup.style.display = ''; + } else { + indicator.className = 'moonfin-jellyseerr-sso-indicator disconnected'; + text.textContent = 'Not signed in'; + loginGroup.style.display = ''; + signedInGroup.style.display = 'none'; + + var api = API.getApiClient(); + if (api && api._currentUser) { + var usernameInput = wrapper.querySelector('#jellyseerr-settings-username'); + if (usernameInput && !usernameInput.value) { + usernameInput.value = api._currentUser.Name || ''; + } + } + } + }); + }); + }, - self.saveSetting("mediaBarLibraryIds", currentIds); + updateSyncStatus: function () { + var self = this; + var statusEl = this.dialog ? this.dialog.querySelector('#moonfinSyncStatus') : null; + if (!statusEl) return Promise.resolve(); - var label = cb.closest(".moonfin-collection-item"); - if (label) - label.classList.toggle( - "moonfin-collection-item-active", - cb.checked, - ); + var indicator = statusEl.querySelector('.moonfin-sync-indicator'); + var text = statusEl.querySelector('.moonfin-sync-text'); - self.showToast( - cb.checked ? "Library added" : "Library removed", - ); - }); - }) - .catch(function (e) { - console.error("[Moonfin] Failed to load library picker:", e); - picker.innerHTML = - '
Failed to load libraries.
'; - }); - }, - - updateJellyseerrSsoSection: function () { - var self = this; - var wrapper = this.dialog - ? this.dialog.querySelector(".moonfin-settings-jellyseerr-wrapper") - : null; - if (!wrapper) return Promise.resolve(); - - // Always fetch fresh config to catch admin changes - return Jellyseerr.fetchConfig().then(function () { - if ( - !Jellyseerr.config || - !Jellyseerr.config.enabled || - !Jellyseerr.config.url - ) { - console.log( - "[Moonfin] Jellyseerr not configured, hiding section. Config:", - Jellyseerr.config, - ); - wrapper.style.display = "none"; - return; - } - - wrapper.style.display = ""; - - var indicator = wrapper.querySelector( - ".moonfin-jellyseerr-sso-indicator", - ); - var text = wrapper.querySelector(".moonfin-jellyseerr-sso-text"); - var loginGroup = wrapper.querySelector(".moonfin-jellyseerr-login-group"); - var signedInGroup = wrapper.querySelector( - ".moonfin-jellyseerr-signedIn-group", - ); - - return Jellyseerr.checkSsoStatus().then(function () { - if (Jellyseerr.ssoStatus && Jellyseerr.ssoStatus.authenticated) { - indicator.className = "moonfin-jellyseerr-sso-indicator connected"; - var displayName = Jellyseerr.ssoStatus.displayName || "Unknown"; - text.textContent = "Signed in as " + displayName; - loginGroup.style.display = "none"; - signedInGroup.style.display = ""; - } else { - indicator.className = "moonfin-jellyseerr-sso-indicator disconnected"; - text.textContent = "Not signed in"; - loginGroup.style.display = ""; - signedInGroup.style.display = "none"; - - var api = API.getApiClient(); - if (api && api._currentUser) { - var usernameInput = wrapper.querySelector( - "#jellyseerr-settings-username", - ); - if (usernameInput && !usernameInput.value) { - usernameInput.value = api._currentUser.Name || ""; - } - } - } - }); - }); - }, - - updateSyncStatus: function () { - var self = this; - var statusEl = this.dialog - ? this.dialog.querySelector("#moonfinSyncStatus") - : null; - if (!statusEl) return Promise.resolve(); - - var indicator = statusEl.querySelector(".moonfin-sync-indicator"); - var text = statusEl.querySelector(".moonfin-sync-text"); - - var syncStatus = Storage.getSyncStatus(); - - if (syncStatus.syncing) { - indicator.className = "moonfin-sync-indicator syncing"; - text.textContent = "Syncing..."; - return Promise.resolve(); - } + var syncStatus = Storage.getSyncStatus(); - // Always re-ping when the panel opens to get fresh status - indicator.className = "moonfin-sync-indicator checking"; - text.textContent = "Checking server..."; - return Storage.pingServer().then(function () { - var freshStatus = Storage.getSyncStatus(); - if (freshStatus.available) { - indicator.className = "moonfin-sync-indicator connected"; - if (freshStatus.lastSync) { - var ago = Math.round((Date.now() - freshStatus.lastSync) / 1000); - text.textContent = - "Synced " + - (ago < 60 ? ago + "s" : Math.round(ago / 60) + "m") + - " ago"; - } else { - text.textContent = "Server sync available"; - } - } else { - indicator.className = "moonfin-sync-indicator disconnected"; - text.textContent = freshStatus.error || "Server sync unavailable"; - } - }); - }, - - setupEventListeners: function () { - var self = this; - - this.dialog - .querySelector(".moonfin-settings-close") - .addEventListener("click", function () { - self.hide(); - }); - - this.dialog - .querySelector(".moonfin-settings-close-btn") - .addEventListener("click", function () { - self.hide(); - }); - - this.dialog - .querySelector(".moonfin-settings-overlay") - .addEventListener("click", function () { - self.hide(); - }); - - this.dialog - .querySelector(".moonfin-settings-reset") - .addEventListener("click", function () { - var activeProfile = Storage.getActiveEditProfile(); - if (activeProfile !== "global") { - if ( - confirm( - 'Reset "' + - activeProfile + - '" device profile? This will remove all overrides for this device.', - ) - ) { - Storage.deleteProfile(activeProfile); - self.showToast("Device profile reset"); - self.hide(); - setTimeout(function () { - self.show(); - }, 350); - } - } else { - if (confirm("Reset all Moonfin settings to defaults?")) { - Storage.reset(); - self.showToast("Settings reset to defaults"); - self.hide(); - setTimeout(function () { - self.show(); - }, 350); - } + if (syncStatus.syncing) { + indicator.className = 'moonfin-sync-indicator syncing'; + text.textContent = 'Syncing...'; + return Promise.resolve(); } - }); - - this.dialog - .querySelector(".moonfin-settings-sync") - .addEventListener("click", function () { - var syncBtn = self.dialog.querySelector(".moonfin-settings-sync"); - syncBtn.disabled = true; - syncBtn.textContent = "Syncing..."; - - Storage.sync(true) - .then(function () { - return self.updateSyncStatus(); - }) - .then(function () { - syncBtn.disabled = false; - syncBtn.textContent = "Sync"; - self.showToast("Settings synced from server"); - self.hide(); - setTimeout(function () { - self.show(); - }, 350); - }); - }); - - // Profile tab switching - var profileTabs = this.dialog.querySelectorAll(".moonfin-profile-tab"); - for (var pti = 0; pti < profileTabs.length; pti++) { - profileTabs[pti].addEventListener("click", function () { - var profileName = this.getAttribute("data-profile"); - for (var pt = 0; pt < profileTabs.length; pt++) { - profileTabs[pt].classList.remove("moonfin-profile-tab-active"); - } - this.classList.add("moonfin-profile-tab-active"); - Storage.setActiveEditProfile(profileName); - self.refreshFormValues(profileName); - self.showToast( - "Editing: " + - (profileName === "global" - ? "All Devices" - : profileName.charAt(0).toUpperCase() + profileName.slice(1)), - ); - }); - } - // Sync toggle - var syncToggle = this.dialog.querySelector("#moonfin-sync-toggle"); - if (syncToggle) { - syncToggle.addEventListener("change", function () { - Storage.setSyncEnabled(syncToggle.checked); - self.showToast(syncToggle.checked ? "Sync enabled" : "Sync disabled"); - self.updateSyncStatus(); - }); - } - - var checkboxes = this.dialog.querySelectorAll( - 'input[type="checkbox"][name]', - ); - for (var i = 0; i < checkboxes.length; i++) { - (function (cb) { - cb.addEventListener("change", function () { - self.saveSetting(cb.name, cb.checked); - self.showToast(cb.checked ? "Enabled" : "Disabled"); - - if (cb.name === "mdblistEnabled") { - var configDiv = self.dialog.querySelector( - ".moonfin-mdblist-config", - ); - if (configDiv) { - configDiv.style.display = cb.checked ? "" : "none"; + // Always re-ping when the panel opens to get fresh status + indicator.className = 'moonfin-sync-indicator checking'; + text.textContent = 'Checking server...'; + return Storage.pingServer().then(function () { + var freshStatus = Storage.getSyncStatus(); + if (freshStatus.available) { + indicator.className = 'moonfin-sync-indicator connected'; + if (freshStatus.lastSync) { + var ago = Math.round((Date.now() - freshStatus.lastSync) / 1000); + text.textContent = 'Synced ' + (ago < 60 ? ago + 's' : Math.round(ago / 60) + 'm') + ' ago'; + } else { + text.textContent = 'Server sync available'; + } + } else { + indicator.className = 'moonfin-sync-indicator disconnected'; + text.textContent = freshStatus.error || 'Server sync unavailable'; } - } + }); + }, - if (cb.name === "tmdbEpisodeRatingsEnabled") { - var tmdbConfigDiv = self.dialog.querySelector( - ".moonfin-tmdb-config", - ); - if (tmdbConfigDiv) { - tmdbConfigDiv.style.display = cb.checked ? "" : "none"; - } - } + setupEventListeners: function () { + var self = this; + + this.dialog.querySelector('.moonfin-settings-close').addEventListener('click', function () { + self.hide(); }); - })(checkboxes[i]); - } - var selects = this.dialog.querySelectorAll("select"); - for (var j = 0; j < selects.length; j++) { - (function (sel) { - sel.addEventListener("change", function () { - var val = sel.value; - var numVal = parseInt(val, 10); - self.saveSetting(sel.name, isNaN(numVal) ? val : numVal); - self.showToast("Setting updated"); + this.dialog.querySelector('.moonfin-settings-close-btn').addEventListener('click', function () { + self.hide(); }); - })(selects[j]); - } - var ranges = this.dialog.querySelectorAll('input[type="range"]'); - for (var k = 0; k < ranges.length; k++) { - (function (range) { - range.addEventListener("input", function () { - var valueSpan = self.dialog.querySelector( - '.moonfin-range-value[data-for="' + range.name + '"]', - ); - if (valueSpan) { - valueSpan.textContent = range.value + "%"; - } + this.dialog.querySelector('.moonfin-settings-overlay').addEventListener('click', function () { + self.hide(); }); - range.addEventListener("change", function () { - self.saveSetting(range.name, parseInt(range.value, 10)); - self.showToast("Setting updated"); + + this.dialog.querySelector('.moonfin-settings-reset').addEventListener('click', function () { + var activeProfile = Storage.getActiveEditProfile(); + if (activeProfile !== 'global') { + if ( + confirm( + 'Reset "' + activeProfile + '" device profile? This will remove all overrides for this device.' + ) + ) { + Storage.deleteProfile(activeProfile); + self.showToast('Device profile reset'); + self.hide(); + setTimeout(function () { + self.show(); + }, 350); + } + } else { + if (confirm('Reset all Moonfin settings to defaults?')) { + Storage.reset(); + self.showToast('Settings reset to defaults'); + self.hide(); + setTimeout(function () { + self.show(); + }, 350); + } + } }); - })(ranges[k]); - } - var colorSelect = this.dialog.querySelector( - 'select[name="mediaBarOverlayColor"]', - ); - if (colorSelect) { - colorSelect.addEventListener("change", function () { - var preview = self.dialog.querySelector("#moonfin-color-preview"); - if (preview) { - preview.style.background = Storage.getColorHex(colorSelect.value); - } - }); - } + this.dialog.querySelector('.moonfin-settings-sync').addEventListener('click', function () { + var syncBtn = self.dialog.querySelector('.moonfin-settings-sync'); + syncBtn.disabled = true; + syncBtn.textContent = 'Syncing...'; + + Storage.sync(true) + .then(function () { + return self.updateSyncStatus(); + }) + .then(function () { + syncBtn.disabled = false; + syncBtn.textContent = 'Sync'; + self.showToast('Settings synced from server'); + self.hide(); + setTimeout(function () { + self.show(); + }, 350); + }); + }); - // Media bar source type toggle - var sourceTypeSelect = this.dialog.querySelector( - 'select[name="mediaBarSourceType"]', - ); - if (sourceTypeSelect) { - sourceTypeSelect.addEventListener("change", function () { - var isCollection = sourceTypeSelect.value === "collection"; - var libraryOpts = self.dialog.querySelector( - ".moonfin-mediabar-library-options", - ); - var collectionOpts = self.dialog.querySelector( - ".moonfin-mediabar-collection-options", - ); - if (libraryOpts) libraryOpts.style.display = isCollection ? "none" : ""; - if (collectionOpts) - collectionOpts.style.display = isCollection ? "" : "none"; - if (isCollection) { - self.loadCollectionPicker(); - } else { - self.loadLibraryPicker(); + // Profile tab switching + var profileTabs = this.dialog.querySelectorAll('.moonfin-profile-tab'); + for (var pti = 0; pti < profileTabs.length; pti++) { + profileTabs[pti].addEventListener('click', function () { + var profileName = this.getAttribute('data-profile'); + for (var pt = 0; pt < profileTabs.length; pt++) { + profileTabs[pt].classList.remove('moonfin-profile-tab-active'); + } + this.classList.add('moonfin-profile-tab-active'); + Storage.setActiveEditProfile(profileName); + self.refreshFormValues(profileName); + self.showToast( + 'Editing: ' + + (profileName === 'global' + ? 'All Devices' + : profileName.charAt(0).toUpperCase() + profileName.slice(1)) + ); + }); } - }); - - // Load pickers on init based on active source mode - if (sourceTypeSelect.value === "collection") { - self.loadCollectionPicker(); - } else { - self.loadLibraryPicker(); - } - } - this.dialog.addEventListener("keydown", function (e) { - if (e.key === "Escape") { - self.hide(); - } - }); - - // MDBList API key - save on input with debounce + on blur - var mdblistApiKeyInput = this.dialog.querySelector( - "#moonfin-mdblistApiKey", - ); - if (mdblistApiKeyInput) { - var mdblistKeyTimer = null; - mdblistApiKeyInput.addEventListener("input", function () { - if (mdblistKeyTimer) clearTimeout(mdblistKeyTimer); - mdblistKeyTimer = setTimeout(function () { - self.saveSetting("mdblistApiKey", mdblistApiKeyInput.value.trim()); - self.showToast("API key saved"); - }, 800); - }); - mdblistApiKeyInput.addEventListener("blur", function () { - if (mdblistKeyTimer) clearTimeout(mdblistKeyTimer); - self.saveSetting("mdblistApiKey", mdblistApiKeyInput.value.trim()); - }); - } + // Sync toggle + var syncToggle = this.dialog.querySelector('#moonfin-sync-toggle'); + if (syncToggle) { + syncToggle.addEventListener('change', function () { + Storage.setSyncEnabled(syncToggle.checked); + self.showToast(syncToggle.checked ? 'Sync enabled' : 'Sync disabled'); + self.updateSyncStatus(); + }); + } - // TMDB API key - save on input with debounce + on blur - var tmdbApiKeyInput = this.dialog.querySelector("#moonfin-tmdbApiKey"); - if (tmdbApiKeyInput) { - var tmdbKeyTimer = null; - tmdbApiKeyInput.addEventListener("input", function () { - if (tmdbKeyTimer) clearTimeout(tmdbKeyTimer); - tmdbKeyTimer = setTimeout(function () { - self.saveSetting("tmdbApiKey", tmdbApiKeyInput.value.trim()); - self.showToast("TMDB API key saved"); - }, 800); - }); - tmdbApiKeyInput.addEventListener("blur", function () { - if (tmdbKeyTimer) clearTimeout(tmdbKeyTimer); - self.saveSetting("tmdbApiKey", tmdbApiKeyInput.value.trim()); - }); - } + var checkboxes = this.dialog.querySelectorAll('input[type="checkbox"][name]'); + for (var i = 0; i < checkboxes.length; i++) { + (function (cb) { + cb.addEventListener('change', function () { + self.saveSetting(cb.name, cb.checked); + self.showToast(cb.checked ? 'Enabled' : 'Disabled'); + + if (cb.name === 'mdblistEnabled') { + var configDiv = self.dialog.querySelector('.moonfin-mdblist-config'); + if (configDiv) { + configDiv.style.display = cb.checked ? '' : 'none'; + } + } + + if (cb.name === 'tmdbEpisodeRatingsEnabled') { + var tmdbConfigDiv = self.dialog.querySelector('.moonfin-tmdb-config'); + if (tmdbConfigDiv) { + tmdbConfigDiv.style.display = cb.checked ? '' : 'none'; + } + } + }); + })(checkboxes[i]); + } - // --- Sortable rating sources --- - var sortableList = this.dialog.querySelector("#moonfin-sources-sortable"); - if (sortableList) { - var dragItem = null; - var dragPlaceholder = document.createElement("div"); - dragPlaceholder.className = "moonfin-sortable-placeholder"; - - var saveSortableState = function () { - var items = sortableList.querySelectorAll(".moonfin-sortable-item"); - var enabled = []; - for (var i = 0; i < items.length; i++) { - if (items[i].classList.contains("moonfin-sortable-item-active")) { - enabled.push(items[i].getAttribute("data-source")); - } + var selects = this.dialog.querySelectorAll('select'); + for (var j = 0; j < selects.length; j++) { + (function (sel) { + sel.addEventListener('change', function () { + var val = sel.value; + var numVal = parseInt(val, 10); + self.saveSetting(sel.name, isNaN(numVal) ? val : numVal); + self.showToast('Setting updated'); + }); + })(selects[j]); } - self.saveSetting("mdblistRatingSources", enabled); - self.showToast("Rating sources updated"); - }; - - sortableList.addEventListener("dragstart", function (e) { - var item = e.target.closest(".moonfin-sortable-item"); - if (!item) return; - dragItem = item; - item.classList.add("moonfin-sortable-dragging"); - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", ""); - }); - - sortableList.addEventListener("dragend", function () { - if (dragItem) { - dragItem.classList.remove("moonfin-sortable-dragging"); - dragItem = null; + + var ranges = this.dialog.querySelectorAll('input[type="range"]'); + for (var k = 0; k < ranges.length; k++) { + (function (range) { + range.addEventListener('input', function () { + var valueSpan = self.dialog.querySelector('.moonfin-range-value[data-for="' + range.name + '"]'); + if (valueSpan) { + valueSpan.textContent = range.value + '%'; + } + }); + range.addEventListener('change', function () { + self.saveSetting(range.name, parseInt(range.value, 10)); + self.showToast('Setting updated'); + }); + })(ranges[k]); } - if (dragPlaceholder.parentNode) { - dragPlaceholder.parentNode.removeChild(dragPlaceholder); + + var colorSelect = this.dialog.querySelector('select[name="mediaBarOverlayColor"]'); + if (colorSelect) { + colorSelect.addEventListener('change', function () { + var preview = self.dialog.querySelector('#moonfin-color-preview'); + if (preview) { + preview.style.background = Storage.getColorHex(colorSelect.value); + } + }); } - }); - - sortableList.addEventListener("dragover", function (e) { - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - var target = e.target.closest(".moonfin-sortable-item"); - if (!target || target === dragItem) return; - - var rect = target.getBoundingClientRect(); - var midY = rect.top + rect.height / 2; - if (e.clientY < midY) { - sortableList.insertBefore(dragPlaceholder, target); - } else { - sortableList.insertBefore(dragPlaceholder, target.nextSibling); + + // Media bar source type toggle + var sourceTypeSelect = this.dialog.querySelector('select[name="mediaBarSourceType"]'); + if (sourceTypeSelect) { + sourceTypeSelect.addEventListener('change', function () { + var isCollection = sourceTypeSelect.value === 'collection'; + var libraryOpts = self.dialog.querySelector('.moonfin-mediabar-library-options'); + var collectionOpts = self.dialog.querySelector('.moonfin-mediabar-collection-options'); + if (libraryOpts) libraryOpts.style.display = isCollection ? 'none' : ''; + if (collectionOpts) collectionOpts.style.display = isCollection ? '' : 'none'; + if (isCollection) { + self.loadCollectionPicker(); + } else { + self.loadLibraryPicker(); + } + }); + + // Load pickers on init based on active source mode + if (sourceTypeSelect.value === 'collection') { + self.loadCollectionPicker(); + } else { + self.loadLibraryPicker(); + } } - }); - - sortableList.addEventListener("drop", function (e) { - e.preventDefault(); - if (!dragItem) return; - if (dragPlaceholder.parentNode) { - sortableList.insertBefore(dragItem, dragPlaceholder); - dragPlaceholder.parentNode.removeChild(dragPlaceholder); + + this.dialog.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + self.hide(); + } + }); + + // MDBList API key - save on input with debounce + on blur + var mdblistApiKeyInput = this.dialog.querySelector('#moonfin-mdblistApiKey'); + if (mdblistApiKeyInput) { + var mdblistKeyTimer = null; + mdblistApiKeyInput.addEventListener('input', function () { + if (mdblistKeyTimer) clearTimeout(mdblistKeyTimer); + mdblistKeyTimer = setTimeout(function () { + self.saveSetting('mdblistApiKey', mdblistApiKeyInput.value.trim()); + self.showToast('API key saved'); + }, 800); + }); + mdblistApiKeyInput.addEventListener('blur', function () { + if (mdblistKeyTimer) clearTimeout(mdblistKeyTimer); + self.saveSetting('mdblistApiKey', mdblistApiKeyInput.value.trim()); + }); } - saveSortableState(); - }); - - // Toggle enable/disable - sortableList.addEventListener("click", function (e) { - var toggleBtn = e.target.closest(".moonfin-sortable-toggle"); - if (!toggleBtn) return; - var item = toggleBtn.closest(".moonfin-sortable-item"); - if (!item) return; - var isActive = item.classList.toggle("moonfin-sortable-item-active"); - var svg = toggleBtn.querySelector("path"); - if (svg) { - svg.setAttribute( - "d", - isActive - ? "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" - : "M19 13H5v-2h14v2z", - ); + + // TMDB API key - save on input with debounce + on blur + var tmdbApiKeyInput = this.dialog.querySelector('#moonfin-tmdbApiKey'); + if (tmdbApiKeyInput) { + var tmdbKeyTimer = null; + tmdbApiKeyInput.addEventListener('input', function () { + if (tmdbKeyTimer) clearTimeout(tmdbKeyTimer); + tmdbKeyTimer = setTimeout(function () { + self.saveSetting('tmdbApiKey', tmdbApiKeyInput.value.trim()); + self.showToast('TMDB API key saved'); + }, 800); + }); + tmdbApiKeyInput.addEventListener('blur', function () { + if (tmdbKeyTimer) clearTimeout(tmdbKeyTimer); + self.saveSetting('tmdbApiKey', tmdbApiKeyInput.value.trim()); + }); } - toggleBtn.title = isActive ? "Disable" : "Enable"; - saveSortableState(); - }); - - // Touch drag support for mobile / TV - (function () { - var touchItem = null; - var touchClone = null; - var touchOffsetY = 0; - - sortableList.addEventListener( - "touchstart", - function (e) { - var handle = e.target.closest(".moonfin-sortable-handle"); - if (!handle) return; - var item = handle.closest(".moonfin-sortable-item"); - if (!item) return; - touchItem = item; - var rect = item.getBoundingClientRect(); - touchOffsetY = e.touches[0].clientY - rect.top; - touchClone = item.cloneNode(true); - touchClone.className = - "moonfin-sortable-item moonfin-sortable-touch-clone"; - touchClone.style.width = rect.width + "px"; - touchClone.style.top = rect.top + "px"; - touchClone.style.left = rect.left + "px"; - document.body.appendChild(touchClone); - item.classList.add("moonfin-sortable-dragging"); - }, - { passive: true }, - ); - sortableList.addEventListener( - "touchmove", - function (e) { - if (!touchItem || !touchClone) return; - e.preventDefault(); - var y = e.touches[0].clientY; - touchClone.style.top = y - touchOffsetY + "px"; + // --- Sortable rating sources --- + var sortableList = this.dialog.querySelector('#moonfin-sources-sortable'); + if (sortableList) { + var dragItem = null; + var dragPlaceholder = document.createElement('div'); + dragPlaceholder.className = 'moonfin-sortable-placeholder'; + + var saveSortableState = function () { + var items = sortableList.querySelectorAll('.moonfin-sortable-item'); + var enabled = []; + for (var i = 0; i < items.length; i++) { + if (items[i].classList.contains('moonfin-sortable-item-active')) { + enabled.push(items[i].getAttribute('data-source')); + } + } + self.saveSetting('mdblistRatingSources', enabled); + self.showToast('Rating sources updated'); + }; + + sortableList.addEventListener('dragstart', function (e) { + var item = e.target.closest('.moonfin-sortable-item'); + if (!item) return; + dragItem = item; + item.classList.add('moonfin-sortable-dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', ''); + }); + + sortableList.addEventListener('dragend', function () { + if (dragItem) { + dragItem.classList.remove('moonfin-sortable-dragging'); + dragItem = null; + } + if (dragPlaceholder.parentNode) { + dragPlaceholder.parentNode.removeChild(dragPlaceholder); + } + }); + + sortableList.addEventListener('dragover', function (e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + var target = e.target.closest('.moonfin-sortable-item'); + if (!target || target === dragItem) return; + + var rect = target.getBoundingClientRect(); + var midY = rect.top + rect.height / 2; + if (e.clientY < midY) { + sortableList.insertBefore(dragPlaceholder, target); + } else { + sortableList.insertBefore(dragPlaceholder, target.nextSibling); + } + }); + + sortableList.addEventListener('drop', function (e) { + e.preventDefault(); + if (!dragItem) return; + if (dragPlaceholder.parentNode) { + sortableList.insertBefore(dragItem, dragPlaceholder); + dragPlaceholder.parentNode.removeChild(dragPlaceholder); + } + saveSortableState(); + }); + + // Toggle enable/disable + sortableList.addEventListener('click', function (e) { + var toggleBtn = e.target.closest('.moonfin-sortable-toggle'); + if (!toggleBtn) return; + var item = toggleBtn.closest('.moonfin-sortable-item'); + if (!item) return; + var isActive = item.classList.toggle('moonfin-sortable-item-active'); + var svg = toggleBtn.querySelector('path'); + if (svg) { + svg.setAttribute( + 'd', + isActive ? 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' : 'M19 13H5v-2h14v2z' + ); + } + toggleBtn.title = isActive ? 'Disable' : 'Enable'; + saveSortableState(); + }); + + // Touch drag support for mobile / TV + (function () { + var touchItem = null; + var touchClone = null; + var touchOffsetY = 0; + + sortableList.addEventListener( + 'touchstart', + function (e) { + var handle = e.target.closest('.moonfin-sortable-handle'); + if (!handle) return; + var item = handle.closest('.moonfin-sortable-item'); + if (!item) return; + touchItem = item; + var rect = item.getBoundingClientRect(); + touchOffsetY = e.touches[0].clientY - rect.top; + touchClone = item.cloneNode(true); + touchClone.className = 'moonfin-sortable-item moonfin-sortable-touch-clone'; + touchClone.style.width = rect.width + 'px'; + touchClone.style.top = rect.top + 'px'; + touchClone.style.left = rect.left + 'px'; + document.body.appendChild(touchClone); + item.classList.add('moonfin-sortable-dragging'); + }, + { passive: true } + ); + + sortableList.addEventListener( + 'touchmove', + function (e) { + if (!touchItem || !touchClone) return; + e.preventDefault(); + var y = e.touches[0].clientY; + touchClone.style.top = y - touchOffsetY + 'px'; + + var items = sortableList.querySelectorAll( + '.moonfin-sortable-item:not(.moonfin-sortable-dragging)' + ); + for (var i = 0; i < items.length; i++) { + var rect = items[i].getBoundingClientRect(); + var midY = rect.top + rect.height / 2; + if (y < midY) { + sortableList.insertBefore(touchItem, items[i]); + return; + } + } + sortableList.appendChild(touchItem); + }, + { passive: false } + ); + + sortableList.addEventListener( + 'touchend', + function () { + if (touchItem) { + touchItem.classList.remove('moonfin-sortable-dragging'); + touchItem = null; + } + if (touchClone && touchClone.parentNode) { + touchClone.parentNode.removeChild(touchClone); + touchClone = null; + } + saveSortableState(); + }, + { passive: true } + ); + })(); + } - var items = sortableList.querySelectorAll( - ".moonfin-sortable-item:not(.moonfin-sortable-dragging)", - ); - for (var i = 0; i < items.length; i++) { - var rect = items[i].getBoundingClientRect(); - var midY = rect.top + rect.height / 2; - if (y < midY) { - sortableList.insertBefore(touchItem, items[i]); - return; - } - } - sortableList.appendChild(touchItem); - }, - { passive: false }, - ); + var loginBtn = this.dialog.querySelector('.moonfin-jellyseerr-settings-login-btn'); + if (loginBtn) { + loginBtn.addEventListener('click', function () { + self.handleJellyseerrLogin(); + }); + } - sortableList.addEventListener( - "touchend", - function () { - if (touchItem) { - touchItem.classList.remove("moonfin-sortable-dragging"); - touchItem = null; - } - if (touchClone && touchClone.parentNode) { - touchClone.parentNode.removeChild(touchClone); - touchClone = null; - } - saveSortableState(); - }, - { passive: true }, - ); - })(); - } + var passwordInput = this.dialog.querySelector('#jellyseerr-settings-password'); + if (passwordInput) { + passwordInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + self.handleJellyseerrLogin(); + } + }); + } - var loginBtn = this.dialog.querySelector( - ".moonfin-jellyseerr-settings-login-btn", - ); - if (loginBtn) { - loginBtn.addEventListener("click", function () { - self.handleJellyseerrLogin(); - }); - } + var authTypeBtns = this.dialog.querySelectorAll('.moonfin-segmented-btn[data-auth-type]'); + for (var ati = 0; ati < authTypeBtns.length; ati++) { + authTypeBtns[ati].addEventListener('click', function () { + var wrapper = self.dialog.querySelector('.moonfin-settings-jellyseerr-wrapper'); + if (!wrapper) return; + var btns = wrapper.querySelectorAll('.moonfin-segmented-btn[data-auth-type]'); + for (var j = 0; j < btns.length; j++) btns[j].classList.remove('moonfin-segmented-btn-active'); + this.classList.add('moonfin-segmented-btn-active'); + var isLocal = this.getAttribute('data-auth-type') === 'local'; + var desc = wrapper.querySelector('.moonfin-jellyseerr-login-desc'); + var usernameLabel = wrapper.querySelector('.moonfin-jellyseerr-username-label'); + if (desc) + desc.textContent = isLocal + ? 'Enter your local Jellyseerr account credentials. Your session is stored on the server so all devices stay signed in.' + : 'Enter your Jellyfin credentials to sign in to Jellyseerr. Your session is stored on the server so all devices stay signed in.'; + if (usernameLabel) usernameLabel.textContent = isLocal ? 'Email' : 'Username'; + var passwordField = wrapper.querySelector('#jellyseerr-settings-password'); + if (passwordField) passwordField.placeholder = isLocal ? '' : 'Leave empty if no password'; + }); + } - var passwordInput = this.dialog.querySelector( - "#jellyseerr-settings-password", - ); - if (passwordInput) { - passwordInput.addEventListener("keydown", function (e) { - if (e.key === "Enter") { - self.handleJellyseerrLogin(); + var logoutBtn = this.dialog.querySelector('.moonfin-jellyseerr-settings-logout-btn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', function () { + if (confirm('Sign out of Jellyseerr? You will need to sign in again to use it.')) { + Jellyseerr.ssoLogout().then(function () { + self.updateJellyseerrSsoSection(); + self.showToast('Signed out of Jellyseerr'); + }); + } + }); } - }); - } + }, - var authTypeBtns = this.dialog.querySelectorAll( - ".moonfin-segmented-btn[data-auth-type]", - ); - for (var ati = 0; ati < authTypeBtns.length; ati++) { - authTypeBtns[ati].addEventListener("click", function () { - var wrapper = self.dialog.querySelector( - ".moonfin-settings-jellyseerr-wrapper", - ); + handleJellyseerrLogin: function () { + var self = this; + var wrapper = this.dialog ? this.dialog.querySelector('.moonfin-settings-jellyseerr-wrapper') : null; if (!wrapper) return; - var btns = wrapper.querySelectorAll( - ".moonfin-segmented-btn[data-auth-type]", - ); - for (var j = 0; j < btns.length; j++) - btns[j].classList.remove("moonfin-segmented-btn-active"); - this.classList.add("moonfin-segmented-btn-active"); - var isLocal = this.getAttribute("data-auth-type") === "local"; - var desc = wrapper.querySelector(".moonfin-jellyseerr-login-desc"); - var usernameLabel = wrapper.querySelector( - ".moonfin-jellyseerr-username-label", - ); - if (desc) - desc.textContent = isLocal - ? "Enter your local Jellyseerr account credentials. Your session is stored on the server so all devices stay signed in." - : "Enter your Jellyfin credentials to sign in to Jellyseerr. Your session is stored on the server so all devices stay signed in."; - if (usernameLabel) - usernameLabel.textContent = isLocal ? "Email" : "Username"; - var passwordField = wrapper.querySelector( - "#jellyseerr-settings-password", - ); - if (passwordField) - passwordField.placeholder = isLocal - ? "" - : "Leave empty if no password"; - }); - } - var logoutBtn = this.dialog.querySelector( - ".moonfin-jellyseerr-settings-logout-btn", - ); - if (logoutBtn) { - logoutBtn.addEventListener("click", function () { - if ( - confirm( - "Sign out of Jellyseerr? You will need to sign in again to use it.", - ) - ) { - Jellyseerr.ssoLogout().then(function () { - self.updateJellyseerrSsoSection(); - self.showToast("Signed out of Jellyseerr"); - }); + var username = wrapper.querySelector('#jellyseerr-settings-username'); + var password = wrapper.querySelector('#jellyseerr-settings-password'); + var errorEl = wrapper.querySelector('.moonfin-jellyseerr-login-error'); + var submitBtn = wrapper.querySelector('.moonfin-jellyseerr-settings-login-btn'); + + var usernameVal = username ? username.value : ''; + var passwordVal = password ? password.value : ''; + + var activeAuthBtn = wrapper.querySelector('.moonfin-segmented-btn-active[data-auth-type]'); + var authType = activeAuthBtn ? activeAuthBtn.getAttribute('data-auth-type') : 'jellyfin'; + var isLocalAuth = authType === 'local'; + if (!usernameVal) { + errorEl.textContent = isLocalAuth ? 'Please enter your email.' : 'Please enter your username.'; + errorEl.style.display = 'block'; + return; } - }); - } - }, - - handleJellyseerrLogin: function () { - var self = this; - var wrapper = this.dialog - ? this.dialog.querySelector(".moonfin-settings-jellyseerr-wrapper") - : null; - if (!wrapper) return; - - var username = wrapper.querySelector("#jellyseerr-settings-username"); - var password = wrapper.querySelector("#jellyseerr-settings-password"); - var errorEl = wrapper.querySelector(".moonfin-jellyseerr-login-error"); - var submitBtn = wrapper.querySelector( - ".moonfin-jellyseerr-settings-login-btn", - ); - - var usernameVal = username ? username.value : ""; - var passwordVal = password ? password.value : ""; - - var activeAuthBtn = wrapper.querySelector( - ".moonfin-segmented-btn-active[data-auth-type]", - ); - var authType = activeAuthBtn - ? activeAuthBtn.getAttribute("data-auth-type") - : "jellyfin"; - var isLocalAuth = authType === "local"; - if (!usernameVal) { - errorEl.textContent = isLocalAuth - ? "Please enter your email." - : "Please enter your username."; - errorEl.style.display = "block"; - return; - } - if (isLocalAuth && !passwordVal) { - errorEl.textContent = "Please enter your password."; - errorEl.style.display = "block"; - return; - } - - submitBtn.disabled = true; - submitBtn.textContent = "Signing in..."; - errorEl.style.display = "none"; - - Jellyseerr.ssoLogin(usernameVal, passwordVal, authType).then( - function (result) { - if (result.success) { - self.updateJellyseerrSsoSection(); - self.showToast("Signed in to Jellyseerr"); - } else { - errorEl.textContent = result.error || "Authentication failed"; - errorEl.style.display = "block"; - submitBtn.disabled = false; - submitBtn.textContent = "Sign In"; + if (isLocalAuth && !passwordVal) { + errorEl.textContent = 'Please enter your password.'; + errorEl.style.display = 'block'; + return; } - }, - ); - }, + + submitBtn.disabled = true; + submitBtn.textContent = 'Signing in...'; + errorEl.style.display = 'none'; + + Jellyseerr.ssoLogin(usernameVal, passwordVal, authType).then(function (result) { + if (result.success) { + self.updateJellyseerrSsoSection(); + self.showToast('Signed in to Jellyseerr'); + } else { + errorEl.textContent = result.error || 'Authentication failed'; + errorEl.style.display = 'block'; + submitBtn.disabled = false; + submitBtn.textContent = 'Sign In'; + } + }); + } }; diff --git a/frontend/src/styles/settings.css b/frontend/src/styles/settings.css index 037e438..6ad7107 100644 --- a/frontend/src/styles/settings.css +++ b/frontend/src/styles/settings.css @@ -4,733 +4,738 @@ */ .moonfin-settings-dialog { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 99999; - display: flex; - justify-content: flex-end; - opacity: 0; - visibility: hidden; - transition: opacity 0.3s ease, visibility 0.3s ease; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 99999; + display: flex; + justify-content: flex-end; + opacity: 0; + visibility: hidden; + transition: + opacity 0.3s ease, + visibility 0.3s ease; } .moonfin-settings-dialog.open { - opacity: 1; - visibility: visible; + opacity: 1; + visibility: visible; } .moonfin-settings-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(4px); + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); } .moonfin-settings-panel { - position: relative; - width: 420px; - max-width: 90vw; - height: 100%; - background: rgba(20, 20, 25, 0.97); - display: flex; - flex-direction: column; - transform: translateX(100%); - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: -4px 0 30px rgba(0, 0, 0, 0.5); + position: relative; + width: 420px; + max-width: 90vw; + height: 100%; + background: rgba(20, 20, 25, 0.97); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: -4px 0 30px rgba(0, 0, 0, 0.5); } .moonfin-settings-dialog.open .moonfin-settings-panel { - transform: translateX(0); + transform: translateX(0); } .moonfin-settings-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 20px 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(15, 15, 20, 0.8); + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 20px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(15, 15, 20, 0.8); } .moonfin-settings-header-left { - display: flex; - align-items: baseline; - gap: 8px; + display: flex; + align-items: baseline; + gap: 8px; } .moonfin-settings-header h2 { - margin: 0; - font-size: 20px; - font-weight: 700; - color: #00a4dc; + margin: 0; + font-size: 20px; + font-weight: 700; + color: #00a4dc; } .moonfin-settings-subtitle { - font-size: 14px; - color: rgba(255, 255, 255, 0.5); - font-weight: 400; + font-size: 14px; + color: rgba(255, 255, 255, 0.5); + font-weight: 400; } .moonfin-settings-close { - width: 36px; - height: 36px; - border: none; - background: rgba(255, 255, 255, 0.06); - border-radius: 50%; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - color: rgba(255, 255, 255, 0.7); - transition: background 0.2s ease, color 0.2s ease; + width: 36px; + height: 36px; + border: none; + background: rgba(255, 255, 255, 0.06); + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.7); + transition: + background 0.2s ease, + color 0.2s ease; } .moonfin-settings-close:hover { - background: rgba(255, 255, 255, 0.12); - color: white; + background: rgba(255, 255, 255, 0.12); + color: white; } /* Profile Tabs */ .moonfin-profile-tabs { - display: flex; - gap: 2px; - padding: 10px 16px 0; - background: rgba(15, 15, 20, 0.6); - border-bottom: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + gap: 2px; + padding: 10px 16px 0; + background: rgba(15, 15, 20, 0.6); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .moonfin-profile-tab { - flex: 1; - padding: 8px 4px; - border: none; - background: rgba(255, 255, 255, 0.04); - color: rgba(255, 255, 255, 0.5); - font-size: 12px; - font-weight: 500; - cursor: pointer; - border-radius: 6px 6px 0 0; - transition: background 0.2s ease, color 0.2s ease; - text-align: center; - white-space: nowrap; - position: relative; + flex: 1; + padding: 8px 4px; + border: none; + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.5); + font-size: 12px; + font-weight: 500; + cursor: pointer; + border-radius: 6px 6px 0 0; + transition: + background 0.2s ease, + color 0.2s ease; + text-align: center; + white-space: nowrap; + position: relative; } .moonfin-profile-tab:hover { - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.8); + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.8); } .moonfin-profile-tab-active { - background: rgba(0, 164, 220, 0.15); - color: #00a4dc; - font-weight: 600; + background: rgba(0, 164, 220, 0.15); + color: #00a4dc; + font-weight: 600; } .moonfin-profile-tab-active:hover { - background: rgba(0, 164, 220, 0.2); - color: #00a4dc; + background: rgba(0, 164, 220, 0.2); + color: #00a4dc; } .moonfin-profile-current-badge { - color: #4caf50; - font-size: 8px; - vertical-align: super; + color: #4caf50; + font-size: 8px; + vertical-align: super; } .moonfin-profile-info { - display: flex; - align-items: flex-start; - gap: 6px; - padding: 8px 16px; - font-size: 11px; - color: rgba(255, 255, 255, 0.45); - background: rgba(0, 164, 220, 0.04); - line-height: 1.4; + display: flex; + align-items: flex-start; + gap: 6px; + padding: 8px 16px; + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + background: rgba(0, 164, 220, 0.04); + line-height: 1.4; } .moonfin-profile-info-icon { - color: rgba(0, 164, 220, 0.6); - flex-shrink: 0; - font-size: 12px; + color: rgba(0, 164, 220, 0.6); + flex-shrink: 0; + font-size: 12px; } /* Inherited setting indicator for device profiles */ .moonfin-toggle-card.moonfin-inherited, .moonfin-select-card.moonfin-inherited { - opacity: 0.55; - border-left: 2px solid rgba(255, 255, 255, 0.1); - padding-left: 10px; - transition: opacity 0.2s ease; + opacity: 0.55; + border-left: 2px solid rgba(255, 255, 255, 0.1); + padding-left: 10px; + transition: opacity 0.2s ease; } .moonfin-toggle-card.moonfin-inherited:hover, .moonfin-select-card.moonfin-inherited:hover { - opacity: 0.85; + opacity: 0.85; } /* Sync toggle in footer */ .moonfin-sync-toggle-label { - display: inline-flex; - align-items: center; - gap: 4px; - margin-left: 10px; - cursor: pointer; - font-size: 11px; - color: rgba(255, 255, 255, 0.5); - user-select: none; + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 10px; + cursor: pointer; + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + user-select: none; } -.moonfin-sync-toggle-label input[type="checkbox"] { - width: 14px; - height: 14px; - margin: 0; - cursor: pointer; - accent-color: #00a4dc; +.moonfin-sync-toggle-label input[type='checkbox'] { + width: 14px; + height: 14px; + margin: 0; + cursor: pointer; + accent-color: #00a4dc; } .moonfin-sync-toggle-text { - font-weight: 500; + font-weight: 500; } .moonfin-settings-content { - flex: 1; - overflow-y: auto; - padding: 16px 16px 20px; - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.15) transparent; + flex: 1; + overflow-y: auto; + padding: 16px 16px 20px; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.15) transparent; } .moonfin-settings-content::-webkit-scrollbar { - width: 6px; + width: 6px; } .moonfin-settings-content::-webkit-scrollbar-track { - background: transparent; + background: transparent; } .moonfin-settings-content::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.15); - border-radius: 3px; + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; } .moonfin-panel-section { - margin-bottom: 12px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; - background: rgba(255, 255, 255, 0.03); - overflow: hidden; + margin-bottom: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + overflow: hidden; } .moonfin-panel-summary { - padding: 14px 16px; - font-weight: 600; - font-size: 14px; - color: #00a4dc; - cursor: pointer; - user-select: none; - list-style: none; - display: flex; - align-items: center; - gap: 6px; - transition: background 0.2s ease; + padding: 14px 16px; + font-weight: 600; + font-size: 14px; + color: #00a4dc; + cursor: pointer; + user-select: none; + list-style: none; + display: flex; + align-items: center; + gap: 6px; + transition: background 0.2s ease; } .moonfin-panel-summary::-webkit-details-marker { - display: none; + display: none; } .moonfin-panel-summary::after { - content: ''; - margin-left: auto; - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 5px solid rgba(255, 255, 255, 0.4); - transition: transform 0.2s ease; + content: ''; + margin-left: auto; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid rgba(255, 255, 255, 0.4); + transition: transform 0.2s ease; } -.moonfin-panel-section[open]>.moonfin-panel-summary::after { - transform: rotate(180deg); +.moonfin-panel-section[open] > .moonfin-panel-summary::after { + transform: rotate(180deg); } .moonfin-panel-summary:hover { - background: rgba(255, 255, 255, 0.04); + background: rgba(255, 255, 255, 0.04); } .moonfin-panel-section-content { - padding: 4px 16px 16px; + padding: 4px 16px 16px; } .moonfin-toggle-card { - margin-bottom: 10px; - padding: 12px; - background: rgba(255, 255, 255, 0.04); - border-radius: 6px; - border-left: 3px solid #00a4dc; + margin-bottom: 10px; + padding: 12px; + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; + border-left: 3px solid #00a4dc; } .moonfin-toggle-card:last-child { - margin-bottom: 0; + margin-bottom: 0; } .moonfin-toggle-label { - display: flex; - align-items: center; - gap: 12px; - cursor: pointer; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; } -.moonfin-toggle-label input[type="checkbox"] { - width: 18px; - height: 18px; - accent-color: #00a4dc; - cursor: pointer; - flex-shrink: 0; +.moonfin-toggle-label input[type='checkbox'] { + width: 18px; + height: 18px; + accent-color: #00a4dc; + cursor: pointer; + flex-shrink: 0; } .moonfin-toggle-info { - flex: 1; - min-width: 0; + flex: 1; + min-width: 0; } .moonfin-toggle-title { - font-weight: 500; - font-size: 14px; - color: rgba(255, 255, 255, 0.9); + font-weight: 500; + font-size: 14px; + color: rgba(255, 255, 255, 0.9); } .moonfin-toggle-desc { - font-size: 12px; - color: rgba(255, 255, 255, 0.45); - margin-top: 2px; - line-height: 1.4; + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + margin-top: 2px; + line-height: 1.4; } .moonfin-select-card { - margin-bottom: 10px; - padding: 12px; - background: rgba(255, 255, 255, 0.04); - border-radius: 6px; - border-left: 3px solid rgba(0, 164, 220, 0.4); + margin-bottom: 10px; + padding: 12px; + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; + border-left: 3px solid rgba(0, 164, 220, 0.4); } .moonfin-select-card:last-child { - margin-bottom: 0; + margin-bottom: 0; } .moonfin-select-info { - margin-bottom: 8px; + margin-bottom: 8px; } .moonfin-panel-select { - width: 100%; - padding: 8px 12px; - font-size: 13px; - color: #fff; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 6px; - cursor: pointer; - outline: none; - transition: border-color 0.2s ease; - -webkit-appearance: none; - appearance: none; + width: 100%; + padding: 8px 12px; + font-size: 13px; + color: #fff; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + cursor: pointer; + outline: none; + transition: border-color 0.2s ease; + -webkit-appearance: none; + appearance: none; } .moonfin-panel-select:hover, .moonfin-panel-select:focus { - border-color: #00a4dc; + border-color: #00a4dc; } .moonfin-panel-select option { - background: #1a1a1e; - color: #fff; + background: #1a1a1e; + color: #fff; } .moonfin-panel-range { - width: 100%; - height: 4px; - -webkit-appearance: none; - appearance: none; - background: rgba(255, 255, 255, 0.15); - border-radius: 2px; - cursor: pointer; - outline: none; - margin-top: 4px; + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; + cursor: pointer; + outline: none; + margin-top: 4px; } .moonfin-panel-range::-webkit-slider-thumb { - -webkit-appearance: none; - width: 16px; - height: 16px; - background: #00a4dc; - border-radius: 50%; - cursor: pointer; - transition: transform 0.15s ease; + -webkit-appearance: none; + width: 16px; + height: 16px; + background: #00a4dc; + border-radius: 50%; + cursor: pointer; + transition: transform 0.15s ease; } .moonfin-panel-range::-webkit-slider-thumb:hover { - transform: scale(1.15); + transform: scale(1.15); } .moonfin-panel-range::-moz-range-thumb { - width: 16px; - height: 16px; - background: #00a4dc; - border: none; - border-radius: 50%; - cursor: pointer; + width: 16px; + height: 16px; + background: #00a4dc; + border: none; + border-radius: 50%; + cursor: pointer; } .moonfin-range-value { - font-weight: 600; - color: #00a4dc; - font-size: 13px; + font-weight: 600; + color: #00a4dc; + font-size: 13px; } .moonfin-color-preview { - width: 100%; - height: 6px; - border-radius: 3px; - margin: 4px 0 10px; + width: 100%; + height: 6px; + border-radius: 3px; + margin: 4px 0 10px; } .moonfin-panel-input { - width: 100%; - padding: 8px 12px; - font-size: 13px; - color: #fff; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 6px; - outline: none; - transition: border-color 0.2s ease; - box-sizing: border-box; + width: 100%; + padding: 8px 12px; + font-size: 13px; + color: #fff; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + outline: none; + transition: border-color 0.2s ease; + box-sizing: border-box; } .moonfin-panel-input:focus { - border-color: #00a4dc; + border-color: #00a4dc; } .moonfin-input-label { - display: block; - font-size: 12px; - color: rgba(255, 255, 255, 0.6); - margin-bottom: 4px; + display: block; + font-size: 12px; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 4px; } .moonfin-panel-btn { - display: inline-block; - padding: 8px 16px; - font-size: 13px; - font-weight: 600; - border: none; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; - width: 100%; - text-align: center; + display: inline-block; + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + width: 100%; + text-align: center; } .moonfin-panel-btn-primary { - background: #00a4dc; - color: #fff; + background: #00a4dc; + color: #fff; } .moonfin-panel-btn-primary:hover { - background: #0096c8; + background: #0096c8; } .moonfin-panel-btn-primary:disabled { - opacity: 0.6; - cursor: not-allowed; + opacity: 0.6; + cursor: not-allowed; } .moonfin-panel-btn-danger { - background: rgba(239, 68, 68, 0.15); - color: #ef4444; - border: 1px solid rgba(239, 68, 68, 0.3); + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); } .moonfin-panel-btn-danger:hover { - background: rgba(239, 68, 68, 0.25); + background: rgba(239, 68, 68, 0.25); } .moonfin-panel-btn-ghost { - background: transparent; - color: rgba(255, 255, 255, 0.6); - border: 1px solid rgba(255, 255, 255, 0.12); - padding: 6px 14px; - width: auto; + background: transparent; + color: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 6px 14px; + width: auto; } .moonfin-panel-btn-ghost:hover { - background: rgba(255, 255, 255, 0.08); - color: white; + background: rgba(255, 255, 255, 0.08); + color: white; } .moonfin-panel-btn-ghost:disabled { - opacity: 0.5; - cursor: not-allowed; + opacity: 0.5; + cursor: not-allowed; } .moonfin-panel-btn-close { - background: #00a4dc; - color: #fff; - padding: 6px 20px; - width: auto; + background: #00a4dc; + color: #fff; + padding: 6px 20px; + width: auto; } .moonfin-panel-btn-close:hover { - background: #0096c8; + background: #0096c8; } .moonfin-settings-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-top: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(15, 15, 20, 0.8); - gap: 12px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(15, 15, 20, 0.8); + gap: 12px; } .moonfin-settings-footer-buttons { - display: flex; - gap: 8px; - align-items: center; + display: flex; + gap: 8px; + align-items: center; } .moonfin-sync-status { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - color: rgba(255, 255, 255, 0.45); + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: rgba(255, 255, 255, 0.45); } .moonfin-sync-indicator { - width: 6px; - height: 6px; - border-radius: 50%; - background: #666; - flex-shrink: 0; + width: 6px; + height: 6px; + border-radius: 50%; + background: #666; + flex-shrink: 0; } .moonfin-sync-indicator.connected { - background: #4caf50; - box-shadow: 0 0 4px #4caf50; + background: #4caf50; + box-shadow: 0 0 4px #4caf50; } .moonfin-sync-indicator.disconnected { - background: #f44336; + background: #f44336; } .moonfin-sync-indicator.checking, .moonfin-sync-indicator.syncing { - background: #ff9800; - animation: moonfin-pulse 1s infinite; + background: #ff9800; + animation: moonfin-pulse 1s infinite; } @keyframes moonfin-pulse { + 0%, + 100% { + opacity: 1; + } - 0%, - 100% { - opacity: 1; - } - - 50% { - opacity: 0.4; - } + 50% { + opacity: 0.4; + } } .moonfin-jellyseerr-sso-status { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; } .moonfin-jellyseerr-sso-indicator { - width: 8px; - height: 8px; - border-radius: 50%; - background: #666; - flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: #666; + flex-shrink: 0; } .moonfin-jellyseerr-sso-indicator.connected { - background: #4caf50; - box-shadow: 0 0 6px #4caf50; + background: #4caf50; + box-shadow: 0 0 6px #4caf50; } .moonfin-jellyseerr-sso-indicator.disconnected { - background: #f44336; + background: #f44336; } .moonfin-jellyseerr-sso-text { - font-size: 13px; - color: rgba(255, 255, 255, 0.8); + font-size: 13px; + color: rgba(255, 255, 255, 0.8); } .moonfin-jellyseerr-login-error { - color: #ef4444; - font-size: 12px; - margin-bottom: 8px; - padding: 6px 10px; - background: rgba(239, 68, 68, 0.1); - border-radius: 4px; + color: #ef4444; + font-size: 12px; + margin-bottom: 8px; + padding: 6px 10px; + background: rgba(239, 68, 68, 0.1); + border-radius: 4px; } .moonfin-segmented-control { - display: flex; - background: rgba(255, 255, 255, 0.08); - border-radius: 8px; - padding: 3px; - gap: 2px; + display: flex; + background: rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 3px; + gap: 2px; } .moonfin-segmented-btn { - flex: 1; - padding: 6px 12px; - border: none; - border-radius: 6px; - background: transparent; - color: rgba(255, 255, 255, 0.6); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - font-family: inherit; + flex: 1; + padding: 6px 12px; + border: none; + border-radius: 6px; + background: transparent; + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; } .moonfin-segmented-btn:hover { - color: rgba(255, 255, 255, 0.85); - background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.85); + background: rgba(255, 255, 255, 0.05); } .moonfin-segmented-btn-active { - background: rgba(255, 255, 255, 0.15); - color: #fff; + background: rgba(255, 255, 255, 0.15); + color: #fff; } @media (max-width: 520px) { - .moonfin-settings-panel { - width: 100%; - max-width: 100vw; - } + .moonfin-settings-panel { + width: 100%; + max-width: 100vw; + } - .moonfin-settings-header h2 { - font-size: 18px; - } + .moonfin-settings-header h2 { + font-size: 18px; + } - .moonfin-settings-footer { - flex-direction: column; - align-items: stretch; - gap: 8px; - } + .moonfin-settings-footer { + flex-direction: column; + align-items: stretch; + gap: 8px; + } - .moonfin-settings-footer-buttons { - justify-content: flex-end; - } + .moonfin-settings-footer-buttons { + justify-content: flex-end; + } } /* Collection / Playlist Picker */ .moonfin-mediabar-library-options, .moonfin-mediabar-collection-options { - margin-bottom: 10px; + margin-bottom: 10px; } .moonfin-collection-picker { - max-height: 240px; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.15) transparent; + max-height: 240px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.15) transparent; } .moonfin-collection-picker::-webkit-scrollbar { - width: 4px; + width: 4px; } .moonfin-collection-picker::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.15); - border-radius: 2px; + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; } .moonfin-collection-picker-loading { - font-size: 12px; - color: rgba(255, 255, 255, 0.45); - padding: 12px 0; + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + padding: 12px 0; } .moonfin-collection-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px; - border-radius: 6px; - cursor: pointer; - transition: background 0.15s ease; - margin-bottom: 2px; + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s ease; + margin-bottom: 2px; } .moonfin-collection-item:hover { - background: rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.06); } .moonfin-collection-item-active { - background: rgba(0, 164, 220, 0.1); + background: rgba(0, 164, 220, 0.1); } .moonfin-collection-item-active:hover { - background: rgba(0, 164, 220, 0.15); + background: rgba(0, 164, 220, 0.15); } -.moonfin-collection-item input[type="checkbox"] { - width: 16px; - height: 16px; - accent-color: #00a4dc; - cursor: pointer; - flex-shrink: 0; +.moonfin-collection-item input[type='checkbox'] { + width: 16px; + height: 16px; + accent-color: #00a4dc; + cursor: pointer; + flex-shrink: 0; } .moonfin-collection-poster { - width: 40px; - height: 40px; - border-radius: 4px; - object-fit: cover; - flex-shrink: 0; - background: rgba(255, 255, 255, 0.08); + width: 40px; + height: 40px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.08); } .moonfin-collection-poster-empty { - display: flex; - align-items: center; - justify-content: center; + display: flex; + align-items: center; + justify-content: center; } .moonfin-collection-info { - flex: 1; - min-width: 0; + flex: 1; + min-width: 0; } .moonfin-collection-name { - font-size: 13px; - color: rgba(255, 255, 255, 0.9); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + font-size: 13px; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .moonfin-collection-type { - font-size: 11px; - color: rgba(255, 255, 255, 0.4); - margin-top: 1px; -} \ No newline at end of file + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + margin-top: 1px; +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index ea38c6c..a370609 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -1,808 +1,743 @@ const API = { - toCamelCase: function (obj) { - if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj; - var result = {}; - var keys = Object.keys(obj); - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - var camel = key.charAt(0).toLowerCase() + key.slice(1); - result[camel] = obj[key]; - } - return result; - }, - - getApiClient() { - return ( - window.ApiClient || - (window.connectionManager && window.connectionManager.currentApiClient()) - ); - }, - - async getCurrentUser() { - const api = this.getApiClient(); - if (!api) return null; - - try { - const user = await api.getCurrentUser(); - return user; - } catch (e) { - console.error("[Moonfin] Failed to get current user:", e); - return null; - } - }, - - async getUserViews() { - const api = this.getApiClient(); - if (!api) return []; - - try { - const userId = api.getCurrentUserId(); - const result = await api.getUserViews(userId); - return result.Items || []; - } catch (e) { - console.error("[Moonfin] Failed to get user views:", e); - return []; - } - }, - - async getMediaBarItems(profile) { - const api = this.getApiClient(); - if (!api) return null; - - try { - const serverUrl = api.serverAddress?.() || ""; - const token = api.accessToken?.(); - const headers = token - ? { Authorization: 'MediaBrowser Token="' + token + '"' } - : {}; - - const profileParam = profile || "global"; - const response = await fetch( - serverUrl + "/Moonfin/MediaBar?profile=" + encodeURIComponent(profileParam), - { method: "GET", headers: headers }, - ); - - if (response.ok) { - const data = await response.json(); - return data.Items || data.items || []; - } - - return null; - } catch (e) { - console.warn("[Moonfin] MediaBar endpoint not available, falling back:", e); - return null; - } - }, - - async getRandomItems(options = {}) { - const api = this.getApiClient(); - if (!api) return []; - - const { limit = 10, libraryIds = [] } = options; - - try { - const userId = api.getCurrentUserId(); - - const baseParams = { - userId: userId, - includeItemTypes: "Movie,Series", - sortBy: "Random", - recursive: true, - hasThemeSong: false, - hasThemeVideo: false, - fields: - "Overview,Genres,CommunityRating,CriticRating,OfficialRating,RunTimeTicks,ProductionYear,ProviderIds", - imageTypeLimit: 1, - enableImageTypes: "Backdrop,Logo,Primary", - }; - - // When specific libraries are selected, query each and merge - if (libraryIds && libraryIds.length > 0) { - var allItems = []; - var seenIds = {}; - - for (var i = 0; i < libraryIds.length; i++) { - var params = Object.assign({}, baseParams, { - parentId: libraryIds[i], - limit: limit, - }); - var libResult = await api.getItems(userId, params); - var items = libResult.Items || []; - for (var j = 0; j < items.length; j++) { - if (!seenIds[items[j].Id]) { - seenIds[items[j].Id] = true; - allItems.push(items[j]); - } - } + toCamelCase: function (obj) { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj; + var result = {}; + var keys = Object.keys(obj); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + var camel = key.charAt(0).toLowerCase() + key.slice(1); + result[camel] = obj[key]; + } + return result; + }, + + getApiClient() { + return window.ApiClient || (window.connectionManager && window.connectionManager.currentApiClient()); + }, + + async getCurrentUser() { + const api = this.getApiClient(); + if (!api) return null; + + try { + const user = await api.getCurrentUser(); + return user; + } catch (e) { + console.error('[Moonfin] Failed to get current user:', e); + return null; } + }, + + async getUserViews() { + const api = this.getApiClient(); + if (!api) return []; + + try { + const userId = api.getCurrentUserId(); + const result = await api.getUserViews(userId); + return result.Items || []; + } catch (e) { + console.error('[Moonfin] Failed to get user views:', e); + return []; + } + }, + + async getMediaBarItems(profile) { + const api = this.getApiClient(); + if (!api) return null; + + try { + const serverUrl = api.serverAddress?.() || ''; + const token = api.accessToken?.(); + const headers = token ? { Authorization: 'MediaBrowser Token="' + token + '"' } : {}; + + const profileParam = profile || 'global'; + const response = await fetch(serverUrl + '/Moonfin/MediaBar?profile=' + encodeURIComponent(profileParam), { + method: 'GET', + headers: headers + }); + + if (response.ok) { + const data = await response.json(); + return data.Items || data.items || []; + } - // Shuffle the merged results - for (var k = allItems.length - 1; k > 0; k--) { - var r = Math.floor(Math.random() * (k + 1)); - var temp = allItems[k]; - allItems[k] = allItems[r]; - allItems[r] = temp; + return null; + } catch (e) { + console.warn('[Moonfin] MediaBar endpoint not available, falling back:', e); + return null; } + }, + + async getRandomItems(options = {}) { + const api = this.getApiClient(); + if (!api) return []; + + const { limit = 10, libraryIds = [] } = options; + + try { + const userId = api.getCurrentUserId(); + + const baseParams = { + userId: userId, + includeItemTypes: 'Movie,Series', + sortBy: 'Random', + recursive: true, + hasThemeSong: false, + hasThemeVideo: false, + fields: 'Overview,Genres,CommunityRating,CriticRating,OfficialRating,RunTimeTicks,ProductionYear,ProviderIds', + imageTypeLimit: 1, + enableImageTypes: 'Backdrop,Logo,Primary' + }; + + // When specific libraries are selected, query each and merge + if (libraryIds && libraryIds.length > 0) { + var allItems = []; + var seenIds = {}; + + for (var i = 0; i < libraryIds.length; i++) { + var params = Object.assign({}, baseParams, { + parentId: libraryIds[i], + limit: limit + }); + var libResult = await api.getItems(userId, params); + var items = libResult.Items || []; + for (var j = 0; j < items.length; j++) { + if (!seenIds[items[j].Id]) { + seenIds[items[j].Id] = true; + allItems.push(items[j]); + } + } + } - return allItems.slice(0, limit); - } + // Shuffle the merged results + for (var k = allItems.length - 1; k > 0; k--) { + var r = Math.floor(Math.random() * (k + 1)); + var temp = allItems[k]; + allItems[k] = allItems[r]; + allItems[r] = temp; + } - // Default: all libraries - baseParams.limit = limit; - const result = await api.getItems(userId, baseParams); - return result.Items || []; - } catch (e) { - console.error("[Moonfin] Failed to get random items:", e); - return []; - } - }, - - async getCollectionsAndPlaylists() { - const api = this.getApiClient(); - if (!api) return []; - - try { - const userId = api.getCurrentUserId(); - const result = await api.getItems(userId, { - userId: userId, - includeItemTypes: "BoxSet,Playlist", - sortBy: "SortName", - sortOrder: "Ascending", - recursive: true, - fields: "PrimaryImageAspectRatio", - imageTypeLimit: 1, - enableImageTypes: "Primary", - }); - return result.Items || []; - } catch (e) { - console.error("[Moonfin] Failed to get collections/playlists:", e); - return []; - } - }, - - async getCollectionItems(collectionIds, options = {}) { - const api = this.getApiClient(); - if (!api || !collectionIds || collectionIds.length === 0) return []; - - const { limit = 10, shuffle = true } = options; - - try { - const userId = api.getCurrentUserId(); - const allItems = []; - const seenIds = {}; - - for (var i = 0; i < collectionIds.length; i++) { - const result = await api.getItems(userId, { - userId: userId, - parentId: collectionIds[i], - sortBy: shuffle ? "Random" : "SortName", - recursive: true, - fields: - "Overview,Genres,CommunityRating,CriticRating,OfficialRating,RunTimeTicks,ProductionYear,ProviderIds", - imageTypeLimit: 1, - enableImageTypes: "Backdrop,Logo,Primary", - }); + return allItems.slice(0, limit); + } - var items = result.Items || []; - for (var j = 0; j < items.length; j++) { - if (!seenIds[items[j].Id]) { - seenIds[items[j].Id] = true; - allItems.push(items[j]); - } + // Default: all libraries + baseParams.limit = limit; + const result = await api.getItems(userId, baseParams); + return result.Items || []; + } catch (e) { + console.error('[Moonfin] Failed to get random items:', e); + return []; } - } - - // Shuffle merged results if requested - if (shuffle) { - for (var k = allItems.length - 1; k > 0; k--) { - var r = Math.floor(Math.random() * (k + 1)); - var temp = allItems[k]; - allItems[k] = allItems[r]; - allItems[r] = temp; + }, + + async getCollectionsAndPlaylists() { + const api = this.getApiClient(); + if (!api) return []; + + try { + const userId = api.getCurrentUserId(); + const result = await api.getItems(userId, { + userId: userId, + includeItemTypes: 'BoxSet,Playlist', + sortBy: 'SortName', + sortOrder: 'Ascending', + recursive: true, + fields: 'PrimaryImageAspectRatio', + imageTypeLimit: 1, + enableImageTypes: 'Primary' + }); + return result.Items || []; + } catch (e) { + console.error('[Moonfin] Failed to get collections/playlists:', e); + return []; + } + }, + + async getCollectionItems(collectionIds, options = {}) { + const api = this.getApiClient(); + if (!api || !collectionIds || collectionIds.length === 0) return []; + + const { limit = 10, shuffle = true } = options; + + try { + const userId = api.getCurrentUserId(); + const allItems = []; + const seenIds = {}; + + for (var i = 0; i < collectionIds.length; i++) { + const result = await api.getItems(userId, { + userId: userId, + parentId: collectionIds[i], + sortBy: shuffle ? 'Random' : 'SortName', + recursive: true, + fields: 'Overview,Genres,CommunityRating,CriticRating,OfficialRating,RunTimeTicks,ProductionYear,ProviderIds', + imageTypeLimit: 1, + enableImageTypes: 'Backdrop,Logo,Primary' + }); + + var items = result.Items || []; + for (var j = 0; j < items.length; j++) { + if (!seenIds[items[j].Id]) { + seenIds[items[j].Id] = true; + allItems.push(items[j]); + } + } + } + + // Shuffle merged results if requested + if (shuffle) { + for (var k = allItems.length - 1; k > 0; k--) { + var r = Math.floor(Math.random() * (k + 1)); + var temp = allItems[k]; + allItems[k] = allItems[r]; + allItems[r] = temp; + } + } + + return allItems.slice(0, limit); + } catch (e) { + console.error('[Moonfin] Failed to get collection items:', e); + return []; + } + }, + + async getItemTrailers(itemId) { + const api = this.getApiClient(); + if (!api || !itemId) return []; + + try { + const userId = api.getCurrentUserId(); + const result = await api.getItems(userId, { + ids: itemId, + userId: userId, + fields: 'RemoteTrailers', + limit: 1 + }); + const item = result.Items && result.Items[0]; + return (item && item.RemoteTrailers) || []; + } catch (e) { + return []; + } + }, + + getImageUrl(item, imageType = 'Backdrop', options = {}) { + const api = this.getApiClient(); + if (!api || !item) return null; + + const itemId = item.Id; + const { maxWidth = 1920, maxHeight = 1080, quality = 96 } = options; + + if (!item.ImageTags || !item.ImageTags[imageType]) { + // For backdrop, check BackdropImageTags + if (imageType === 'Backdrop' && item.BackdropImageTags && item.BackdropImageTags.length > 0) { + return api.getScaledImageUrl(itemId, { + type: 'Backdrop', + maxWidth, + maxHeight, + quality, + tag: item.BackdropImageTags[0] + }); + } + return null; } - } - return allItems.slice(0, limit); - } catch (e) { - console.error("[Moonfin] Failed to get collection items:", e); - return []; - } - }, - - async getItemTrailers(itemId) { - const api = this.getApiClient(); - if (!api || !itemId) return []; - - try { - const userId = api.getCurrentUserId(); - const result = await api.getItems(userId, { - ids: itemId, - userId: userId, - fields: "RemoteTrailers", - limit: 1, - }); - const item = result.Items && result.Items[0]; - return (item && item.RemoteTrailers) || []; - } catch (e) { - return []; - } - }, - - getImageUrl(item, imageType = "Backdrop", options = {}) { - const api = this.getApiClient(); - if (!api || !item) return null; - - const itemId = item.Id; - const { maxWidth = 1920, maxHeight = 1080, quality = 96 } = options; - - if (!item.ImageTags || !item.ImageTags[imageType]) { - // For backdrop, check BackdropImageTags - if ( - imageType === "Backdrop" && - item.BackdropImageTags && - item.BackdropImageTags.length > 0 - ) { return api.getScaledImageUrl(itemId, { - type: "Backdrop", - maxWidth, - maxHeight, - quality, - tag: item.BackdropImageTags[0], + type: imageType, + maxWidth, + maxHeight, + quality, + tag: item.ImageTags[imageType] }); - } - return null; - } + }, - return api.getScaledImageUrl(itemId, { - type: imageType, - maxWidth, - maxHeight, - quality, - tag: item.ImageTags[imageType], - }); - }, - - getUserAvatarUrl(user) { - const api = this.getApiClient(); - if (!api || !user) return null; - - if (user.PrimaryImageTag) { - return api.getUserImageUrl(user.Id, { - type: "Primary", - tag: user.PrimaryImageTag, - }); - } - return null; - }, - - navigateToItem(itemId) { - if (window.Emby && window.Emby.Page) { - window.Emby.Page.show("/details?id=" + itemId); - } else if (window.appRouter) { - window.appRouter.show("/details?id=" + itemId); - } - }, + getUserAvatarUrl(user) { + const api = this.getApiClient(); + if (!api || !user) return null; - navigateTo(path) { - if (window.Emby && window.Emby.Page) { - window.Emby.Page.show(path); - } else if (window.appRouter) { - window.appRouter.show(path); - } - }, - - async getGenres(parentId) { - var api = this.getApiClient(); - if (!api) return []; - - try { - var userId = api.getCurrentUserId(); - var params = { - userId: userId, - includeItemTypes: "Movie,Series", - sortBy: "SortName", - sortOrder: "Ascending", - recursive: true, - enableTotalRecordCount: true, - }; - if (parentId) { - params.parentId = parentId; - } - var result = await api.getGenres(userId, params); - return result.Items || []; - } catch (e) { - console.error("[Moonfin] Failed to get genres:", e); - return []; - } - }, - - async getGenreItems(genreName, options) { - var api = this.getApiClient(); - if (!api) return { Items: [], TotalRecordCount: 0 }; - - try { - var userId = api.getCurrentUserId(); - var params = { - userId: userId, - genres: genreName, - includeItemTypes: options.includeItemTypes || "Movie,Series", - sortBy: options.sortBy || "SortName", - sortOrder: options.sortOrder || "Ascending", - recursive: true, - startIndex: options.startIndex || 0, - limit: options.limit || 100, - enableTotalRecordCount: true, - fields: - "PrimaryImageAspectRatio,ProductionYear,CommunityRating,OfficialRating,RunTimeTicks,Overview,Genres", - imageTypeLimit: 1, - enableImageTypes: "Primary,Backdrop", - }; - if (options.parentId) { - params.parentId = options.parentId; - } - if (options.nameStartsWith) { - params.nameStartsWith = options.nameStartsWith; - } - if (options.nameLessThan) { - params.nameLessThan = options.nameLessThan; - } - var result = await api.getItems(userId, params); - return result; - } catch (e) { - console.error("[Moonfin] Failed to get genre items:", e); - return { Items: [], TotalRecordCount: 0 }; - } - }, - - async getLibraryItems(parentId, options) { - var api = this.getApiClient(); - if (!api) return { Items: [], TotalRecordCount: 0 }; - - try { - var userId = api.getCurrentUserId(); - var params = { - userId: userId, - parentId: parentId, - includeItemTypes: options.includeItemTypes || "Movie,Series", - sortBy: options.sortBy || "SortName", - sortOrder: options.sortOrder || "Ascending", - recursive: true, - startIndex: options.startIndex || 0, - limit: options.limit || 100, - enableTotalRecordCount: true, - fields: - "PrimaryImageAspectRatio,ProductionYear,CommunityRating,OfficialRating,RunTimeTicks,Overview,Genres", - imageTypeLimit: 1, - enableImageTypes: "Primary,Backdrop", - }; - if (options.nameStartsWith) { - params.nameStartsWith = options.nameStartsWith; - } - if (options.nameLessThan) { - params.nameLessThan = options.nameLessThan; - } - var result = await api.getItems(userId, params); - return result; - } catch (e) { - console.error("[Moonfin] Failed to get library items:", e); - return { Items: [], TotalRecordCount: 0 }; - } - }, - - getPrimaryImageUrl(item, options) { - var api = this.getApiClient(); - if (!api || !item) return null; - - var opts = options || {}; - var maxWidth = opts.maxWidth || 300; - var quality = opts.quality || 90; - - if (item.ImageTags && item.ImageTags.Primary) { - return api.getScaledImageUrl(item.Id, { - type: "Primary", - maxWidth: maxWidth, - quality: quality, - tag: item.ImageTags.Primary, - }); - } - return null; - }, - - getBackdropUrl(item, options) { - var api = this.getApiClient(); - if (!api || !item) return null; - - var opts = options || {}; - var maxWidth = opts.maxWidth || 780; - var quality = opts.quality || 80; - - if (item.BackdropImageTags && item.BackdropImageTags.length > 0) { - return api.getScaledImageUrl(item.Id, { - type: "Backdrop", - maxWidth: maxWidth, - quality: quality, - tag: item.BackdropImageTags[0], - }); - } - // Fallback for items without their own backdrop - if ( - item.ParentBackdropItemId && - item.ParentBackdropImageTags && - item.ParentBackdropImageTags.length > 0 - ) { - return api.getScaledImageUrl(item.ParentBackdropItemId, { - type: "Backdrop", - maxWidth: maxWidth, - quality: quality, - tag: item.ParentBackdropImageTags[0], - }); - } - return null; - }, - - // ===== Webpack Interop for Jellyfin Web Editor Dialogs ===== - // Editor modules (metadataEditor, imageeditor, subtitleeditor, itemidentifier) are in - // separate lazy-loaded webpack chunks. We capture __webpack_require__ via the chunk push - // trick, find the shortcuts.js + itemContextMenu.js modules by their unique strings, - // extract the chunk IDs from their compiled source, load those chunks, then find the - // editor modules by their export shapes. - - getServerId: function () { - var api = this.getApiClient(); - if (!api) return null; - try { - if (api.serverInfo && typeof api.serverInfo === "function") - return api.serverInfo().Id; - if (api._serverInfo) return api._serverInfo.Id; - if (api.serverId && typeof api.serverId === "function") - return api.serverId(); - } catch (e) {} - return null; - }, - - _initWebpackRequire: function () { - if (window.__moonfin_wp_require) return true; - // Auto-detect: the compiled global varies by build config - // Production jellyfin-web uses just 'webpackChunk' (no suffix) - var chunkArr = - window.webpackChunk || - window.webpackChunkjellyfin_web || - window["webpackChunk_jellyfin_web"]; - if (!chunkArr) { - // Scan window for any webpackChunk* array - var keys = Object.keys(window); - for (var i = 0; i < keys.length; i++) { - if ( - keys[i].indexOf("webpackChunk") === 0 && - Array.isArray(window[keys[i]]) - ) { - chunkArr = window[keys[i]]; - break; + if (user.PrimaryImageTag) { + return api.getUserImageUrl(user.Id, { + type: 'Primary', + tag: user.PrimaryImageTag + }); } - } - } - if (!chunkArr) { - console.warn("[Moonfin] No webpackChunk array found"); - return false; - } - chunkArr.push([ - ["moonfin"], - {}, - function (__webpack_require__) { - window.__moonfin_wp_require = __webpack_require__; - }, - ]); - return !!window.__moonfin_wp_require; - }, - - _extractChunkIds: function (factorySource) { - var ids = []; - // Match numeric chunk IDs: .e(12345) - var regex = /\.e\((\d+)\)/g; - var match; - while ((match = regex.exec(factorySource)) !== null) { - var id = parseInt(match[1]); - if (ids.indexOf(id) === -1) ids.push(id); - } - // Match string chunk IDs: .e("chunk-name") - regex = /\.e\("([^"]+)"\)/g; - while ((match = regex.exec(factorySource)) !== null) { - if (ids.indexOf(match[1]) === -1) ids.push(match[1]); - } - return ids; - }, + return null; + }, + + navigateToItem(itemId) { + if (window.Emby && window.Emby.Page) { + window.Emby.Page.show('/details?id=' + itemId); + } else if (window.appRouter) { + window.appRouter.show('/details?id=' + itemId); + } + }, - _loadChunks: function (req, chunkIds) { - var loads = []; - for (var i = 0; i < chunkIds.length; i++) { - loads.push(req.e(chunkIds[i]).catch(function () {})); - } - return Promise.all(loads); - }, - - _findFactoryByHint: function (req, hint) { - var factories = req.m || {}; - var keys = Object.keys(factories); - for (var i = 0; i < keys.length; i++) { - try { - if (factories[keys[i]].toString().indexOf(hint) !== -1) { - return keys[i]; + navigateTo(path) { + if (window.Emby && window.Emby.Page) { + window.Emby.Page.show(path); + } else if (window.appRouter) { + window.appRouter.show(path); } - } catch (e) {} - } - return null; - }, - - _findFactoryByHints: function (req, hints) { - var factories = req.m || {}; - var keys = Object.keys(factories); - for (var i = 0; i < keys.length; i++) { - try { - var src = factories[keys[i]].toString(); - var allMatch = true; - for (var h = 0; h < hints.length; h++) { - if (src.indexOf(hints[h]) === -1) { - allMatch = false; - break; - } + }, + + async getGenres(parentId) { + var api = this.getApiClient(); + if (!api) return []; + + try { + var userId = api.getCurrentUserId(); + var params = { + userId: userId, + includeItemTypes: 'Movie,Series', + sortBy: 'SortName', + sortOrder: 'Ascending', + recursive: true, + enableTotalRecordCount: true + }; + if (parentId) { + params.parentId = parentId; + } + var result = await api.getGenres(userId, params); + return result.Items || []; + } catch (e) { + console.error('[Moonfin] Failed to get genres:', e); + return []; } - if (allMatch) return keys[i]; - } catch (e) {} - } - return null; - }, - - _editorModulesPromise: null, - - _loadEditorModules: function () { - if (this._editorModulesPromise) return this._editorModulesPromise; - if ( - window.__moonfin_editors && - Object.keys(window.__moonfin_editors).length >= 4 - ) { - return Promise.resolve(true); - } + }, + + async getGenreItems(genreName, options) { + var api = this.getApiClient(); + if (!api) return { Items: [], TotalRecordCount: 0 }; + + try { + var userId = api.getCurrentUserId(); + var params = { + userId: userId, + genres: genreName, + includeItemTypes: options.includeItemTypes || 'Movie,Series', + sortBy: options.sortBy || 'SortName', + sortOrder: options.sortOrder || 'Ascending', + recursive: true, + startIndex: options.startIndex || 0, + limit: options.limit || 100, + enableTotalRecordCount: true, + fields: 'PrimaryImageAspectRatio,ProductionYear,CommunityRating,OfficialRating,RunTimeTicks,Overview,Genres', + imageTypeLimit: 1, + enableImageTypes: 'Primary,Backdrop' + }; + if (options.parentId) { + params.parentId = options.parentId; + } + if (options.nameStartsWith) { + params.nameStartsWith = options.nameStartsWith; + } + if (options.nameLessThan) { + params.nameLessThan = options.nameLessThan; + } + var result = await api.getItems(userId, params); + return result; + } catch (e) { + console.error('[Moonfin] Failed to get genre items:', e); + return { Items: [], TotalRecordCount: 0 }; + } + }, + + async getLibraryItems(parentId, options) { + var api = this.getApiClient(); + if (!api) return { Items: [], TotalRecordCount: 0 }; + + try { + var userId = api.getCurrentUserId(); + var params = { + userId: userId, + parentId: parentId, + includeItemTypes: options.includeItemTypes || 'Movie,Series', + sortBy: options.sortBy || 'SortName', + sortOrder: options.sortOrder || 'Ascending', + recursive: true, + startIndex: options.startIndex || 0, + limit: options.limit || 100, + enableTotalRecordCount: true, + fields: 'PrimaryImageAspectRatio,ProductionYear,CommunityRating,OfficialRating,RunTimeTicks,Overview,Genres', + imageTypeLimit: 1, + enableImageTypes: 'Primary,Backdrop' + }; + if (options.nameStartsWith) { + params.nameStartsWith = options.nameStartsWith; + } + if (options.nameLessThan) { + params.nameLessThan = options.nameLessThan; + } + var result = await api.getItems(userId, params); + return result; + } catch (e) { + console.error('[Moonfin] Failed to get library items:', e); + return { Items: [], TotalRecordCount: 0 }; + } + }, + + getPrimaryImageUrl(item, options) { + var api = this.getApiClient(); + if (!api || !item) return null; + + var opts = options || {}; + var maxWidth = opts.maxWidth || 300; + var quality = opts.quality || 90; + + if (item.ImageTags && item.ImageTags.Primary) { + return api.getScaledImageUrl(item.Id, { + type: 'Primary', + maxWidth: maxWidth, + quality: quality, + tag: item.ImageTags.Primary + }); + } + return null; + }, + + getBackdropUrl(item, options) { + var api = this.getApiClient(); + if (!api || !item) return null; + + var opts = options || {}; + var maxWidth = opts.maxWidth || 780; + var quality = opts.quality || 80; + + if (item.BackdropImageTags && item.BackdropImageTags.length > 0) { + return api.getScaledImageUrl(item.Id, { + type: 'Backdrop', + maxWidth: maxWidth, + quality: quality, + tag: item.BackdropImageTags[0] + }); + } + // Fallback for items without their own backdrop + if (item.ParentBackdropItemId && item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) { + return api.getScaledImageUrl(item.ParentBackdropItemId, { + type: 'Backdrop', + maxWidth: maxWidth, + quality: quality, + tag: item.ParentBackdropImageTags[0] + }); + } + return null; + }, + + // ===== Webpack Interop for Jellyfin Web Editor Dialogs ===== + // Editor modules (metadataEditor, imageeditor, subtitleeditor, itemidentifier) are in + // separate lazy-loaded webpack chunks. We capture __webpack_require__ via the chunk push + // trick, find the shortcuts.js + itemContextMenu.js modules by their unique strings, + // extract the chunk IDs from their compiled source, load those chunks, then find the + // editor modules by their export shapes. + + getServerId: function () { + var api = this.getApiClient(); + if (!api) return null; + try { + if (api.serverInfo && typeof api.serverInfo === 'function') return api.serverInfo().Id; + if (api._serverInfo) return api._serverInfo.Id; + if (api.serverId && typeof api.serverId === 'function') return api.serverId(); + } catch (e) {} + return null; + }, + + _initWebpackRequire: function () { + if (window.__moonfin_wp_require) return true; + // Auto-detect: the compiled global varies by build config + // Production jellyfin-web uses just 'webpackChunk' (no suffix) + var chunkArr = window.webpackChunk || window.webpackChunkjellyfin_web || window['webpackChunk_jellyfin_web']; + if (!chunkArr) { + // Scan window for any webpackChunk* array + var keys = Object.keys(window); + for (var i = 0; i < keys.length; i++) { + if (keys[i].indexOf('webpackChunk') === 0 && Array.isArray(window[keys[i]])) { + chunkArr = window[keys[i]]; + break; + } + } + } + if (!chunkArr) { + console.warn('[Moonfin] No webpackChunk array found'); + return false; + } + chunkArr.push([ + ['moonfin'], + {}, + function (__webpack_require__) { + window.__moonfin_wp_require = __webpack_require__; + } + ]); + return !!window.__moonfin_wp_require; + }, + + _extractChunkIds: function (factorySource) { + var ids = []; + // Match numeric chunk IDs: .e(12345) + var regex = /\.e\((\d+)\)/g; + var match; + while ((match = regex.exec(factorySource)) !== null) { + var id = parseInt(match[1]); + if (ids.indexOf(id) === -1) ids.push(id); + } + // Match string chunk IDs: .e("chunk-name") + regex = /\.e\("([^"]+)"\)/g; + while ((match = regex.exec(factorySource)) !== null) { + if (ids.indexOf(match[1]) === -1) ids.push(match[1]); + } + return ids; + }, - var apiSelf = this; - this._editorModulesPromise = new Promise(function (resolve) { - if (!apiSelf._initWebpackRequire()) { - resolve(false); - return; - } - - var req = window.__moonfin_wp_require; - - // Step 1: Find shortcuts.js module by its unique 'playAllFromHere' export - var shortcutsId = apiSelf._findFactoryByHint(req, "playAllFromHere"); - if (!shortcutsId) { - console.warn( - "[Moonfin] Could not find shortcuts module in webpack factories", - ); - resolve(false); - return; - } - - // Step 2: Extract chunk IDs from shortcuts factory and load them - var shortcutChunkIds = apiSelf._extractChunkIds( - req.m[shortcutsId].toString(), - ); - console.log("[Moonfin] Loading shortcuts chunks:", shortcutChunkIds); - - apiSelf - ._loadChunks(req, shortcutChunkIds) - .then(function () { - // Step 3: Find itemContextMenu module by its unique command strings - var icmId = apiSelf._findFactoryByHints(req, [ - "editimages", - "editsubtitles", - "identify", - ]); - if (icmId) { - // Step 4: Extract and load itemContextMenu's chunks (editor modules) - var icmChunkIds = apiSelf._extractChunkIds(req.m[icmId].toString()); - console.log( - "[Moonfin] Loading itemContextMenu chunks:", - icmChunkIds, - ); - return apiSelf._loadChunks(req, icmChunkIds); - } - }) - .then(function () { - // Step 5: All editor chunks loaded. Find editor modules by export shape. - var editors = {}; - var factories = req.m; - var cache = req.c || {}; - var fkeys = Object.keys(factories); - - // Source hints to narrow down which factories to try instantiating - var editorHints = [ - "editItemMetadataForm", - "MessageItemSaved", - "imageType", - "hasChanges", - "subtitleList", - "btnOpenUploadMenu", - "showFindNew", - "identifyResults", - ]; - - for (var i = 0; i < fkeys.length; i++) { - var id = fkeys[i]; - var mod; - - // Check cache first (no side effects) - if (cache[id]) { - mod = cache[id].exports; - } else { - // Only try modules whose factory contains editor-related strings - var src; - try { - src = factories[id].toString(); - } catch (e) { - continue; - } - var isEditor = false; - for (var h = 0; h < editorHints.length; h++) { - if (src.indexOf(editorHints[h]) !== -1) { - isEditor = true; - break; + _loadChunks: function (req, chunkIds) { + var loads = []; + for (var i = 0; i < chunkIds.length; i++) { + loads.push(req.e(chunkIds[i]).catch(function () {})); + } + return Promise.all(loads); + }, + + _findFactoryByHint: function (req, hint) { + var factories = req.m || {}; + var keys = Object.keys(factories); + for (var i = 0; i < keys.length; i++) { + try { + if (factories[keys[i]].toString().indexOf(hint) !== -1) { + return keys[i]; + } + } catch (e) {} + } + return null; + }, + + _findFactoryByHints: function (req, hints) { + var factories = req.m || {}; + var keys = Object.keys(factories); + for (var i = 0; i < keys.length; i++) { + try { + var src = factories[keys[i]].toString(); + var allMatch = true; + for (var h = 0; h < hints.length; h++) { + if (src.indexOf(hints[h]) === -1) { + allMatch = false; + break; + } } - } - if (!isEditor) continue; - - try { - mod = req(id); - } catch (e) { - continue; - } + if (allMatch) return keys[i]; + } catch (e) {} + } + return null; + }, + + _editorModulesPromise: null, + + _loadEditorModules: function () { + if (this._editorModulesPromise) return this._editorModulesPromise; + if (window.__moonfin_editors && Object.keys(window.__moonfin_editors).length >= 4) { + return Promise.resolve(true); + } + + var apiSelf = this; + this._editorModulesPromise = new Promise(function (resolve) { + if (!apiSelf._initWebpackRequire()) { + resolve(false); + return; } - if (!mod) continue; - apiSelf._matchEditorExports(mod, editors); - - if ( - editors.metadata && - editors.identifier && - editors.image && - editors.subtitle - ) - break; - } - - window.__moonfin_editors = editors; - var found = Object.keys(editors); - console.log( - "[Moonfin] Found editor modules:", - found.join(", ") || "none", - ); - resolve(found.length > 0); - }) - .catch(function (e) { - console.error("[Moonfin] Error loading editor chunks:", e); - resolve(false); + var req = window.__moonfin_wp_require; + + // Step 1: Find shortcuts.js module by its unique 'playAllFromHere' export + var shortcutsId = apiSelf._findFactoryByHint(req, 'playAllFromHere'); + if (!shortcutsId) { + console.warn('[Moonfin] Could not find shortcuts module in webpack factories'); + resolve(false); + return; + } + + // Step 2: Extract chunk IDs from shortcuts factory and load them + var shortcutChunkIds = apiSelf._extractChunkIds(req.m[shortcutsId].toString()); + console.log('[Moonfin] Loading shortcuts chunks:', shortcutChunkIds); + + apiSelf + ._loadChunks(req, shortcutChunkIds) + .then(function () { + // Step 3: Find itemContextMenu module by its unique command strings + var icmId = apiSelf._findFactoryByHints(req, ['editimages', 'editsubtitles', 'identify']); + if (icmId) { + // Step 4: Extract and load itemContextMenu's chunks (editor modules) + var icmChunkIds = apiSelf._extractChunkIds(req.m[icmId].toString()); + console.log('[Moonfin] Loading itemContextMenu chunks:', icmChunkIds); + return apiSelf._loadChunks(req, icmChunkIds); + } + }) + .then(function () { + // Step 5: All editor chunks loaded. Find editor modules by export shape. + var editors = {}; + var factories = req.m; + var cache = req.c || {}; + var fkeys = Object.keys(factories); + + // Source hints to narrow down which factories to try instantiating + var editorHints = [ + 'editItemMetadataForm', + 'MessageItemSaved', + 'imageType', + 'hasChanges', + 'subtitleList', + 'btnOpenUploadMenu', + 'showFindNew', + 'identifyResults' + ]; + + for (var i = 0; i < fkeys.length; i++) { + var id = fkeys[i]; + var mod; + + // Check cache first (no side effects) + if (cache[id]) { + mod = cache[id].exports; + } else { + // Only try modules whose factory contains editor-related strings + var src; + try { + src = factories[id].toString(); + } catch (e) { + continue; + } + var isEditor = false; + for (var h = 0; h < editorHints.length; h++) { + if (src.indexOf(editorHints[h]) !== -1) { + isEditor = true; + break; + } + } + if (!isEditor) continue; + + try { + mod = req(id); + } catch (e) { + continue; + } + } + + if (!mod) continue; + apiSelf._matchEditorExports(mod, editors); + + if (editors.metadata && editors.identifier && editors.image && editors.subtitle) break; + } + + window.__moonfin_editors = editors; + var found = Object.keys(editors); + console.log('[Moonfin] Found editor modules:', found.join(', ') || 'none'); + resolve(found.length > 0); + }) + .catch(function (e) { + console.error('[Moonfin] Error loading editor chunks:', e); + resolve(false); + }); }); - }); - - // Allow retry on failure - this._editorModulesPromise.then(function (success) { - if (!success) apiSelf._editorModulesPromise = null; - }); - - return this._editorModulesPromise; - }, - - _matchEditorExports: function (mod, editors) { - if (!mod) return; - var d = mod.default || mod; - - // metadataEditor: default export with show() + embed() — unique shape - if ( - !editors.metadata && - d && - typeof d.show === "function" && - typeof d.embed === "function" - ) { - editors.metadata = d; - return; - } - // itemIdentifier: named exports show() + showFindNew() — unique shape - if ( - !editors.identifier && - typeof mod.show === "function" && - typeof mod.showFindNew === "function" - ) { - editors.identifier = mod; - return; - } + // Allow retry on failure + this._editorModulesPromise.then(function (success) { + if (!success) apiSelf._editorModulesPromise = null; + }); - // imageeditor: named export show(options), length <= 1, no showFindNew/embed - if ( - !editors.image && - typeof mod.show === "function" && - !mod.showFindNew && - !(d && typeof d.embed === "function") && - mod.show.length <= 1 && - mod !== (editors.identifier || null) - ) { - editors.image = mod; - return; - } + return this._editorModulesPromise; + }, + + _matchEditorExports: function (mod, editors) { + if (!mod) return; + var d = mod.default || mod; + + // metadataEditor: default export with show() + embed() — unique shape + if (!editors.metadata && d && typeof d.show === 'function' && typeof d.embed === 'function') { + editors.metadata = d; + return; + } + + // itemIdentifier: named exports show() + showFindNew() — unique shape + if (!editors.identifier && typeof mod.show === 'function' && typeof mod.showFindNew === 'function') { + editors.identifier = mod; + return; + } - // subtitleeditor: default export with show(itemId, serverId), no embed - if ( - !editors.subtitle && - d && - d !== mod && - typeof d.show === "function" && - !d.embed && - d.show.length >= 2 - ) { - editors.subtitle = d; - return; + // imageeditor: named export show(options), length <= 1, no showFindNew/embed + if ( + !editors.image && + typeof mod.show === 'function' && + !mod.showFindNew && + !(d && typeof d.embed === 'function') && + mod.show.length <= 1 && + mod !== (editors.identifier || null) + ) { + editors.image = mod; + return; + } + + // subtitleeditor: default export with show(itemId, serverId), no embed + if (!editors.subtitle && d && d !== mod && typeof d.show === 'function' && !d.embed && d.show.length >= 2) { + editors.subtitle = d; + return; + } + }, + + openMetadataEditor: function (itemId) { + var serverId = this.getServerId(); + return this._loadEditorModules() + .then(function (loaded) { + var ed = window.__moonfin_editors && window.__moonfin_editors.metadata; + if (!ed) return false; + ed.show(itemId, serverId); + return true; + }) + .catch(function (e) { + console.warn('[Moonfin] Failed to open metadata editor:', e); + return false; + }); + }, + + openImageEditor: function (itemId) { + var serverId = this.getServerId(); + return this._loadEditorModules() + .then(function (loaded) { + var ed = window.__moonfin_editors && window.__moonfin_editors.image; + if (!ed) return false; + var showFn = ed.show || (ed.default && ed.default.show); + if (!showFn) return false; + showFn({ itemId: itemId, serverId: serverId }); + return true; + }) + .catch(function (e) { + console.warn('[Moonfin] Failed to open image editor:', e); + return false; + }); + }, + + openSubtitleEditor: function (itemId) { + var serverId = this.getServerId(); + return this._loadEditorModules() + .then(function (loaded) { + var ed = window.__moonfin_editors && window.__moonfin_editors.subtitle; + if (!ed) return false; + ed.show(itemId, serverId); + return true; + }) + .catch(function (e) { + console.warn('[Moonfin] Failed to open subtitle editor:', e); + return false; + }); + }, + + openItemIdentifier: function (itemId) { + var serverId = this.getServerId(); + return this._loadEditorModules() + .then(function (loaded) { + var ed = window.__moonfin_editors && window.__moonfin_editors.identifier; + if (!ed) return false; + ed.show(itemId, serverId); + return true; + }) + .catch(function (e) { + console.warn('[Moonfin] Failed to open item identifier:', e); + return false; + }); } - }, - - openMetadataEditor: function (itemId) { - var serverId = this.getServerId(); - return this._loadEditorModules() - .then(function (loaded) { - var ed = window.__moonfin_editors && window.__moonfin_editors.metadata; - if (!ed) return false; - ed.show(itemId, serverId); - return true; - }) - .catch(function (e) { - console.warn("[Moonfin] Failed to open metadata editor:", e); - return false; - }); - }, - - openImageEditor: function (itemId) { - var serverId = this.getServerId(); - return this._loadEditorModules() - .then(function (loaded) { - var ed = window.__moonfin_editors && window.__moonfin_editors.image; - if (!ed) return false; - var showFn = ed.show || (ed.default && ed.default.show); - if (!showFn) return false; - showFn({ itemId: itemId, serverId: serverId }); - return true; - }) - .catch(function (e) { - console.warn("[Moonfin] Failed to open image editor:", e); - return false; - }); - }, - - openSubtitleEditor: function (itemId) { - var serverId = this.getServerId(); - return this._loadEditorModules() - .then(function (loaded) { - var ed = window.__moonfin_editors && window.__moonfin_editors.subtitle; - if (!ed) return false; - ed.show(itemId, serverId); - return true; - }) - .catch(function (e) { - console.warn("[Moonfin] Failed to open subtitle editor:", e); - return false; - }); - }, - - openItemIdentifier: function (itemId) { - var serverId = this.getServerId(); - return this._loadEditorModules() - .then(function (loaded) { - var ed = - window.__moonfin_editors && window.__moonfin_editors.identifier; - if (!ed) return false; - ed.show(itemId, serverId); - return true; - }) - .catch(function (e) { - console.warn("[Moonfin] Failed to open item identifier:", e); - return false; - }); - }, }; diff --git a/frontend/src/utils/storage.js b/frontend/src/utils/storage.js index 54bc2c1..09865cd 100644 --- a/frontend/src/utils/storage.js +++ b/frontend/src/utils/storage.js @@ -1,812 +1,762 @@ const Storage = { - STORAGE_KEY: "moonfin_settings", - PROFILES_KEY: "moonfin_profiles", - SNAPSHOT_KEY: "moonfin_sync_snapshot", - SYNC_PREF_KEY: "moonfin_sync_enabled", - CLIENT_ID: "moonfin-web", - - syncState: { - serverAvailable: null, - lastSyncTime: null, - lastSyncError: null, - syncing: false, - mdblistAvailable: false, - tmdbAvailable: false, - adminDefaults: null, - }, - - defaults: { - navbarEnabled: false, - detailsPageEnabled: false, - - mediaBarEnabled: false, - mediaBarItemCount: 10, - mediaBarOverlayOpacity: 50, - mediaBarOverlayColor: "gray", - mediaBarAutoAdvance: true, - mediaBarIntervalMs: 7000, - mediaBarTrailerPreview: true, - mediaBarSourceType: "library", - mediaBarCollectionIds: [], - mediaBarShuffleItems: true, - mediaBarLibraryIds: [], - - showShuffleButton: true, - showGenresButton: true, - showFavoritesButton: true, - showCastButton: true, - showSyncPlayButton: true, - showLibrariesInToolbar: true, - shuffleContentType: "both", - - seasonalSurprise: "none", - backdropEnabled: true, - confirmExit: true, - - navbarPosition: "top", - showClock: true, - use24HourClock: false, - - mdblistEnabled: false, - mdblistApiKey: "", - mdblistRatingSources: ["imdb", "tmdb", "tomatoes", "metacritic"], - mdblistShowRatingNames: true, - - tmdbApiKey: "", - tmdbEpisodeRatingsEnabled: false, - }, - - colorOptions: { - gray: { name: "Gray", hex: "#808080" }, - black: { name: "Black", hex: "#000000" }, - dark_blue: { name: "Dark Blue", hex: "#1A2332" }, - purple: { name: "Purple", hex: "#4A148C" }, - teal: { name: "Teal", hex: "#00695C" }, - navy: { name: "Navy", hex: "#0D1B2A" }, - charcoal: { name: "Charcoal", hex: "#36454F" }, - brown: { name: "Brown", hex: "#3E2723" }, - dark_red: { name: "Dark Red", hex: "#8B0000" }, - dark_green: { name: "Dark Green", hex: "#0B4F0F" }, - slate: { name: "Slate", hex: "#475569" }, - indigo: { name: "Indigo", hex: "#1E3A8A" }, - }, - - seasonalOptions: { - none: { name: "None" }, - winter: { name: "Winter" }, - spring: { name: "Spring" }, - summer: { name: "Summer" }, - fall: { name: "Fall" }, - halloween: { name: "Halloween" }, - }, - - // ─── Profile Storage ──────────────────────────────────────────── - - getProfiles() { - try { - const stored = localStorage.getItem(this.PROFILES_KEY); - if (stored) { - return JSON.parse(stored); - } - } catch (e) { - console.error("[Moonfin] Failed to read profiles:", e); - } - return {}; - }, - - getProfile(profileName) { - const profiles = this.getProfiles(); - return profiles[profileName] || {}; - }, - - saveProfile(profileName, settings, syncToServer = true) { - try { - const profiles = this.getProfiles(); - profiles[profileName] = settings; - localStorage.setItem(this.PROFILES_KEY, JSON.stringify(profiles)); - - // Dispatch change event with resolved settings for current device - const resolved = this.getAll(); - window.dispatchEvent( - new CustomEvent("moonfin-settings-changed", { detail: resolved }), - ); - - if ( - syncToServer && - this.syncState.serverAvailable && - this.isSyncEnabled() - ) { - this.saveProfileToServer(profileName, settings); - } - } catch (e) { - console.error("[Moonfin] Failed to save profile:", e); - } - }, - - deleteProfile(profileName) { - if (profileName === "global") return; - const profiles = this.getProfiles(); - delete profiles[profileName]; - localStorage.setItem(this.PROFILES_KEY, JSON.stringify(profiles)); - - if (this.syncState.serverAvailable && this.isSyncEnabled()) { - this.deleteProfileFromServer(profileName); - } - }, - - // ─── Resolution Chain ─────────────────────────────────────────── - - /** - * Gets resolved flat settings for the current device. - * Resolution: device profile → global → admin defaults → built-in defaults. - */ - getAll(profileOverride) { - const deviceProfile = profileOverride || Device.getProfileName(); - return this.resolveSettings(deviceProfile); - }, - - resolveSettings(profileName) { - const profiles = this.getProfiles(); - const global = profiles.global || {}; - const device = profileName !== "global" ? profiles[profileName] || {} : {}; - const adminDefaults = this.syncState.adminDefaults || {}; - - const resolved = {}; - const allKeys = Object.keys(this.defaults); - - for (const key of allKeys) { - // Resolution chain: device → global → admin defaults → built-in - if (device[key] !== undefined && device[key] !== null) { - resolved[key] = device[key]; - } else if (global[key] !== undefined && global[key] !== null) { - resolved[key] = global[key]; - } else if ( - adminDefaults[key] !== undefined && - adminDefaults[key] !== null - ) { - resolved[key] = adminDefaults[key]; - } else { - resolved[key] = this.defaults[key]; - } - } - - return resolved; - }, - - get(key, defaultValue = null) { - const settings = this.getAll(); - return key in settings - ? settings[key] - : defaultValue !== null - ? defaultValue - : this.defaults[key]; - }, - - set(key, value, profileName) { - profileName = profileName || this._activeEditProfile || "global"; - const profile = this.getProfile(profileName); - profile[key] = value; - this.saveProfile(profileName, profile); - }, - - saveAll(settings, syncToServer = true) { - this.saveProfile("global", settings, syncToServer); - }, - - reset(profileName) { - if (profileName && profileName !== "global") { - this.deleteProfile(profileName); - } else { - // Reset all profiles - localStorage.removeItem(this.PROFILES_KEY); - localStorage.removeItem(this.SNAPSHOT_KEY); - if (this.syncState.serverAvailable && this.isSyncEnabled()) { - this.saveAllProfilesToServer({}); - } - } - }, - - // ─── Active Edit Profile ──────────────────────────────────────── + STORAGE_KEY: 'moonfin_settings', + PROFILES_KEY: 'moonfin_profiles', + SNAPSHOT_KEY: 'moonfin_sync_snapshot', + SYNC_PREF_KEY: 'moonfin_sync_enabled', + CLIENT_ID: 'moonfin-web', + + syncState: { + serverAvailable: null, + lastSyncTime: null, + lastSyncError: null, + syncing: false, + mdblistAvailable: false, + tmdbAvailable: false, + adminDefaults: null + }, + + defaults: { + navbarEnabled: false, + detailsPageEnabled: false, + + mediaBarEnabled: false, + mediaBarItemCount: 10, + mediaBarOverlayOpacity: 50, + mediaBarOverlayColor: 'gray', + mediaBarAutoAdvance: true, + mediaBarIntervalMs: 7000, + mediaBarTrailerPreview: true, + mediaBarSourceType: 'library', + mediaBarCollectionIds: [], + mediaBarShuffleItems: true, + mediaBarLibraryIds: [], + + showShuffleButton: true, + showGenresButton: true, + showFavoritesButton: true, + showCastButton: true, + showSyncPlayButton: true, + showLibrariesInToolbar: true, + shuffleContentType: 'both', + + seasonalSurprise: 'none', + backdropEnabled: true, + confirmExit: true, + + navbarPosition: 'top', + showClock: true, + use24HourClock: false, + + mdblistEnabled: false, + mdblistApiKey: '', + mdblistRatingSources: ['imdb', 'tmdb', 'tomatoes', 'metacritic'], + mdblistShowRatingNames: true, + + tmdbApiKey: '', + tmdbEpisodeRatingsEnabled: false + }, + + colorOptions: { + gray: { name: 'Gray', hex: '#808080' }, + black: { name: 'Black', hex: '#000000' }, + dark_blue: { name: 'Dark Blue', hex: '#1A2332' }, + purple: { name: 'Purple', hex: '#4A148C' }, + teal: { name: 'Teal', hex: '#00695C' }, + navy: { name: 'Navy', hex: '#0D1B2A' }, + charcoal: { name: 'Charcoal', hex: '#36454F' }, + brown: { name: 'Brown', hex: '#3E2723' }, + dark_red: { name: 'Dark Red', hex: '#8B0000' }, + dark_green: { name: 'Dark Green', hex: '#0B4F0F' }, + slate: { name: 'Slate', hex: '#475569' }, + indigo: { name: 'Indigo', hex: '#1E3A8A' } + }, + + seasonalOptions: { + none: { name: 'None' }, + winter: { name: 'Winter' }, + spring: { name: 'Spring' }, + summer: { name: 'Summer' }, + fall: { name: 'Fall' }, + halloween: { name: 'Halloween' } + }, + + // ─── Profile Storage ──────────────────────────────────────────── + + getProfiles() { + try { + const stored = localStorage.getItem(this.PROFILES_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch (e) { + console.error('[Moonfin] Failed to read profiles:', e); + } + return {}; + }, + + getProfile(profileName) { + const profiles = this.getProfiles(); + return profiles[profileName] || {}; + }, + + saveProfile(profileName, settings, syncToServer = true) { + try { + const profiles = this.getProfiles(); + profiles[profileName] = settings; + localStorage.setItem(this.PROFILES_KEY, JSON.stringify(profiles)); + + // Dispatch change event with resolved settings for current device + const resolved = this.getAll(); + window.dispatchEvent(new CustomEvent('moonfin-settings-changed', { detail: resolved })); + + if (syncToServer && this.syncState.serverAvailable && this.isSyncEnabled()) { + this.saveProfileToServer(profileName, settings); + } + } catch (e) { + console.error('[Moonfin] Failed to save profile:', e); + } + }, - _activeEditProfile: "global", + deleteProfile(profileName) { + if (profileName === 'global') return; + const profiles = this.getProfiles(); + delete profiles[profileName]; + localStorage.setItem(this.PROFILES_KEY, JSON.stringify(profiles)); - setActiveEditProfile(profileName) { - this._activeEditProfile = profileName; - }, + if (this.syncState.serverAvailable && this.isSyncEnabled()) { + this.deleteProfileFromServer(profileName); + } + }, + + // ─── Resolution Chain ─────────────────────────────────────────── + + /** + * Gets resolved flat settings for the current device. + * Resolution: device profile → global → admin defaults → built-in defaults. + */ + getAll(profileOverride) { + const deviceProfile = profileOverride || Device.getProfileName(); + return this.resolveSettings(deviceProfile); + }, + + resolveSettings(profileName) { + const profiles = this.getProfiles(); + const global = profiles.global || {}; + const device = profileName !== 'global' ? profiles[profileName] || {} : {}; + const adminDefaults = this.syncState.adminDefaults || {}; + + const resolved = {}; + const allKeys = Object.keys(this.defaults); + + for (const key of allKeys) { + // Resolution chain: device → global → admin defaults → built-in + if (device[key] !== undefined && device[key] !== null) { + resolved[key] = device[key]; + } else if (global[key] !== undefined && global[key] !== null) { + resolved[key] = global[key]; + } else if (adminDefaults[key] !== undefined && adminDefaults[key] !== null) { + resolved[key] = adminDefaults[key]; + } else { + resolved[key] = this.defaults[key]; + } + } - getActiveEditProfile() { - return this._activeEditProfile; - }, + return resolved; + }, + + get(key, defaultValue = null) { + const settings = this.getAll(); + return key in settings ? settings[key] : defaultValue !== null ? defaultValue : this.defaults[key]; + }, + + set(key, value, profileName) { + profileName = profileName || this._activeEditProfile || 'global'; + const profile = this.getProfile(profileName); + profile[key] = value; + this.saveProfile(profileName, profile); + }, + + saveAll(settings, syncToServer = true) { + this.saveProfile('global', settings, syncToServer); + }, + + reset(profileName) { + if (profileName && profileName !== 'global') { + this.deleteProfile(profileName); + } else { + // Reset all profiles + localStorage.removeItem(this.PROFILES_KEY); + localStorage.removeItem(this.SNAPSHOT_KEY); + if (this.syncState.serverAvailable && this.isSyncEnabled()) { + this.saveAllProfilesToServer({}); + } + } + }, - // ─── Sync Preference ──────────────────────────────────────────── + // ─── Active Edit Profile ──────────────────────────────────────── - isSyncEnabled() { - try { - const val = localStorage.getItem(this.SYNC_PREF_KEY); - return val === null ? true : val === "true"; - } catch (e) { - return true; - } - }, + _activeEditProfile: 'global', - setSyncEnabled(enabled) { - localStorage.setItem(this.SYNC_PREF_KEY, String(enabled)); - }, + setActiveEditProfile(profileName) { + this._activeEditProfile = profileName; + }, - // ─── Backward Compatibility ───────────────────────────────────── + getActiveEditProfile() { + return this._activeEditProfile; + }, - _migrateFromLegacy() { - try { - const legacy = localStorage.getItem(this.STORAGE_KEY); - const profiles = localStorage.getItem(this.PROFILES_KEY); + // ─── Sync Preference ──────────────────────────────────────────── - if (legacy && !profiles) { - const legacySettings = JSON.parse(legacy); - console.log("[Moonfin] Migrating legacy settings to profile format"); - this.saveProfile("global", legacySettings, false); - // Keep the legacy key around for one session as backup - localStorage.setItem(this.STORAGE_KEY + "_backup", legacy); - localStorage.removeItem(this.STORAGE_KEY); - } - } catch (e) { - console.error("[Moonfin] Legacy migration failed:", e); - } - }, - - // ─── Color Helpers ────────────────────────────────────────────── - - getColorHex(colorKey) { - return this.colorOptions[colorKey]?.hex || this.colorOptions["gray"].hex; - }, - - getColorRgba(colorKey, opacity = 50) { - const hex = this.getColorHex(colorKey); - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `rgba(${r}, ${g}, ${b}, ${opacity / 100})`; - }, - - // ─── Server Communication ─────────────────────────────────────── - - async pingServer() { - try { - const serverUrl = window.ApiClient?.serverAddress?.() || ""; - const response = await fetch(`${serverUrl}/Moonfin/Ping`, { - method: "GET", - headers: { - "Content-Type": "application/json", - ...this.getAuthHeader(), - }, - }); - - if (response.ok) { - const data = API.toCamelCase(await response.json()); - this.syncState.serverAvailable = - data.installed && data.settingsSyncEnabled; - this.syncState.mdblistAvailable = data.mdblistAvailable || false; - this.syncState.tmdbAvailable = data.tmdbAvailable || false; - - // Store admin defaults for the resolution chain - if (data.defaultSettings) { - this.syncState.adminDefaults = this._mapProfileFromServer( - data.defaultSettings, - ); + isSyncEnabled() { + try { + const val = localStorage.getItem(this.SYNC_PREF_KEY); + return val === null ? true : val === 'true'; + } catch (e) { + return true; + } + }, + + setSyncEnabled(enabled) { + localStorage.setItem(this.SYNC_PREF_KEY, String(enabled)); + }, + + // ─── Backward Compatibility ───────────────────────────────────── + + _migrateFromLegacy() { + try { + const legacy = localStorage.getItem(this.STORAGE_KEY); + const profiles = localStorage.getItem(this.PROFILES_KEY); + + if (legacy && !profiles) { + const legacySettings = JSON.parse(legacy); + console.log('[Moonfin] Migrating legacy settings to profile format'); + this.saveProfile('global', legacySettings, false); + // Keep the legacy key around for one session as backup + localStorage.setItem(this.STORAGE_KEY + '_backup', legacy); + localStorage.removeItem(this.STORAGE_KEY); + } + } catch (e) { + console.error('[Moonfin] Legacy migration failed:', e); + } + }, + + // ─── Color Helpers ────────────────────────────────────────────── + + getColorHex(colorKey) { + return this.colorOptions[colorKey]?.hex || this.colorOptions['gray'].hex; + }, + + getColorRgba(colorKey, opacity = 50) { + const hex = this.getColorHex(colorKey); + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${opacity / 100})`; + }, + + // ─── Server Communication ─────────────────────────────────────── + + async pingServer() { + try { + const serverUrl = window.ApiClient?.serverAddress?.() || ''; + const response = await fetch(`${serverUrl}/Moonfin/Ping`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...this.getAuthHeader() + } + }); + + if (response.ok) { + const data = API.toCamelCase(await response.json()); + this.syncState.serverAvailable = data.installed && data.settingsSyncEnabled; + this.syncState.mdblistAvailable = data.mdblistAvailable || false; + this.syncState.tmdbAvailable = data.tmdbAvailable || false; + + // Store admin defaults for the resolution chain + if (data.defaultSettings) { + this.syncState.adminDefaults = this._mapProfileFromServer(data.defaultSettings); + } + + console.log('[Moonfin] Server plugin detected:', data); + return data; + } + } catch (e) { + console.log('[Moonfin] Server plugin not available:', e.message); } - console.log("[Moonfin] Server plugin detected:", data); - return data; - } - } catch (e) { - console.log("[Moonfin] Server plugin not available:", e.message); - } + this.syncState.serverAvailable = false; + return null; + }, - this.syncState.serverAvailable = false; - return null; - }, + getAuthHeader() { + const token = window.ApiClient?.accessToken?.(); + if (token) { + return { Authorization: `MediaBrowser Token="${token}"` }; + } + return {}; + }, - getAuthHeader() { - const token = window.ApiClient?.accessToken?.(); - if (token) { - return { Authorization: `MediaBrowser Token="${token}"` }; - } - return {}; - }, + async fetchFromServer() { + if (this.syncState.serverAvailable === false) { + return null; + } - async fetchFromServer() { - if (this.syncState.serverAvailable === false) { - return null; - } + try { + const serverUrl = window.ApiClient?.serverAddress?.() || ''; + const response = await fetch(`${serverUrl}/Moonfin/Settings`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...this.getAuthHeader() + } + }); + + if (response.ok) { + const serverData = API.toCamelCase(await response.json()); + console.log('[Moonfin] Fetched settings from server'); + return this._mapEnvelopeFromServer(serverData); + } else if (response.status === 404) { + console.log('[Moonfin] No settings found on server'); + return null; + } + } catch (e) { + console.error('[Moonfin] Failed to fetch from server:', e); + this.syncState.lastSyncError = e.message; + } - try { - const serverUrl = window.ApiClient?.serverAddress?.() || ""; - const response = await fetch(`${serverUrl}/Moonfin/Settings`, { - method: "GET", - headers: { - "Content-Type": "application/json", - ...this.getAuthHeader(), - }, - }); - - if (response.ok) { - const serverData = API.toCamelCase(await response.json()); - console.log("[Moonfin] Fetched settings from server"); - return this._mapEnvelopeFromServer(serverData); - } else if (response.status === 404) { - console.log("[Moonfin] No settings found on server"); return null; - } - } catch (e) { - console.error("[Moonfin] Failed to fetch from server:", e); - this.syncState.lastSyncError = e.message; - } + }, - return null; - }, + async saveAllProfilesToServer(profiles) { + if (this.syncState.serverAvailable === false || !this.isSyncEnabled()) { + return false; + } - async saveAllProfilesToServer(profiles) { - if (this.syncState.serverAvailable === false || !this.isSyncEnabled()) { - return false; - } + try { + this.syncState.syncing = true; + const serverUrl = window.ApiClient?.serverAddress?.() || ''; + + const envelope = this._mapEnvelopeToServer(profiles); + envelope.syncEnabled = this.isSyncEnabled(); + + const response = await fetch(`${serverUrl}/Moonfin/Settings`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.getAuthHeader() + }, + body: JSON.stringify({ + settings: envelope, + clientId: this.CLIENT_ID, + mergeMode: 'replace' + }) + }); + + if (response.ok) { + this.syncState.lastSyncTime = Date.now(); + this.syncState.lastSyncError = null; + console.log('[Moonfin] All profiles saved to server'); + return true; + } + } catch (e) { + console.error('[Moonfin] Failed to save to server:', e); + this.syncState.lastSyncError = e.message; + } finally { + this.syncState.syncing = false; + } - try { - this.syncState.syncing = true; - const serverUrl = window.ApiClient?.serverAddress?.() || ""; - - const envelope = this._mapEnvelopeToServer(profiles); - envelope.syncEnabled = this.isSyncEnabled(); - - const response = await fetch(`${serverUrl}/Moonfin/Settings`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this.getAuthHeader(), - }, - body: JSON.stringify({ - settings: envelope, - clientId: this.CLIENT_ID, - mergeMode: "replace", - }), - }); - - if (response.ok) { - this.syncState.lastSyncTime = Date.now(); - this.syncState.lastSyncError = null; - console.log("[Moonfin] All profiles saved to server"); - return true; - } - } catch (e) { - console.error("[Moonfin] Failed to save to server:", e); - this.syncState.lastSyncError = e.message; - } finally { - this.syncState.syncing = false; - } + return false; + }, - return false; - }, + async saveProfileToServer(profileName, profileSettings) { + if (this.syncState.serverAvailable === false || !this.isSyncEnabled()) { + return false; + } - async saveProfileToServer(profileName, profileSettings) { - if (this.syncState.serverAvailable === false || !this.isSyncEnabled()) { - return false; - } + try { + this.syncState.syncing = true; + const serverUrl = window.ApiClient?.serverAddress?.() || ''; + const serverProfile = this._mapProfileToServer(profileSettings); + + const response = await fetch(`${serverUrl}/Moonfin/Settings/Profile/${profileName}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.getAuthHeader() + }, + body: JSON.stringify({ + profile: serverProfile, + clientId: this.CLIENT_ID + }) + }); + + if (response.ok) { + this.syncState.lastSyncTime = Date.now(); + this.syncState.lastSyncError = null; + console.log('[Moonfin] Profile "' + profileName + '" saved to server'); + return true; + } + } catch (e) { + console.error('[Moonfin] Failed to save profile to server:', e); + this.syncState.lastSyncError = e.message; + } finally { + this.syncState.syncing = false; + } - try { - this.syncState.syncing = true; - const serverUrl = window.ApiClient?.serverAddress?.() || ""; - const serverProfile = this._mapProfileToServer(profileSettings); - - const response = await fetch( - `${serverUrl}/Moonfin/Settings/Profile/${profileName}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this.getAuthHeader(), - }, - body: JSON.stringify({ - profile: serverProfile, - clientId: this.CLIENT_ID, - }), - }, - ); - - if (response.ok) { - this.syncState.lastSyncTime = Date.now(); - this.syncState.lastSyncError = null; - console.log('[Moonfin] Profile "' + profileName + '" saved to server'); - return true; - } - } catch (e) { - console.error("[Moonfin] Failed to save profile to server:", e); - this.syncState.lastSyncError = e.message; - } finally { - this.syncState.syncing = false; - } + return false; + }, - return false; - }, + async deleteProfileFromServer(profileName) { + if (this.syncState.serverAvailable === false || !this.isSyncEnabled()) { + return false; + } - async deleteProfileFromServer(profileName) { - if (this.syncState.serverAvailable === false || !this.isSyncEnabled()) { - return false; - } + try { + const serverUrl = window.ApiClient?.serverAddress?.() || ''; + await fetch(`${serverUrl}/Moonfin/Settings/Profile/${profileName}`, { + method: 'DELETE', + headers: this.getAuthHeader() + }); + } catch (e) { + console.error('[Moonfin] Failed to delete profile from server:', e); + } - try { - const serverUrl = window.ApiClient?.serverAddress?.() || ""; - await fetch(`${serverUrl}/Moonfin/Settings/Profile/${profileName}`, { - method: "DELETE", - headers: this.getAuthHeader(), - }); - } catch (e) { - console.error("[Moonfin] Failed to delete profile from server:", e); - } + return false; + }, + + // ─── Server ↔ Local Mapping ───────────────────────────────────── + + _mapProfileFromServer(serverProfile) { + if (!serverProfile) return {}; + var mapping = { + navbarEnabled: 'navbarEnabled', + detailsPageEnabled: 'detailsPageEnabled', + mediaBarEnabled: 'mediaBarEnabled', + mediaBarItemCount: 'mediaBarItemCount', + mediaBarOverlayOpacity: 'mediaBarOpacity', + mediaBarOverlayColor: 'mediaBarOverlayColor', + mediaBarAutoAdvance: 'mediaBarAutoAdvance', + mediaBarIntervalMs: 'mediaBarIntervalMs', + mediaBarTrailerPreview: 'mediaBarTrailerPreview', + mediaBarSourceType: 'mediaBarSourceType', + mediaBarCollectionIds: 'mediaBarCollectionIds', + mediaBarShuffleItems: 'mediaBarShuffleItems', + mediaBarLibraryIds: 'mediaBarLibraryIds', + showShuffleButton: 'showShuffleButton', + showGenresButton: 'showGenresButton', + showFavoritesButton: 'showFavoritesButton', + showCastButton: 'showCastButton', + showSyncPlayButton: 'showSyncPlayButton', + showLibrariesInToolbar: 'showLibrariesInToolbar', + shuffleContentType: 'shuffleContentType', + seasonalSurprise: 'seasonalSurprise', + backdropEnabled: 'backdropEnabled', + confirmExit: 'confirmExit', + navbarPosition: 'navbarPosition', + showClock: 'showClock', + use24HourClock: 'use24HourClock', + mdblistEnabled: 'mdblistEnabled', + mdblistApiKey: 'mdblistApiKey', + mdblistRatingSources: 'mdblistRatingSources', + mdblistShowRatingNames: 'mdblistShowRatingNames', + tmdbApiKey: 'tmdbApiKey', + tmdbEpisodeRatingsEnabled: 'tmdbEpisodeRatingsEnabled' + }; + // Only include properties that have actual values — prevents undefined/null + // from polluting merge operations and overwriting valid false values + var result = {}; + for (var localKey in mapping) { + var serverKey = mapping[localKey]; + var val = serverProfile[serverKey]; + if (val !== undefined && val !== null) { + result[localKey] = val; + } + } + return result; + }, + + _mapProfileToServer(localProfile) { + if (!localProfile) return {}; + return { + navbarEnabled: localProfile.navbarEnabled, + detailsPageEnabled: localProfile.detailsPageEnabled, + mediaBarEnabled: localProfile.mediaBarEnabled, + mediaBarItemCount: localProfile.mediaBarItemCount, + mediaBarOpacity: localProfile.mediaBarOverlayOpacity, + mediaBarOverlayColor: localProfile.mediaBarOverlayColor, + mediaBarAutoAdvance: localProfile.mediaBarAutoAdvance, + mediaBarIntervalMs: localProfile.mediaBarIntervalMs, + mediaBarTrailerPreview: localProfile.mediaBarTrailerPreview, + mediaBarSourceType: localProfile.mediaBarSourceType, + mediaBarCollectionIds: localProfile.mediaBarCollectionIds, + mediaBarShuffleItems: localProfile.mediaBarShuffleItems, + mediaBarLibraryIds: localProfile.mediaBarLibraryIds, + showShuffleButton: localProfile.showShuffleButton, + showGenresButton: localProfile.showGenresButton, + showFavoritesButton: localProfile.showFavoritesButton, + showCastButton: localProfile.showCastButton, + showSyncPlayButton: localProfile.showSyncPlayButton, + showLibrariesInToolbar: localProfile.showLibrariesInToolbar, + shuffleContentType: localProfile.shuffleContentType, + seasonalSurprise: localProfile.seasonalSurprise, + backdropEnabled: localProfile.backdropEnabled, + confirmExit: localProfile.confirmExit, + navbarPosition: localProfile.navbarPosition, + showClock: localProfile.showClock, + use24HourClock: localProfile.use24HourClock, + mdblistEnabled: localProfile.mdblistEnabled, + mdblistApiKey: localProfile.mdblistApiKey, + mdblistRatingSources: localProfile.mdblistRatingSources, + mdblistShowRatingNames: localProfile.mdblistShowRatingNames, + tmdbApiKey: localProfile.tmdbApiKey, + tmdbEpisodeRatingsEnabled: localProfile.tmdbEpisodeRatingsEnabled + }; + }, + + /** + * Maps server envelope (v2) to local profiles object. + * Also handles v1 legacy format from the server. + */ + _mapEnvelopeFromServer(serverData) { + // v2 profiled format + if (serverData.global || serverData.desktop || serverData.mobile || serverData.tv) { + const profiles = {}; + if (serverData.global) profiles.global = this._mapProfileFromServer(serverData.global); + if (serverData.desktop) profiles.desktop = this._mapProfileFromServer(serverData.desktop); + if (serverData.mobile) profiles.mobile = this._mapProfileFromServer(serverData.mobile); + if (serverData.tv) profiles.tv = this._mapProfileFromServer(serverData.tv); + return { + profiles: profiles, + syncEnabled: serverData.syncEnabled !== false + }; + } - return false; - }, - - // ─── Server ↔ Local Mapping ───────────────────────────────────── - - _mapProfileFromServer(serverProfile) { - if (!serverProfile) return {}; - var mapping = { - navbarEnabled: "navbarEnabled", - detailsPageEnabled: "detailsPageEnabled", - mediaBarEnabled: "mediaBarEnabled", - mediaBarItemCount: "mediaBarItemCount", - mediaBarOverlayOpacity: "mediaBarOpacity", - mediaBarOverlayColor: "mediaBarOverlayColor", - mediaBarAutoAdvance: "mediaBarAutoAdvance", - mediaBarIntervalMs: "mediaBarIntervalMs", - mediaBarTrailerPreview: "mediaBarTrailerPreview", - mediaBarSourceType: "mediaBarSourceType", - mediaBarCollectionIds: "mediaBarCollectionIds", - mediaBarShuffleItems: "mediaBarShuffleItems", - mediaBarLibraryIds: "mediaBarLibraryIds", - showShuffleButton: "showShuffleButton", - showGenresButton: "showGenresButton", - showFavoritesButton: "showFavoritesButton", - showCastButton: "showCastButton", - showSyncPlayButton: "showSyncPlayButton", - showLibrariesInToolbar: "showLibrariesInToolbar", - shuffleContentType: "shuffleContentType", - seasonalSurprise: "seasonalSurprise", - backdropEnabled: "backdropEnabled", - confirmExit: "confirmExit", - navbarPosition: "navbarPosition", - showClock: "showClock", - use24HourClock: "use24HourClock", - mdblistEnabled: "mdblistEnabled", - mdblistApiKey: "mdblistApiKey", - mdblistRatingSources: "mdblistRatingSources", - mdblistShowRatingNames: "mdblistShowRatingNames", - tmdbApiKey: "tmdbApiKey", - tmdbEpisodeRatingsEnabled: "tmdbEpisodeRatingsEnabled", - }; - // Only include properties that have actual values — prevents undefined/null - // from polluting merge operations and overwriting valid false values - var result = {}; - for (var localKey in mapping) { - var serverKey = mapping[localKey]; - var val = serverProfile[serverKey]; - if (val !== undefined && val !== null) { - result[localKey] = val; - } - } - return result; - }, - - _mapProfileToServer(localProfile) { - if (!localProfile) return {}; - return { - navbarEnabled: localProfile.navbarEnabled, - detailsPageEnabled: localProfile.detailsPageEnabled, - mediaBarEnabled: localProfile.mediaBarEnabled, - mediaBarItemCount: localProfile.mediaBarItemCount, - mediaBarOpacity: localProfile.mediaBarOverlayOpacity, - mediaBarOverlayColor: localProfile.mediaBarOverlayColor, - mediaBarAutoAdvance: localProfile.mediaBarAutoAdvance, - mediaBarIntervalMs: localProfile.mediaBarIntervalMs, - mediaBarTrailerPreview: localProfile.mediaBarTrailerPreview, - mediaBarSourceType: localProfile.mediaBarSourceType, - mediaBarCollectionIds: localProfile.mediaBarCollectionIds, - mediaBarShuffleItems: localProfile.mediaBarShuffleItems, - mediaBarLibraryIds: localProfile.mediaBarLibraryIds, - showShuffleButton: localProfile.showShuffleButton, - showGenresButton: localProfile.showGenresButton, - showFavoritesButton: localProfile.showFavoritesButton, - showCastButton: localProfile.showCastButton, - showSyncPlayButton: localProfile.showSyncPlayButton, - showLibrariesInToolbar: localProfile.showLibrariesInToolbar, - shuffleContentType: localProfile.shuffleContentType, - seasonalSurprise: localProfile.seasonalSurprise, - backdropEnabled: localProfile.backdropEnabled, - confirmExit: localProfile.confirmExit, - navbarPosition: localProfile.navbarPosition, - showClock: localProfile.showClock, - use24HourClock: localProfile.use24HourClock, - mdblistEnabled: localProfile.mdblistEnabled, - mdblistApiKey: localProfile.mdblistApiKey, - mdblistRatingSources: localProfile.mdblistRatingSources, - mdblistShowRatingNames: localProfile.mdblistShowRatingNames, - tmdbApiKey: localProfile.tmdbApiKey, - tmdbEpisodeRatingsEnabled: localProfile.tmdbEpisodeRatingsEnabled, - }; - }, - - /** - * Maps server envelope (v2) to local profiles object. - * Also handles v1 legacy format from the server. - */ - _mapEnvelopeFromServer(serverData) { - // v2 profiled format - if ( - serverData.global || - serverData.desktop || - serverData.mobile || - serverData.tv - ) { - const profiles = {}; - if (serverData.global) - profiles.global = this._mapProfileFromServer(serverData.global); - if (serverData.desktop) - profiles.desktop = this._mapProfileFromServer(serverData.desktop); - if (serverData.mobile) - profiles.mobile = this._mapProfileFromServer(serverData.mobile); - if (serverData.tv) - profiles.tv = this._mapProfileFromServer(serverData.tv); - return { - profiles: profiles, - syncEnabled: serverData.syncEnabled !== false, - }; - } + // v1 legacy flat format — treat as global + const mapped = this._mapProfileFromServer(serverData); + return { + profiles: { global: mapped }, + syncEnabled: true + }; + }, + + _mapEnvelopeToServer(profiles) { + const envelope = { schemaVersion: 2 }; + if (profiles.global) envelope.global = this._mapProfileToServer(profiles.global); + if (profiles.desktop) envelope.desktop = this._mapProfileToServer(profiles.desktop); + if (profiles.mobile) envelope.mobile = this._mapProfileToServer(profiles.mobile); + if (profiles.tv) envelope.tv = this._mapProfileToServer(profiles.tv); + return envelope; + }, + + // ─── Sync Snapshots ───────────────────────────────────────────── + + getSnapshot() { + try { + const stored = localStorage.getItem(this.SNAPSHOT_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch (e) { + console.error('[Moonfin] Failed to read sync snapshot:', e); + } + return null; + }, - // v1 legacy flat format — treat as global - const mapped = this._mapProfileFromServer(serverData); - return { - profiles: { global: mapped }, - syncEnabled: true, - }; - }, - - _mapEnvelopeToServer(profiles) { - const envelope = { schemaVersion: 2 }; - if (profiles.global) - envelope.global = this._mapProfileToServer(profiles.global); - if (profiles.desktop) - envelope.desktop = this._mapProfileToServer(profiles.desktop); - if (profiles.mobile) - envelope.mobile = this._mapProfileToServer(profiles.mobile); - if (profiles.tv) envelope.tv = this._mapProfileToServer(profiles.tv); - return envelope; - }, - - // ─── Sync Snapshots ───────────────────────────────────────────── - - getSnapshot() { - try { - const stored = localStorage.getItem(this.SNAPSHOT_KEY); - if (stored) { - return JSON.parse(stored); - } - } catch (e) { - console.error("[Moonfin] Failed to read sync snapshot:", e); - } - return null; - }, - - saveSnapshot(profiles) { - try { - localStorage.setItem(this.SNAPSHOT_KEY, JSON.stringify(profiles)); - } catch (e) { - console.error("[Moonfin] Failed to save sync snapshot:", e); - } - }, - - // ─── Three-Way Merge ──────────────────────────────────────────── - - threeWayMergeProfiles(localProfiles, serverProfiles, snapshotProfiles) { - const merged = {}; - const allProfileNames = new Set([ - ...Object.keys(localProfiles || {}), - ...Object.keys(serverProfiles || {}), - ...Object.keys(snapshotProfiles || {}), - ]); - - for (const name of allProfileNames) { - merged[name] = this._threeWayMergeFlat( - localProfiles[name] || {}, - serverProfiles[name] || {}, - snapshotProfiles[name] || {}, - ); - } + saveSnapshot(profiles) { + try { + localStorage.setItem(this.SNAPSHOT_KEY, JSON.stringify(profiles)); + } catch (e) { + console.error('[Moonfin] Failed to save sync snapshot:', e); + } + }, + + // ─── Three-Way Merge ──────────────────────────────────────────── + + threeWayMergeProfiles(localProfiles, serverProfiles, snapshotProfiles) { + const merged = {}; + const allProfileNames = new Set([ + ...Object.keys(localProfiles || {}), + ...Object.keys(serverProfiles || {}), + ...Object.keys(snapshotProfiles || {}) + ]); + + for (const name of allProfileNames) { + merged[name] = this._threeWayMergeFlat( + localProfiles[name] || {}, + serverProfiles[name] || {}, + snapshotProfiles[name] || {} + ); + } - return merged; - }, - - _threeWayMergeFlat(local, server, snapshot) { - const merged = {}; - const allKeys = new Set([ - ...Object.keys(local), - ...Object.keys(server), - ...Object.keys(this.defaults), - ]); - - for (const key of allKeys) { - const localVal = local[key]; - const serverVal = server[key]; - const snapVal = snapshot[key]; - - const localChanged = !this._deepEqual(localVal, snapVal); - const serverChanged = !this._deepEqual(serverVal, snapVal); - - if (localChanged && !serverChanged) { - if (localVal !== undefined) merged[key] = localVal; - } else if (serverChanged && !localChanged) { - if (serverVal !== undefined) merged[key] = serverVal; - } else if (localChanged && serverChanged) { - if (localVal !== undefined) merged[key] = localVal; - console.log('[Moonfin] Merge conflict on "' + key + '" — local wins'); - } else { - if (localVal !== undefined) merged[key] = localVal; - } - } + return merged; + }, + + _threeWayMergeFlat(local, server, snapshot) { + const merged = {}; + const allKeys = new Set([...Object.keys(local), ...Object.keys(server), ...Object.keys(this.defaults)]); + + for (const key of allKeys) { + const localVal = local[key]; + const serverVal = server[key]; + const snapVal = snapshot[key]; + + const localChanged = !this._deepEqual(localVal, snapVal); + const serverChanged = !this._deepEqual(serverVal, snapVal); + + if (localChanged && !serverChanged) { + if (localVal !== undefined) merged[key] = localVal; + } else if (serverChanged && !localChanged) { + if (serverVal !== undefined) merged[key] = serverVal; + } else if (localChanged && serverChanged) { + if (localVal !== undefined) merged[key] = localVal; + console.log('[Moonfin] Merge conflict on "' + key + '" — local wins'); + } else { + if (localVal !== undefined) merged[key] = localVal; + } + } - return merged; - }, - - _deepEqual(a, b) { - if (a === b) return true; - if (a == null || b == null) return a == b; - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (!this._deepEqual(a[i], b[i])) return false; - } - return true; - } - if (typeof a === "object" && typeof b === "object") { - const ka = Object.keys(a), - kb = Object.keys(b); - if (ka.length !== kb.length) return false; - for (const k of ka) { - if (!this._deepEqual(a[k], b[k])) return false; - } - return true; - } - return false; - }, + return merged; + }, + + _deepEqual(a, b) { + if (a === b) return true; + if (a == null || b == null) return a == b; + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!this._deepEqual(a[i], b[i])) return false; + } + return true; + } + if (typeof a === 'object' && typeof b === 'object') { + const ka = Object.keys(a), + kb = Object.keys(b); + if (ka.length !== kb.length) return false; + for (const k of ka) { + if (!this._deepEqual(a[k], b[k])) return false; + } + return true; + } + return false; + }, - // ─── Full Sync ────────────────────────────────────────────────── + // ─── Full Sync ────────────────────────────────────────────────── - async sync(forceFromServer = false) { - console.log( - "[Moonfin] Starting settings sync..." + - (forceFromServer ? " (server wins)" : ""), - ); + async sync(forceFromServer = false) { + console.log('[Moonfin] Starting settings sync...' + (forceFromServer ? ' (server wins)' : '')); - const pingResult = await this.pingServer(); - if (!pingResult?.installed || !pingResult?.settingsSyncEnabled) { - console.log("[Moonfin] Server sync not available"); - return; - } + const pingResult = await this.pingServer(); + if (!pingResult?.installed || !pingResult?.settingsSyncEnabled) { + console.log('[Moonfin] Server sync not available'); + return; + } - if (!this.isSyncEnabled()) { - console.log("[Moonfin] User has disabled sync"); - return; - } + if (!this.isSyncEnabled()) { + console.log('[Moonfin] User has disabled sync'); + return; + } - const localProfiles = this.getProfiles(); - const hasLocalProfiles = Object.keys(localProfiles).length > 0; - const serverResult = await this.fetchFromServer(); - const serverProfiles = serverResult?.profiles || null; - const snapshot = this.getSnapshot(); - - let merged; - - if (forceFromServer && serverProfiles) { - // Server wins — overwrite local with server profiles - merged = {}; - const allNames = new Set([ - ...Object.keys(localProfiles), - ...Object.keys(serverProfiles), - ]); - for (const name of allNames) { - merged[name] = { - ...(localProfiles[name] || {}), - ...(serverProfiles[name] || {}), - }; - } - console.log("[Moonfin] Applied server profiles (manual sync)"); - } else if (serverProfiles && hasLocalProfiles && snapshot) { - merged = this.threeWayMergeProfiles( - localProfiles, - serverProfiles, - snapshot, - ); - console.log("[Moonfin] Three-way merged profiles"); - } else if (serverProfiles && hasLocalProfiles && !snapshot) { - // First sync — local wins for conflicts - merged = {}; - const allNames = new Set([ - ...Object.keys(serverProfiles), - ...Object.keys(localProfiles), - ]); - for (const name of allNames) { - merged[name] = { - ...(serverProfiles[name] || {}), - ...(localProfiles[name] || {}), - }; - } - console.log("[Moonfin] First sync — local wins, pushed to server"); - } else if (serverProfiles && !hasLocalProfiles) { - merged = serverProfiles; - console.log("[Moonfin] Restored profiles from server (fresh install)"); - } else if (hasLocalProfiles) { - merged = localProfiles; - console.log("[Moonfin] Pushed local profiles to server"); - } else { - return; - } + const localProfiles = this.getProfiles(); + const hasLocalProfiles = Object.keys(localProfiles).length > 0; + const serverResult = await this.fetchFromServer(); + const serverProfiles = serverResult?.profiles || null; + const snapshot = this.getSnapshot(); + + let merged; + + if (forceFromServer && serverProfiles) { + // Server wins — overwrite local with server profiles + merged = {}; + const allNames = new Set([...Object.keys(localProfiles), ...Object.keys(serverProfiles)]); + for (const name of allNames) { + merged[name] = { + ...(localProfiles[name] || {}), + ...(serverProfiles[name] || {}) + }; + } + console.log('[Moonfin] Applied server profiles (manual sync)'); + } else if (serverProfiles && hasLocalProfiles && snapshot) { + merged = this.threeWayMergeProfiles(localProfiles, serverProfiles, snapshot); + console.log('[Moonfin] Three-way merged profiles'); + } else if (serverProfiles && hasLocalProfiles && !snapshot) { + // First sync — local wins for conflicts + merged = {}; + const allNames = new Set([...Object.keys(serverProfiles), ...Object.keys(localProfiles)]); + for (const name of allNames) { + merged[name] = { + ...(serverProfiles[name] || {}), + ...(localProfiles[name] || {}) + }; + } + console.log('[Moonfin] First sync — local wins, pushed to server'); + } else if (serverProfiles && !hasLocalProfiles) { + merged = serverProfiles; + console.log('[Moonfin] Restored profiles from server (fresh install)'); + } else if (hasLocalProfiles) { + merged = localProfiles; + console.log('[Moonfin] Pushed local profiles to server'); + } else { + return; + } - // Save locally without triggering sync - try { - localStorage.setItem(this.PROFILES_KEY, JSON.stringify(merged)); - window.dispatchEvent( - new CustomEvent("moonfin-settings-changed", { detail: this.getAll() }), - ); - } catch (e) { - console.error("[Moonfin] Failed to save merged profiles:", e); - } + // Save locally without triggering sync + try { + localStorage.setItem(this.PROFILES_KEY, JSON.stringify(merged)); + window.dispatchEvent(new CustomEvent('moonfin-settings-changed', { detail: this.getAll() })); + } catch (e) { + console.error('[Moonfin] Failed to save merged profiles:', e); + } - // Update sync preference from server if available - if (serverResult && serverResult.syncEnabled !== undefined) { - this.setSyncEnabled(serverResult.syncEnabled); - } + // Update sync preference from server if available + if (serverResult && serverResult.syncEnabled !== undefined) { + this.setSyncEnabled(serverResult.syncEnabled); + } - await this.saveAllProfilesToServer(merged); - this.saveSnapshot(merged); - }, + await this.saveAllProfilesToServer(merged); + this.saveSnapshot(merged); + }, - initSync() { - if (this._initialSyncDone) return; - this._initialSyncDone = true; + initSync() { + if (this._initialSyncDone) return; + this._initialSyncDone = true; - // Migrate legacy flat settings - this._migrateFromLegacy(); + // Migrate legacy flat settings + this._migrateFromLegacy(); - if (window.ApiClient?.isLoggedIn?.()) { - setTimeout(() => this.sync(), 2000); - } else { - const onLogin = () => { if (window.ApiClient?.isLoggedIn?.()) { - document.removeEventListener("viewshow", onLogin); - setTimeout(() => this.sync(), 2000); + setTimeout(() => this.sync(), 2000); + } else { + const onLogin = () => { + if (window.ApiClient?.isLoggedIn?.()) { + document.removeEventListener('viewshow', onLogin); + setTimeout(() => this.sync(), 2000); + } + }; + document.addEventListener('viewshow', onLogin); } - }; - document.addEventListener("viewshow", onLogin); + }, + + getSyncStatus() { + return { + available: this.syncState.serverAvailable, + lastSync: this.syncState.lastSyncTime, + error: this.syncState.lastSyncError, + syncing: this.syncState.syncing + }; + }, + + resetForNewUser() { + localStorage.removeItem(this.PROFILES_KEY); + localStorage.removeItem(this.STORAGE_KEY); + localStorage.removeItem(this.SNAPSHOT_KEY); + this._initialSyncDone = false; + this.syncState.serverAvailable = null; + this.syncState.lastSyncTime = null; + this.syncState.lastSyncError = null; + this.syncState.syncing = false; + this.syncState.mdblistAvailable = false; + this.syncState.tmdbAvailable = false; + this.syncState.adminDefaults = null; } - }, - - getSyncStatus() { - return { - available: this.syncState.serverAvailable, - lastSync: this.syncState.lastSyncTime, - error: this.syncState.lastSyncError, - syncing: this.syncState.syncing, - }; - }, - - resetForNewUser() { - localStorage.removeItem(this.PROFILES_KEY); - localStorage.removeItem(this.STORAGE_KEY); - localStorage.removeItem(this.SNAPSHOT_KEY); - this._initialSyncDone = false; - this.syncState.serverAvailable = null; - this.syncState.lastSyncTime = null; - this.syncState.lastSyncError = null; - this.syncState.syncing = false; - this.syncState.mdblistAvailable = false; - this.syncState.tmdbAvailable = false; - this.syncState.adminDefaults = null; - }, }; From 9ad1b61fed97fc3c1ba4c12e829c0e3a3427d84a Mon Sep 17 00:00:00 2001 From: enyineer Date: Sun, 15 Mar 2026 01:17:47 +0100 Subject: [PATCH 06/11] feat: add media bar source selection (library/collection), pickers, and shuffle - Replace mediaBarContentType with mediaBarSourceType (library/collection) - Add server-side MediaBar endpoint with client-side fallbacks - Add collection/library picker UI in settings panel - Add shuffle toggle for collection items - Add admin default settings for source type, collections, libraries - Remove prettier config files - Restore upstream formatting in settings.css --- .editorconfig | 22 - .prettierignore | 1 - .prettierrc | 10 - backend/Pages/configPage.html | 79 +- frontend/src/components/mediabar.js | 354 +++----- frontend/src/components/settings.js | 1299 ++++++++++----------------- frontend/src/styles/settings.css | 27 +- frontend/src/utils/api.js | 329 +++---- frontend/src/utils/storage.js | 70 +- 9 files changed, 854 insertions(+), 1337 deletions(-) delete mode 100644 .editorconfig delete mode 100644 .prettierignore delete mode 100644 .prettierrc diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 001286e..0000000 --- a/.editorconfig +++ /dev/null @@ -1,22 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.cs] -indent_style = space -indent_size = 4 - -[*.{js,html,css}] -indent_style = space -indent_size = 4 - -[*.json] -indent_style = space -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 2d19fc7..0000000 --- a/.prettierignore +++ /dev/null @@ -1 +0,0 @@ -*.html diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 1817f29..0000000 --- a/.prettierrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "tabWidth": 4, - "useTabs": false, - "singleQuote": true, - "trailingComma": "none", - "printWidth": 120, - "semi": true, - "bracketSpacing": true, - "endOfLine": "lf" -} diff --git a/backend/Pages/configPage.html b/backend/Pages/configPage.html index 3d210a6..d068092 100644 --- a/backend/Pages/configPage.html +++ b/backend/Pages/configPage.html @@ -1,14 +1,11 @@ - Moonfin - -
+
@@ -37,8 +34,7 @@

Jellyseerr / Seerr Integration

Enable Jellyseerr / Seerr
- Enable Jellyseerr or Seerr integration for all users. Users can configure their own authentication - method in their client settings. + Enable Jellyseerr or Seerr integration for all users. Users can configure their own authentication method in their client settings.
@@ -58,10 +54,8 @@

Jellyseerr / Seerr Integration

- - + +
Optional: When set, the client-side iframe loads directly from this URL instead of through the proxy. Use this if you're running Seerr v3 (Next.js) behind a reverse proxy @@ -72,12 +66,10 @@

Jellyseerr / Seerr Integration

SSO API calls will still go through the Jellyfin server.
-
+
SSO Proxy: When Jellyseerr is enabled, Moonfin provides single sign-on. Users authenticate once with their Jellyfin credentials, and the session is stored server-side. - All Moonfin clients (web, Android, Roku, webOS, Tizen, etc) can access Jellyseerr through - /Moonfin/Jellyseerr/Api/ + All Moonfin clients (web, Android, Roku, webOS, Tizen, etc) can access Jellyseerr through /Moonfin/Jellyseerr/Api/ without re-authenticating.
@@ -99,8 +91,7 @@

MDBList Integration

TMDB Integration

- +
Provide a server-wide TMDB API key that all users can use for episode ratings. @@ -117,11 +108,9 @@

Default User Settings

a setting will see these values. Users can always override defaults in their own Moonfin settings panel.
-
+
How it works: These are the lowest-priority fallback. Resolution order: - User's device profile → User's global settings → These admin defaults → - Built-in defaults. + User's device profile → User's global settings → These admin defaults → Built-in defaults.

UI Features

@@ -148,8 +137,7 @@

UI Features<
Enable the featured media carousel on the home page by default.

- + MDBList Ratings -
Enable MDBList ratings by default (requires server-wide or user API key). -
+
Enable MDBList ratings by default (requires server-wide or user API key).
-
Enable TMDB episode ratings by default (requires server-wide or user API - key).
+
Enable TMDB episode ratings by default (requires server-wide or user API key).
@@ -261,17 +240,14 @@

Integrations

Web Plugin Status

Moonfin uses the - File - Transformation + File Transformation plugin to inject its web UI.

Setup:

    -
  1. Install the File Transformation plugin (add - https://www.iamparadox.dev/jellyfin/plugins/manifest.json to your plugin repositories) -
  2. +
  3. Install the File Transformation plugin (add https://www.iamparadox.dev/jellyfin/plugins/manifest.json to your plugin repositories)
  4. Restart Jellyfin
  5. Force refresh your browser (Ctrl+Shift+R)
@@ -296,11 +272,11 @@

Web Plugin Status

pluginUniqueId: '8c5d0e91-4f2a-4b6d-9e3f-1a7c8d9e0f2b' }; - document.addEventListener('pageshow', function (e) { + document.addEventListener('pageshow', function(e) { if (e.target.id !== 'MoonfinConfigPage') return; Dashboard.showLoadingMsg(); - ApiClient.getPluginConfiguration(MoonfinConfig.pluginUniqueId).then(function (config) { + ApiClient.getPluginConfiguration(MoonfinConfig.pluginUniqueId).then(function(config) { document.querySelector('#EnableSettingsSync').checked = config.EnableSettingsSync; document.querySelector('#JellyseerrEnabled').checked = config.JellyseerrEnabled; document.querySelector('#JellyseerrUrl').value = config.JellyseerrUrl || ''; @@ -364,7 +340,7 @@

Web Plugin Status

fields: 'PrimaryImageAspectRatio', imageTypeLimit: 1, enableImageTypes: 'Primary' - }).then(function (result) { + }).then(function(result) { var items = result.Items || []; if (items.length === 0) { picker.innerHTML = '
No collections or playlists found.
'; @@ -396,8 +372,8 @@

Web Plugin Status

picker.innerHTML = '
Loading...
'; var userId = ApiClient.getCurrentUserId(); - ApiClient.getUserViews(userId).then(function (result) { - var items = (result.Items || []).filter(function (item) { + ApiClient.getUserViews(userId).then(function(result) { + var items = (result.Items || []).filter(function(item) { var ct = item.CollectionType; return ct === 'movies' || ct === 'tvshows' || ct === 'music' || ct === 'mixed'; }); @@ -422,10 +398,10 @@

Web Plugin Status

} document.querySelector('#MoonfinConfigForm') - .addEventListener('submit', function (ev) { + .addEventListener('submit', function(ev) { ev.preventDefault(); Dashboard.showLoadingMsg(); - ApiClient.getPluginConfiguration(MoonfinConfig.pluginUniqueId).then(function (config) { + ApiClient.getPluginConfiguration(MoonfinConfig.pluginUniqueId).then(function(config) { config.EnableSettingsSync = document.querySelector('#EnableSettingsSync').checked; config.JellyseerrEnabled = document.querySelector('#JellyseerrEnabled').checked; config.JellyseerrUrl = document.querySelector('#JellyseerrUrl').value || null; @@ -466,7 +442,7 @@

Web Plugin Status

config.DefaultUserSettings.mdblistEnabled = document.querySelector('#DefaultMdblistEnabled').checked; config.DefaultUserSettings.tmdbEpisodeRatingsEnabled = document.querySelector('#DefaultTmdbEpisodeRatingsEnabled').checked; - ApiClient.updatePluginConfiguration(MoonfinConfig.pluginUniqueId, config).then(function (result) { + ApiClient.updatePluginConfiguration(MoonfinConfig.pluginUniqueId, config).then(function(result) { Dashboard.processPluginConfigurationUpdateResult(result); }); }); @@ -476,5 +452,4 @@

Web Plugin Status

- - \ No newline at end of file + diff --git a/frontend/src/components/mediabar.js b/frontend/src/components/mediabar.js index 1767836..76ca0e3 100644 --- a/frontend/src/components/mediabar.js +++ b/frontend/src/components/mediabar.js @@ -47,34 +47,31 @@ var MediaBar = { _loadContentAsync(settings) { var self = this; - this.waitForApi() - .then(function () { - return self.loadContent(); - }) - .then(function () { - if (self.items.length > 0) { - self.container.classList.remove('loading'); - if (settings.mediaBarAutoAdvance) { - self.startAutoAdvance(); - } - } else { - document.body.classList.remove('moonfin-mediabar-active'); - self.container.classList.add('empty'); + this.waitForApi().then(function() { + return self.loadContent(); + }).then(function() { + if (self.items.length > 0) { + self.container.classList.remove('loading'); + if (settings.mediaBarAutoAdvance) { + self.startAutoAdvance(); } - }) - .catch(function (e) { - console.error('[Moonfin] MediaBar: Failed to load content -', e.message); + } else { document.body.classList.remove('moonfin-mediabar-active'); - if (self.container) self.container.classList.add('empty'); - }); + self.container.classList.add('empty'); + } + }).catch(function(e) { + console.error('[Moonfin] MediaBar: Failed to load content -', e.message); + document.body.classList.remove('moonfin-mediabar-active'); + if (self.container) self.container.classList.add('empty'); + }); }, waitForApi() { - return new Promise(function (resolve, reject) { + return new Promise(function(resolve, reject) { var attempts = 0; var maxAttempts = 50; - - var check = function () { + + var check = function() { var api = API.getApiClient(); if (api) { try { @@ -87,7 +84,7 @@ var MediaBar = { // Not authenticated yet } } - + if (attempts >= maxAttempts) { reject(new Error('API timeout')); } else { @@ -112,44 +109,36 @@ var MediaBar = { this.container.className = 'moonfin-mediabar'; this.container.innerHTML = '
' + - '
' + - '
' + + '
' + + '
' + '
' + '
' + '
' + '
' + - '
' + - '' + - '
' + - '
' + - '' + - '
' + - '
' + - '
' + + '
' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + '
' + '
' + - '' + - '' + + '' + + '' + '
' + - '
' + - '
' + + '
' + + '
' + '
'; document.body.appendChild(this.container); @@ -162,16 +151,15 @@ var MediaBar = { var serverItems = await API.getMediaBarItems(Device.getProfileName()); if (serverItems) { this.items = serverItems; - } else if ( - settings.mediaBarSourceType === 'collection' && + } else if (settings.mediaBarSourceType === 'collection' && settings.mediaBarCollectionIds && - settings.mediaBarCollectionIds.length > 0 - ) { + settings.mediaBarCollectionIds.length > 0) { // Fallback: client-side collection fetching - this.items = await API.getCollectionItems(settings.mediaBarCollectionIds, { - limit: settings.mediaBarItemCount, - shuffle: settings.mediaBarShuffleItems !== false - }); + this.items = await API.getCollectionItems( + settings.mediaBarCollectionIds, { + limit: settings.mediaBarItemCount, + shuffle: settings.mediaBarShuffleItems !== false + }); } else { // Fallback: client-side random items this.items = await API.getRandomItems({ @@ -200,7 +188,7 @@ var MediaBar = { var logoUrl = API.getImageUrl(item, 'Logo', { maxWidth: 500 }); var logoContainer = this.container.querySelector('.moonfin-mediabar-logo-container'); var logoImg = this.container.querySelector('.moonfin-mediabar-logo'); - + if (logoUrl) { logoImg.src = logoUrl; logoImg.alt = item.Name; @@ -252,7 +240,7 @@ var MediaBar = { if (MdbList.isEnabled()) { var currentIdx = this.currentIndex; - MdbList.fetchRatings(item).then(function (mdbRatings) { + MdbList.fetchRatings(item).then(function(mdbRatings) { if (MediaBar.currentIndex !== currentIdx) return; if (mdbRatings && mdbRatings.length > 0) { var mdbHtml = MdbList.buildRatingsHtml(mdbRatings, 'compact'); @@ -314,17 +302,15 @@ var MediaBar = { this._trailerState = 'resolving'; this._trailerVideoId = videoId; - this._ensureYTApi(function () { + this._ensureYTApi(function() { if (self._trailerState !== 'resolving' || self._trailerVideoId !== videoId) return; - self.fetchSponsorSegments(videoId) - .then(function (segments) { - self._sponsorSegments = segments; - self._loadYTPlayer(videoId); - }) - .catch(function () { - self._sponsorSegments = []; - self._loadYTPlayer(videoId); - }); + self.fetchSponsorSegments(videoId).then(function(segments) { + self._sponsorSegments = segments; + self._loadYTPlayer(videoId); + }).catch(function() { + self._sponsorSegments = []; + self._loadYTPlayer(videoId); + }); }); }, @@ -340,7 +326,7 @@ var MediaBar = { tag.src = 'https://www.youtube.com/iframe_api'; document.head.appendChild(tag); } - var checkInterval = setInterval(function () { + var checkInterval = setInterval(function() { if (window.YT && window.YT.Player) { clearInterval(checkInterval); self._ytApiReady = true; @@ -348,9 +334,7 @@ var MediaBar = { callback(); } }, 100); - setTimeout(function () { - clearInterval(checkInterval); - }, 10000); + setTimeout(function() { clearInterval(checkInterval); }, 10000); }, _loadYTPlayer(videoId) { @@ -361,9 +345,7 @@ var MediaBar = { var trailerContainer = this.container.querySelector('.moonfin-mediabar-trailer-container'); if (this._trailerPlayer) { - try { - this._trailerPlayer.destroy(); - } catch (e) {} + try { this._trailerPlayer.destroy(); } catch(e) {} this._trailerPlayer = null; } @@ -394,10 +376,10 @@ var MediaBar = { origin: window.location.origin }, events: { - onReady: function (event) { + onReady: function(event) { event.target.mute(); event.target.playVideo(); - self._trailerRevealTimer = setTimeout(function () { + self._trailerRevealTimer = setTimeout(function() { if (self._trailerState === 'playing') { var iframe = trailerContainer.querySelector('iframe'); if (iframe) iframe.classList.add('visible'); @@ -405,58 +387,44 @@ var MediaBar = { } }, self._trailerRevealMs); }, - onStateChange: function (event) { + onStateChange: function(event) { if (event.data === 0) { self.stopTrailer(); } }, - onError: function (event) { + onError: function(event) { console.warn('[Moonfin] MediaBar: YouTube player error:', event.data); self._trailerState = 'unavailable'; self.stopTrailer(); } } }); - } catch (e) { + } catch(e) { console.warn('[Moonfin] MediaBar: Failed to create YouTube player:', e); this._trailerState = 'unavailable'; } }, fetchSponsorSegments(videoId) { - return new Promise(function (resolve) { - var url = - 'https://sponsor.ajay.app/api/skipSegments?videoID=' + - videoId + - '&categories=["sponsor","selfpromo","intro","outro","interaction","music_offtopic"]'; - - fetch(url) - .then(function (resp) { - if (!resp.ok) { - resolve([]); - return; - } - return resp.json(); - }) - .then(function (data) { - if (!Array.isArray(data)) { - resolve([]); - return; - } - var segments = []; - for (var i = 0; i < data.length; i++) { - if (data[i].segment && data[i].segment.length === 2) { - segments.push({ - start: data[i].segment[0], - end: data[i].segment[1] - }); - } + return new Promise(function(resolve) { + var url = 'https://sponsor.ajay.app/api/skipSegments?videoID=' + videoId + + '&categories=["sponsor","selfpromo","intro","outro","interaction","music_offtopic"]'; + + fetch(url).then(function(resp) { + if (!resp.ok) { resolve([]); return; } + return resp.json(); + }).then(function(data) { + if (!Array.isArray(data)) { resolve([]); return; } + var segments = []; + for (var i = 0; i < data.length; i++) { + if (data[i].segment && data[i].segment.length === 2) { + segments.push({ start: data[i].segment[0], end: data[i].segment[1] }); } - resolve(segments); - }) - .catch(function () { - resolve([]); - }); + } + resolve(segments); + }).catch(function() { + resolve([]); + }); }); }, @@ -464,9 +432,7 @@ var MediaBar = { var startTime = 0; if (!segments || segments.length === 0) return startTime; - var sorted = segments.slice().sort(function (a, b) { - return a.start - b.start; - }); + var sorted = segments.slice().sort(function(a, b) { return a.start - b.start; }); for (var i = 0; i < sorted.length; i++) { if (sorted[i].start <= startTime + 1) { startTime = Math.max(startTime, sorted[i].end); @@ -484,15 +450,11 @@ var MediaBar = { if (this.container) this.container.classList.remove('trailer-active'); if (this._trailerPlayer) { - try { - this._trailerPlayer.destroy(); - } catch (e) {} + try { this._trailerPlayer.destroy(); } catch(e) {} this._trailerPlayer = null; } - var trailerContainer = this.container - ? this.container.querySelector('.moonfin-mediabar-trailer-container') - : null; + var trailerContainer = this.container ? this.container.querySelector('.moonfin-mediabar-trailer-container') : null; if (trailerContainer) trailerContainer.innerHTML = ''; this._trailerState = 'idle'; @@ -523,7 +485,7 @@ var MediaBar = { var img = new Image(); var self = this; - var doSwap = function () { + var doSwap = function() { next.style.transition = 'none'; next.classList.remove('active'); next.style.backgroundImage = "url('" + url + "')"; @@ -532,7 +494,7 @@ var MediaBar = { next.style.transition = ''; next.classList.add('active'); - self._crossfadeTimer = setTimeout(function () { + self._crossfadeTimer = setTimeout(function() { current.style.backgroundImage = "url('" + url + "')"; next.style.transition = 'none'; next.classList.remove('active'); @@ -544,7 +506,7 @@ var MediaBar = { img.onload = doSwap; img.onerror = doSwap; - setTimeout(function () { + setTimeout(function() { if (!img.complete) doSwap(); }, 300); img.src = url; @@ -556,32 +518,17 @@ var MediaBar = { if (!this.items || this.items.length < 2) return; var nextIdx = (this.currentIndex + 1) % this.items.length; var prevIdx = (this.currentIndex - 1 + this.items.length) % this.items.length; - var nextUrl = API.getImageUrl(this.items[nextIdx], 'Backdrop', { - maxWidth: 1920 - }); - var prevUrl = API.getImageUrl(this.items[prevIdx], 'Backdrop', { - maxWidth: 1920 - }); - if (nextUrl) { - var i1 = new Image(); - i1.src = nextUrl; - } - if (prevUrl) { - var i2 = new Image(); - i2.src = prevUrl; - } + var nextUrl = API.getImageUrl(this.items[nextIdx], 'Backdrop', { maxWidth: 1920 }); + var prevUrl = API.getImageUrl(this.items[prevIdx], 'Backdrop', { maxWidth: 1920 }); + if (nextUrl) { var i1 = new Image(); i1.src = nextUrl; } + if (prevUrl) { var i2 = new Image(); i2.src = prevUrl; } }, updateDots() { var dotsContainer = this.container.querySelector('.moonfin-mediabar-dots'); var html = ''; for (var i = 0; i < this.items.length; i++) { - html += - ''; + html += ''; } dotsContainer.innerHTML = html; }, @@ -629,7 +576,7 @@ var MediaBar = { var settings = Storage.getAll(); if (!settings.mediaBarAutoAdvance) return; - this.autoAdvanceTimer = setInterval(function () { + this.autoAdvanceTimer = setInterval(function() { if (!self.isPaused && self.isVisible && self._trailerState === 'idle') { self.nextSlide(); } @@ -652,6 +599,7 @@ var MediaBar = { ensureInDOM() { if (this.container && !document.body.contains(this.container)) { + document.body.appendChild(this.container); } }, @@ -659,17 +607,17 @@ var MediaBar = { setupEventListeners() { var self = this; - this.container.querySelector('.moonfin-mediabar-prev').addEventListener('click', function (e) { + this.container.querySelector('.moonfin-mediabar-prev').addEventListener('click', function(e) { e.stopPropagation(); self.prevSlide(); }); - this.container.querySelector('.moonfin-mediabar-next').addEventListener('click', function (e) { + this.container.querySelector('.moonfin-mediabar-next').addEventListener('click', function(e) { e.stopPropagation(); self.nextSlide(); }); - this.container.querySelector('.moonfin-mediabar-dots').addEventListener('click', function (e) { + this.container.querySelector('.moonfin-mediabar-dots').addEventListener('click', function(e) { e.stopPropagation(); var dot = e.target.closest('.moonfin-mediabar-dot'); if (dot) { @@ -677,7 +625,7 @@ var MediaBar = { } }); - this.container.addEventListener('click', function (e) { + this.container.addEventListener('click', function(e) { if (e.target.closest('.moonfin-mediabar-nav-btn, .moonfin-mediabar-dots, .moonfin-mediabar-dots-wrap')) { return; } @@ -695,61 +643,37 @@ var MediaBar = { var touchStartY = 0; var touchMoved = false; - this.container.addEventListener( - 'touchstart', - function (e) { - var touch = e.touches[0]; - touchStartX = touch.clientX; - touchStartY = touch.clientY; - touchMoved = false; - }, - { passive: true } - ); - - this.container.addEventListener( - 'touchmove', - function (e) { - if (!touchStartX) return; - var dx = Math.abs(e.touches[0].clientX - touchStartX); - var dy = Math.abs(e.touches[0].clientY - touchStartY); - if (dx > 10 || dy > 10) touchMoved = true; - if (dx > dy && dx > 10) e.preventDefault(); - }, - { passive: false } - ); - - this.container.addEventListener( - 'touchend', - function (e) { - if (!touchMoved) { - touchStartX = 0; - return; - } - var dx = e.changedTouches[0].clientX - touchStartX; - if (Math.abs(dx) >= 50) { - if (dx < 0) self.nextSlide(); - else self.prevSlide(); - } - touchStartX = 0; - touchMoved = false; - }, - { passive: true } - ); + this.container.addEventListener('touchstart', function(e) { + var touch = e.touches[0]; + touchStartX = touch.clientX; + touchStartY = touch.clientY; + touchMoved = false; + }, { passive: true }); + + this.container.addEventListener('touchmove', function(e) { + if (!touchStartX) return; + var dx = Math.abs(e.touches[0].clientX - touchStartX); + var dy = Math.abs(e.touches[0].clientY - touchStartY); + if (dx > 10 || dy > 10) touchMoved = true; + if (dx > dy && dx > 10) e.preventDefault(); + }, { passive: false }); + + this.container.addEventListener('touchend', function(e) { + if (!touchMoved) { touchStartX = 0; return; } + var dx = e.changedTouches[0].clientX - touchStartX; + if (Math.abs(dx) >= 50) { + if (dx < 0) self.nextSlide(); + else self.prevSlide(); + } + touchStartX = 0; + touchMoved = false; + }, { passive: true }); - this.container.addEventListener('keydown', function (e) { + this.container.addEventListener('keydown', function(e) { switch (e.key) { - case 'ArrowLeft': - self.prevSlide(); - e.preventDefault(); - break; - case 'ArrowRight': - self.nextSlide(); - e.preventDefault(); - break; - case ' ': - self.togglePause(); - e.preventDefault(); - break; + case 'ArrowLeft': self.prevSlide(); e.preventDefault(); break; + case 'ArrowRight': self.nextSlide(); e.preventDefault(); break; + case ' ': self.togglePause(); e.preventDefault(); break; case 'Enter': var item = self.items[self.currentIndex]; if (item) { @@ -764,22 +688,22 @@ var MediaBar = { } }); - this.container.addEventListener('mouseenter', function () { + this.container.addEventListener('mouseenter', function() { self.container.classList.add('focused'); }); - this.container.addEventListener('mouseleave', function () { + this.container.addEventListener('mouseleave', function() { self.container.classList.remove('focused'); }); - document.addEventListener('visibilitychange', function () { + document.addEventListener('visibilitychange', function() { self.isVisible = !document.hidden; if (document.hidden) { self.stopTrailer(); } }); - window.addEventListener('moonfin-settings-changed', function (e) { + window.addEventListener('moonfin-settings-changed', function(e) { self.applySettings(e.detail); }); }, @@ -799,7 +723,7 @@ var MediaBar = { var infoBox = this.container.querySelector('.moonfin-mediabar-info'); if (infoBox) infoBox.style.background = overlayColor; - this.container.querySelectorAll('.moonfin-mediabar-nav-btn').forEach(function (btn) { + this.container.querySelectorAll('.moonfin-mediabar-nav-btn').forEach(function(btn) { btn.style.background = overlayColor; }); @@ -813,13 +737,11 @@ var MediaBar = { this.stopTrailer(); } - if ( - this._lastItemCount !== settings.mediaBarItemCount || + if (this._lastItemCount !== settings.mediaBarItemCount || this._lastSourceType !== settings.mediaBarSourceType || this._lastShuffleItems !== settings.mediaBarShuffleItems || JSON.stringify(this._lastCollectionIds) !== JSON.stringify(settings.mediaBarCollectionIds) || - JSON.stringify(this._lastLibraryIds) !== JSON.stringify(settings.mediaBarLibraryIds) - ) { + JSON.stringify(this._lastLibraryIds) !== JSON.stringify(settings.mediaBarLibraryIds)) { this._lastItemCount = settings.mediaBarItemCount; this._lastSourceType = settings.mediaBarSourceType; this._lastCollectionIds = settings.mediaBarCollectionIds; diff --git a/frontend/src/components/settings.js b/frontend/src/components/settings.js index dce202e..cead030 100644 --- a/frontend/src/components/settings.js +++ b/frontend/src/components/settings.js @@ -3,14 +3,14 @@ var Settings = { isOpen: false, _toastTimeout: null, - show: function () { + show: function() { if (this.isOpen) return; this.createDialog(); // Trigger animation after append var self = this; - requestAnimationFrame(function () { - requestAnimationFrame(function () { + requestAnimationFrame(function() { + requestAnimationFrame(function() { if (self.dialog) { self.dialog.classList.add('open'); } @@ -22,14 +22,14 @@ var Settings = { else if (typeof Plugin !== 'undefined') Plugin._overlayHistoryDepth++; }, - hide: function (skipHistoryBack) { + hide: function(skipHistoryBack) { if (!this.isOpen) return; var self = this; this.isOpen = false; this.dialog.classList.remove('open'); - setTimeout(function () { + setTimeout(function() { if (self.dialog) { self.dialog.remove(); self.dialog = null; @@ -37,13 +37,11 @@ var Settings = { }, 300); if (!skipHistoryBack) { - try { - history.back(); - } catch (e) {} + try { history.back(); } catch(e) {} } }, - showToast: function (message) { + showToast: function(message) { var existing = document.querySelector('.moonfin-toast'); if (existing) existing.remove(); @@ -52,396 +50,219 @@ var Settings = { toast.textContent = message; document.body.appendChild(toast); - requestAnimationFrame(function () { + requestAnimationFrame(function() { toast.classList.add('visible'); }); if (this._toastTimeout) clearTimeout(this._toastTimeout); - this._toastTimeout = setTimeout(function () { + this._toastTimeout = setTimeout(function() { toast.classList.remove('visible'); - setTimeout(function () { - toast.remove(); - }, 300); + setTimeout(function() { toast.remove(); }, 300); }, 2000); }, - saveSetting: function (name, value) { + saveSetting: function(name, value) { var profileName = Storage.getActiveEditProfile(); var profile = Storage.getProfile(profileName); profile[name] = value; Storage.saveProfile(profileName, profile); - var safeValue = - name.toLowerCase().indexOf('apikey') !== -1 || name.toLowerCase().indexOf('token') !== -1 ? '***' : value; + var safeValue = name.toLowerCase().indexOf('apikey') !== -1 || name.toLowerCase().indexOf('token') !== -1 ? '***' : value; console.log('[Moonfin] Setting saved to profile "' + profileName + '":', name, '=', safeValue); }, - createToggleCard: function (id, title, description, checked) { - return ( - '
' + + createToggleCard: function(id, title, description, checked) { + return '
' + '' + - '
' - ); + '
'; }, - createSelectCard: function (id, title, description, options, currentValue) { + createSelectCard: function(id, title, description, options, currentValue) { var optionsHtml = ''; for (var i = 0; i < options.length; i++) { var opt = options[i]; - optionsHtml += - ''; + optionsHtml += ''; } - return ( - '
' + + return '
' + '
' + - '
' + - title + - '
' + - (description ? '
' + description + '
' : '') + + '
' + title + '
' + + (description ? '
' + description + '
' : '') + '
' + - '' + + optionsHtml + '' + - '
' - ); + '
'; }, - createRangeCard: function (id, title, description, min, max, step, currentValue, suffix) { - return ( - '
' + + createRangeCard: function(id, title, description, min, max, step, currentValue, suffix) { + return '
' + '
' + - '
' + - title + - ' ' + - currentValue + - (suffix || '') + - '
' + - (description ? '
' + description + '
' : '') + + '
' + title + ' ' + currentValue + (suffix || '') + '
' + + (description ? '
' + description + '
' : '') + '
' + - '' + - '
' - ); + '' + + '
'; }, - createSection: function (icon, title, contentHtml, openByDefault) { - return ( - '
' + - '' + - (icon ? icon + ' ' : '') + - title + - '' + + createSection: function(icon, title, contentHtml, openByDefault) { + return '
' + + '' + (icon ? icon + ' ' : '') + title + '' + '
' + - contentHtml + + contentHtml + '
' + - '
' - ); + '
'; }, - createDialog: function () { + createDialog: function() { var existing = document.querySelector('.moonfin-settings-dialog'); if (existing) existing.remove(); - var settings = Storage.getAll(Storage.getActiveEditProfile()); + var settings = Storage.getAll(); var self = this; this.dialog = document.createElement('div'); this.dialog.className = 'moonfin-settings-dialog'; var uiContent = - this.createToggleCard( - 'navbarEnabled', - 'Navigation Bar', - 'Show the custom navigation bar with quick access buttons', - settings.navbarEnabled - ) + - this.createSelectCard( - 'navbarPosition', - 'Navbar Position', - 'Show the navigation bar at the top or as a left sidebar', - [ - { value: 'top', label: 'Top' }, - { value: 'left', label: 'Left (Sidebar)' } - ], - settings.navbarPosition - ) + - this.createToggleCard( - 'mediaBarEnabled', - 'Media Bar', - 'Show the featured media carousel on the home page', - settings.mediaBarEnabled - ) + - this.createToggleCard( - 'detailsPageEnabled', - 'Details Page', - 'Use the custom Moonfin details page instead of the default Jellyfin one', - settings.detailsPageEnabled - ); + this.createToggleCard('navbarEnabled', 'Navigation Bar', 'Show the custom navigation bar with quick access buttons', settings.navbarEnabled) + + this.createSelectCard('navbarPosition', 'Navbar Position', 'Show the navigation bar at the top or as a left sidebar', [ + { value: 'top', label: 'Top' }, + { value: 'left', label: 'Left (Sidebar)' } + ], settings.navbarPosition) + + this.createToggleCard('mediaBarEnabled', 'Media Bar', 'Show the featured media carousel on the home page', settings.mediaBarEnabled) + + this.createToggleCard('detailsPageEnabled', 'Details Page', 'Use the custom Moonfin details page instead of the default Jellyfin one', settings.detailsPageEnabled); var mediaBarContent = - this.createSelectCard( - 'mediaBarSourceType', - 'Content Source', - 'Where to pull media bar items from', - [ - { value: 'library', label: 'Library (Random)' }, - { value: 'collection', label: 'Collections / Playlists' } - ], - settings.mediaBarSourceType - ) + - '
' + - '
' + - '
' + - '
Libraries
' + - '
Optionally select which libraries to use. Leave all unchecked to use all libraries.
' + - '
' + - '
' + - '
Loading...
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
Collections / Playlists
' + - '
Select which collections or playlists to show in the media bar
' + - '
' + - '
' + - '
Loading...
' + - '
' + + this.createSelectCard('mediaBarSourceType', 'Content Source', 'Where to pull media bar items from', [ + { value: 'library', label: 'Library (Random)' }, + { value: 'collection', label: 'Collections / Playlists' } + ], settings.mediaBarSourceType) + + + '
' + + '
' + + '
' + + '
Libraries
' + + '
Optionally select which libraries to use. Leave all unchecked to use all libraries.
' + + '
' + + '
' + + '
Loading...
' + + '
' + + '
' + '
' + - this.createToggleCard( - 'mediaBarShuffleItems', - 'Shuffle Items', - 'Randomize the order of items from selected collections', - settings.mediaBarShuffleItems !== false - ) + + '
' + + '
' + + '
' + + '
Collections / Playlists
' + + '
Select which collections or playlists to show in the media bar
' + + '
' + + '
' + + '
Loading...
' + + '
' + + '
' + + this.createToggleCard('mediaBarShuffleItems', 'Shuffle Items', 'Randomize the order of items from selected collections', settings.mediaBarShuffleItems !== false) + '
' + - this.createSelectCard( - 'mediaBarItemCount', - 'Number of Items', - 'How many items to display', - [ - { value: '5', label: '5' }, - { value: '10', label: '10' }, - { value: '15', label: '15' }, - { value: '20', label: '20' } - ], - settings.mediaBarItemCount - ) + - this.createToggleCard( - 'mediaBarTrailerPreview', - 'Trailer Preview', - 'Automatically play muted trailer previews in the media bar background', - settings.mediaBarTrailerPreview - ); + + this.createSelectCard('mediaBarItemCount', 'Number of Items', 'How many items to display', [ + { value: '5', label: '5' }, + { value: '10', label: '10' }, + { value: '15', label: '15' }, + { value: '20', label: '20' } + ], settings.mediaBarItemCount) + + + this.createToggleCard('mediaBarTrailerPreview', 'Trailer Preview', 'Automatically play muted trailer previews in the media bar background', settings.mediaBarTrailerPreview); var colorOptions = []; var colorKeys = Object.keys(Storage.colorOptions); for (var i = 0; i < colorKeys.length; i++) { - colorOptions.push({ - value: colorKeys[i], - label: Storage.colorOptions[colorKeys[i]].name - }); + colorOptions.push({ value: colorKeys[i], label: Storage.colorOptions[colorKeys[i]].name }); } var overlayContent = - this.createSelectCard( - 'mediaBarOverlayColor', - 'Overlay Color', - 'Color of the gradient overlay on media bar items', - colorOptions, - settings.mediaBarOverlayColor - ) + - '
' + - this.createRangeCard( - 'mediaBarOverlayOpacity', - 'Overlay Opacity', - 'Transparency of the gradient overlay', - 0, - 100, - 5, - settings.mediaBarOverlayOpacity, - '%' - ); + this.createSelectCard('mediaBarOverlayColor', 'Overlay Color', 'Color of the gradient overlay on media bar items', colorOptions, settings.mediaBarOverlayColor) + + '
' + + this.createRangeCard('mediaBarOverlayOpacity', 'Overlay Opacity', 'Transparency of the gradient overlay', 0, 100, 5, settings.mediaBarOverlayOpacity, '%'); var toolbarContent = - this.createToggleCard( - 'showShuffleButton', - 'Shuffle Button', - 'Show random content button in the toolbar', - settings.showShuffleButton - ) + - this.createSelectCard( - 'shuffleContentType', - 'Shuffle Content Type', - 'What type of content to shuffle', - [ - { value: 'both', label: 'Movies & TV Shows' }, - { value: 'movies', label: 'Movies Only' }, - { value: 'tv', label: 'TV Shows Only' } - ], - settings.shuffleContentType - ) + - this.createToggleCard( - 'showGenresButton', - 'Genres Button', - 'Show genres dropdown in the toolbar', - settings.showGenresButton - ) + - this.createToggleCard( - 'showFavoritesButton', - 'Favorites Button', - 'Show favorites button in the toolbar', - settings.showFavoritesButton - ) + - this.createToggleCard( - 'showCastButton', - 'Cast Button', - 'Show Chromecast button in the toolbar', - settings.showCastButton - ) + - this.createToggleCard( - 'showSyncPlayButton', - 'SyncPlay Button', - 'Show SyncPlay button in the toolbar', - settings.showSyncPlayButton - ) + - this.createToggleCard( - 'showLibrariesInToolbar', - 'Library Shortcuts', - 'Show library quick links in the toolbar', - settings.showLibrariesInToolbar - ); + this.createToggleCard('showShuffleButton', 'Shuffle Button', 'Show random content button in the toolbar', settings.showShuffleButton) + + this.createSelectCard('shuffleContentType', 'Shuffle Content Type', 'What type of content to shuffle', [ + { value: 'both', label: 'Movies & TV Shows' }, + { value: 'movies', label: 'Movies Only' }, + { value: 'tv', label: 'TV Shows Only' } + ], settings.shuffleContentType) + + this.createToggleCard('showGenresButton', 'Genres Button', 'Show genres dropdown in the toolbar', settings.showGenresButton) + + this.createToggleCard('showFavoritesButton', 'Favorites Button', 'Show favorites button in the toolbar', settings.showFavoritesButton) + + this.createToggleCard('showCastButton', 'Cast Button', 'Show Chromecast button in the toolbar', settings.showCastButton) + + this.createToggleCard('showSyncPlayButton', 'SyncPlay Button', 'Show SyncPlay button in the toolbar', settings.showSyncPlayButton) + + this.createToggleCard('showLibrariesInToolbar', 'Library Shortcuts', 'Show library quick links in the toolbar', settings.showLibrariesInToolbar); var seasonalOptions = []; var seasonKeys = Object.keys(Storage.seasonalOptions); for (var j = 0; j < seasonKeys.length; j++) { - seasonalOptions.push({ - value: seasonKeys[j], - label: Storage.seasonalOptions[seasonKeys[j]].name - }); + seasonalOptions.push({ value: seasonKeys[j], label: Storage.seasonalOptions[seasonKeys[j]].name }); } var displayContent = this.createToggleCard('showClock', 'Clock', 'Show a clock in the navigation bar', settings.showClock) + - this.createToggleCard( - 'use24HourClock', - '24-Hour Format', - 'Use 24-hour time format instead of 12-hour', - settings.use24HourClock - ) + - this.createSelectCard( - 'seasonalSurprise', - 'Seasonal Effect', - 'Add a seasonal visual effect to the interface', - seasonalOptions, - settings.seasonalSurprise - ); + this.createToggleCard('use24HourClock', '24-Hour Format', 'Use 24-hour time format instead of 12-hour', settings.use24HourClock) + + this.createSelectCard('seasonalSurprise', 'Seasonal Effect', 'Add a seasonal visual effect to the interface', seasonalOptions, settings.seasonalSurprise); var jellyseerrContent = '
' + - '
' + - '' + - 'Checking...' + - '
' + + '
' + + '' + + 'Checking...' + + '
' + '
' + '' + ''; var mdblistSources = [ - { key: 'imdb', label: 'IMDb' }, - { key: 'tmdb', label: 'TMDb' }, - { key: 'trakt', label: 'Trakt' }, - { key: 'tomatoes', label: 'Rotten Tomatoes (Critics)' }, - { key: 'popcorn', label: 'Rotten Tomatoes (Audience)' }, - { key: 'metacritic', label: 'Metacritic' }, + { key: 'imdb', label: 'IMDb' }, + { key: 'tmdb', label: 'TMDb' }, + { key: 'trakt', label: 'Trakt' }, + { key: 'tomatoes', label: 'Rotten Tomatoes (Critics)' }, + { key: 'popcorn', label: 'Rotten Tomatoes (Audience)' }, + { key: 'metacritic', label: 'Metacritic' }, { key: 'metacriticuser', label: 'Metacritic User' }, - { key: 'letterboxd', label: 'Letterboxd' }, - { key: 'rogerebert', label: 'Roger Ebert' }, - { key: 'myanimelist', label: 'MyAnimeList' }, - { key: 'anilist', label: 'AniList' } + { key: 'letterboxd', label: 'Letterboxd' }, + { key: 'rogerebert', label: 'Roger Ebert' }, + { key: 'myanimelist', label: 'MyAnimeList' }, + { key: 'anilist', label: 'AniList' } ]; var selectedSources = settings.mdblistRatingSources || ['imdb', 'tmdb', 'tomatoes', 'metacritic']; - var serverUrl = - (window.ApiClient && window.ApiClient.serverAddress ? window.ApiClient.serverAddress() : '') || ''; + var serverUrl = (window.ApiClient && window.ApiClient.serverAddress ? window.ApiClient.serverAddress() : '') || ''; var sourceIconFiles = { - imdb: 'imdb.svg', - tmdb: 'tmdb.svg', - trakt: 'trakt.svg', - tomatoes: 'rt-fresh.svg', - popcorn: 'rt-audience-up.svg', - metacritic: 'metacritic.svg', - metacriticuser: 'metacritic-user.svg', - letterboxd: 'letterboxd.svg', - rogerebert: 'rogerebert.svg', - myanimelist: 'mal.svg', - anilist: 'anilist.svg' + imdb: 'imdb.svg', tmdb: 'tmdb.svg', trakt: 'trakt.svg', + tomatoes: 'rt-fresh.svg', popcorn: 'rt-audience-up.svg', + metacritic: 'metacritic.svg', metacriticuser: 'metacritic-user.svg', + letterboxd: 'letterboxd.svg', rogerebert: 'rogerebert.svg', + myanimelist: 'mal.svg', anilist: 'anilist.svg' }; // Build ordered list: enabled sources first (in saved order), then disabled @@ -449,22 +270,14 @@ var Settings = { for (var oi = 0; oi < selectedSources.length; oi++) { for (var oj = 0; oj < mdblistSources.length; oj++) { if (mdblistSources[oj].key === selectedSources[oi]) { - orderedSources.push({ - key: mdblistSources[oj].key, - label: mdblistSources[oj].label, - enabled: true - }); + orderedSources.push({ key: mdblistSources[oj].key, label: mdblistSources[oj].label, enabled: true }); break; } } } for (var uk = 0; uk < mdblistSources.length; uk++) { if (selectedSources.indexOf(mdblistSources[uk].key) === -1) { - orderedSources.push({ - key: mdblistSources[uk].key, - label: mdblistSources[uk].label, - enabled: false - }); + orderedSources.push({ key: mdblistSources[uk].key, label: mdblistSources[uk].label, enabled: false }); } } @@ -472,175 +285,112 @@ var Settings = { for (var si = 0; si < orderedSources.length; si++) { var src = orderedSources[si]; var iconUrl = serverUrl + '/Moonfin/Assets/' + (sourceIconFiles[src.key] || 'imdb.svg'); - sourceItems += - '
' + + sourceItems += '
' + '' + - '' + + '' + '' + - '' +
-                src.label +
-                '' + - '' + - src.label + - '' + - '' + - '
'; + '
'; } var tmdbContent = - this.createToggleCard( - 'tmdbEpisodeRatingsEnabled', - 'Enable Episode Ratings', - 'Show TMDB ratings for individual TV episodes on the details page', - settings.tmdbEpisodeRatingsEnabled - ) + - '
' + - (Storage.syncState.tmdbAvailable - ? '
' + - 'Your server admin has provided a server-wide TMDB API key. You can leave the field below blank to use it, or enter your own key.' + - '
' - : '') + - '
' + - '' + - '' + - '
Get a free API key at themoviedb.org/settings/api
' + - '
' + + this.createToggleCard('tmdbEpisodeRatingsEnabled', 'Enable Episode Ratings', 'Show TMDB ratings for individual TV episodes on the details page', settings.tmdbEpisodeRatingsEnabled) + + '
' + + (Storage.syncState.tmdbAvailable ? + '
' + + 'Your server admin has provided a server-wide TMDB API key. You can leave the field below blank to use it, or enter your own key.' + + '
' : '') + + '
' + + '' + + '' + + '
Get a free API key at themoviedb.org/settings/api
' + + '
' + '
'; var mdblistContent = - this.createToggleCard( - 'mdblistEnabled', - 'Enable MDBList Ratings', - 'Show ratings from MDBList (IMDb, Rotten Tomatoes, Metacritic, etc.) on media bar and item details', - settings.mdblistEnabled - ) + - '
' + - this.createToggleCard( - 'mdblistShowRatingNames', - 'Show Rating Source Names', - 'Display the name of each rating source below its score', - settings.mdblistShowRatingNames - ) + - (Storage.syncState.mdblistAvailable - ? '
' + - 'Your server admin has provided a server-wide MDBList API key. You can leave the field below blank to use it, or enter your own key.' + - '
' - : '') + - '
' + - '' + - '' + - '
Get your free API key at mdblist.com/preferences
' + - '
' + - '
' + - '' + - '

Drag to reorder. Click the icon on the right to enable or disable a source.

' + - '
' + - sourceItems + - '
' + - '
' + + this.createToggleCard('mdblistEnabled', 'Enable MDBList Ratings', 'Show ratings from MDBList (IMDb, Rotten Tomatoes, Metacritic, etc.) on media bar and item details', settings.mdblistEnabled) + + '
' + + this.createToggleCard('mdblistShowRatingNames', 'Show Rating Source Names', 'Display the name of each rating source below its score', settings.mdblistShowRatingNames) + + (Storage.syncState.mdblistAvailable ? + '
' + + 'Your server admin has provided a server-wide MDBList API key. You can leave the field below blank to use it, or enter your own key.' + + '
' : '') + + '
' + + '' + + '' + + '
Get your free API key at mdblist.com/preferences
' + + '
' + + '
' + + '' + + '

Drag to reorder. Click the icon on the right to enable or disable a source.

' + + '
' + sourceItems + '
' + + '
' + '
'; var currentDeviceProfile = Device.getProfileName(); - var profileLabels = { - global: 'All Devices', - desktop: 'Desktop', - mobile: 'Mobile', - tv: 'TV' - }; + var profileLabels = { global: 'All Devices', desktop: 'Desktop', mobile: 'Mobile', tv: 'TV' }; var profileTabsHtml = '
'; var profileNames = ['global', 'desktop', 'mobile', 'tv']; for (var pi = 0; pi < profileNames.length; pi++) { var pn = profileNames[pi]; var isActive = pn === 'global'; var isCurrent = pn === currentDeviceProfile; - profileTabsHtml += - ''; + ''; } profileTabsHtml += '
'; - var profileInfoHtml = - '
' + + var profileInfoHtml = '
' + ' ' + '"All Devices" settings apply everywhere. Device profiles override only the settings you change.' + - '
'; + '
'; this.dialog.innerHTML = '
' + '
' + - '
' + - '
' + - '

Moonfin

' + - 'Settings' + - '
' + - '' + - '
' + - profileTabsHtml + - profileInfoHtml + - '
' + - this.createSection('', 'Moonfin UI', uiContent, true) + - this.createSection('', 'Media Bar', mediaBarContent) + - this.createSection('', 'Overlay Appearance', overlayContent) + - this.createSection('', 'Toolbar Buttons', toolbarContent) + - this.createSection('', 'Display', displayContent) + - this.createSection('', 'TMDB Episode Ratings', tmdbContent) + - this.createSection('', 'MDBList Ratings', mdblistContent) + - '' + - '
' + - '' + + '
' + + '
' + + '

Moonfin

' + + 'Settings' + + '
' + + '' + + '
' + + profileTabsHtml + + profileInfoHtml + + '
' + + this.createSection('', 'Moonfin UI', uiContent, true) + + this.createSection('', 'Media Bar', mediaBarContent) + + this.createSection('', 'Overlay Appearance', overlayContent) + + this.createSection('', 'Toolbar Buttons', toolbarContent) + + this.createSection('', 'Display', displayContent) + + this.createSection('', 'TMDB Episode Ratings', tmdbContent) + + this.createSection('', 'MDBList Ratings', mdblistContent) + + '' + + '
' + + '' + '
'; document.body.appendChild(this.dialog); @@ -650,10 +400,10 @@ var Settings = { this.updateJellyseerrSsoSection(); }, - refreshFormValues: function (profileName) { + refreshFormValues: function(profileName) { if (!this.dialog) return; var resolved = Storage.resolveSettings(profileName); - var raw = profileName !== 'global' ? Storage.getProfile(profileName) : null; + var raw = (profileName !== 'global') ? Storage.getProfile(profileName) : null; // Update checkboxes var checkboxes = this.dialog.querySelectorAll('input[type="checkbox"][name]'); @@ -729,217 +479,21 @@ var Settings = { var infoText = this.dialog.querySelector('.moonfin-profile-info-text'); if (infoText) { if (profileName === 'global') { - infoText.textContent = - '"All Devices" settings apply everywhere. Device profiles override only the settings you change.'; + infoText.textContent = '"All Devices" settings apply everywhere. Device profiles override only the settings you change.'; } else { var label = profileName.charAt(0).toUpperCase() + profileName.slice(1); - infoText.textContent = - 'Editing ' + - label + - ' overrides. Dimmed settings are inherited from "All Devices". Changes here only affect ' + - label + - ' devices.'; + infoText.textContent = 'Editing ' + label + ' overrides. Dimmed settings are inherited from "All Devices". Changes here only affect ' + label + ' devices.'; } } }, - loadCollectionPicker: function () { - var self = this; - var picker = this.dialog ? this.dialog.querySelector('#moonfin-collection-picker') : null; - if (!picker) return; - - picker.innerHTML = '
Loading...
'; - - API.getCollectionsAndPlaylists() - .then(function (items) { - if (!self.dialog) return; - if (!items || items.length === 0) { - picker.innerHTML = - '
No collections or playlists found on this server.
'; - return; - } - - var settings = Storage.getAll(); - var selectedIds = settings.mediaBarCollectionIds || []; - var serverUrl = - (window.ApiClient && window.ApiClient.serverAddress ? window.ApiClient.serverAddress() : '') || ''; - var html = ''; - - for (var i = 0; i < items.length; i++) { - var item = items[i]; - var isChecked = selectedIds.indexOf(item.Id) !== -1; - var typeBadge = item.Type === 'BoxSet' ? 'Collection' : 'Playlist'; - var posterUrl = ''; - if (item.ImageTags && item.ImageTags.Primary) { - posterUrl = - serverUrl + - '/Items/' + - item.Id + - '/Images/Primary?maxWidth=60&quality=80&tag=' + - item.ImageTags.Primary; - } - - html += - ''; - } - - picker.innerHTML = html; - - // Event delegation for checkboxes - picker.addEventListener('change', function (e) { - var cb = e.target; - if (!cb.dataset.collectionId) return; - - var currentIds = Storage.get('mediaBarCollectionIds') || []; - // Ensure we're working with a copy - currentIds = currentIds.slice(); - var id = cb.dataset.collectionId; - var idx = currentIds.indexOf(id); - - if (cb.checked && idx === -1) { - currentIds.push(id); - } else if (!cb.checked && idx !== -1) { - currentIds.splice(idx, 1); - } - - self.saveSetting('mediaBarCollectionIds', currentIds); - - // Update visual state - var label = cb.closest('.moonfin-collection-item'); - if (label) label.classList.toggle('moonfin-collection-item-active', cb.checked); - - self.showToast(cb.checked ? 'Added to media bar' : 'Removed from media bar'); - }); - }) - .catch(function (e) { - console.error('[Moonfin] Failed to load collection picker:', e); - picker.innerHTML = '
Failed to load collections.
'; - }); - }, - - loadLibraryPicker: function () { - var self = this; - var picker = this.dialog ? this.dialog.querySelector('#moonfin-library-picker') : null; - if (!picker) return; - - picker.innerHTML = '
Loading...
'; - - API.getUserViews() - .then(function (views) { - if (!self.dialog) return; - - // Filter to media libraries (Movies, TV Shows, Music, etc.) - var libraries = []; - for (var i = 0; i < views.length; i++) { - var ct = views[i].CollectionType; - if (ct === 'movies' || ct === 'tvshows' || ct === 'music' || ct === 'mixed') { - libraries.push(views[i]); - } - } - - if (libraries.length === 0) { - picker.innerHTML = '
No media libraries found.
'; - return; - } - - var settings = Storage.getAll(); - var selectedIds = settings.mediaBarLibraryIds || []; - var html = ''; - - for (var j = 0; j < libraries.length; j++) { - var lib = libraries[j]; - var isChecked = selectedIds.indexOf(lib.Id) !== -1; - var typeBadge = - (lib.CollectionType || 'library').charAt(0).toUpperCase() + - (lib.CollectionType || 'library').slice(1); - - html += - ''; - } - - picker.innerHTML = html; - - // Event delegation for library checkboxes - picker.addEventListener('change', function (e) { - var cb = e.target; - if (!cb.dataset.libraryId) return; - - var currentIds = Storage.get('mediaBarLibraryIds') || []; - currentIds = currentIds.slice(); - var id = cb.dataset.libraryId; - var idx = currentIds.indexOf(id); - - if (cb.checked && idx === -1) { - currentIds.push(id); - } else if (!cb.checked && idx !== -1) { - currentIds.splice(idx, 1); - } - - self.saveSetting('mediaBarLibraryIds', currentIds); - - var label = cb.closest('.moonfin-collection-item'); - if (label) label.classList.toggle('moonfin-collection-item-active', cb.checked); - - self.showToast(cb.checked ? 'Library added' : 'Library removed'); - }); - }) - .catch(function (e) { - console.error('[Moonfin] Failed to load library picker:', e); - picker.innerHTML = '
Failed to load libraries.
'; - }); - }, - - updateJellyseerrSsoSection: function () { + updateJellyseerrSsoSection: function() { var self = this; var wrapper = this.dialog ? this.dialog.querySelector('.moonfin-settings-jellyseerr-wrapper') : null; if (!wrapper) return Promise.resolve(); // Always fetch fresh config to catch admin changes - return Jellyseerr.fetchConfig().then(function () { + return Jellyseerr.fetchConfig().then(function() { if (!Jellyseerr.config || !Jellyseerr.config.enabled || !Jellyseerr.config.url) { console.log('[Moonfin] Jellyseerr not configured, hiding section. Config:', Jellyseerr.config); wrapper.style.display = 'none'; @@ -953,7 +507,7 @@ var Settings = { var loginGroup = wrapper.querySelector('.moonfin-jellyseerr-login-group'); var signedInGroup = wrapper.querySelector('.moonfin-jellyseerr-signedIn-group'); - return Jellyseerr.checkSsoStatus().then(function () { + return Jellyseerr.checkSsoStatus().then(function() { if (Jellyseerr.ssoStatus && Jellyseerr.ssoStatus.authenticated) { indicator.className = 'moonfin-jellyseerr-sso-indicator connected'; var displayName = Jellyseerr.ssoStatus.displayName || 'Unknown'; @@ -978,7 +532,7 @@ var Settings = { }); }, - updateSyncStatus: function () { + updateSyncStatus: function() { var self = this; var statusEl = this.dialog ? this.dialog.querySelector('#moonfinSyncStatus') : null; if (!statusEl) return Promise.resolve(); @@ -997,7 +551,7 @@ var Settings = { // Always re-ping when the panel opens to get fresh status indicator.className = 'moonfin-sync-indicator checking'; text.textContent = 'Checking server...'; - return Storage.pingServer().then(function () { + return Storage.pingServer().then(function() { var freshStatus = Storage.getSyncStatus(); if (freshStatus.available) { indicator.className = 'moonfin-sync-indicator connected'; @@ -1014,72 +568,60 @@ var Settings = { }); }, - setupEventListeners: function () { + setupEventListeners: function() { var self = this; - this.dialog.querySelector('.moonfin-settings-close').addEventListener('click', function () { + this.dialog.querySelector('.moonfin-settings-close').addEventListener('click', function() { self.hide(); }); - this.dialog.querySelector('.moonfin-settings-close-btn').addEventListener('click', function () { + this.dialog.querySelector('.moonfin-settings-close-btn').addEventListener('click', function() { self.hide(); }); - this.dialog.querySelector('.moonfin-settings-overlay').addEventListener('click', function () { + this.dialog.querySelector('.moonfin-settings-overlay').addEventListener('click', function() { self.hide(); }); - this.dialog.querySelector('.moonfin-settings-reset').addEventListener('click', function () { + this.dialog.querySelector('.moonfin-settings-reset').addEventListener('click', function() { var activeProfile = Storage.getActiveEditProfile(); if (activeProfile !== 'global') { - if ( - confirm( - 'Reset "' + activeProfile + '" device profile? This will remove all overrides for this device.' - ) - ) { + if (confirm('Reset "' + activeProfile + '" device profile? This will remove all overrides for this device.')) { Storage.deleteProfile(activeProfile); self.showToast('Device profile reset'); self.hide(); - setTimeout(function () { - self.show(); - }, 350); + setTimeout(function() { self.show(); }, 350); } } else { if (confirm('Reset all Moonfin settings to defaults?')) { Storage.reset(); self.showToast('Settings reset to defaults'); self.hide(); - setTimeout(function () { - self.show(); - }, 350); + setTimeout(function() { self.show(); }, 350); } } }); - this.dialog.querySelector('.moonfin-settings-sync').addEventListener('click', function () { + this.dialog.querySelector('.moonfin-settings-sync').addEventListener('click', function() { var syncBtn = self.dialog.querySelector('.moonfin-settings-sync'); syncBtn.disabled = true; syncBtn.textContent = 'Syncing...'; - Storage.sync(true) - .then(function () { - return self.updateSyncStatus(); - }) - .then(function () { - syncBtn.disabled = false; - syncBtn.textContent = 'Sync'; - self.showToast('Settings synced from server'); - self.hide(); - setTimeout(function () { - self.show(); - }, 350); - }); + Storage.sync(true).then(function() { + return self.updateSyncStatus(); + }).then(function() { + syncBtn.disabled = false; + syncBtn.textContent = 'Sync'; + self.showToast('Settings synced from server'); + self.hide(); + setTimeout(function() { self.show(); }, 350); + }); }); // Profile tab switching var profileTabs = this.dialog.querySelectorAll('.moonfin-profile-tab'); for (var pti = 0; pti < profileTabs.length; pti++) { - profileTabs[pti].addEventListener('click', function () { + profileTabs[pti].addEventListener('click', function() { var profileName = this.getAttribute('data-profile'); for (var pt = 0; pt < profileTabs.length; pt++) { profileTabs[pt].classList.remove('moonfin-profile-tab-active'); @@ -1087,19 +629,14 @@ var Settings = { this.classList.add('moonfin-profile-tab-active'); Storage.setActiveEditProfile(profileName); self.refreshFormValues(profileName); - self.showToast( - 'Editing: ' + - (profileName === 'global' - ? 'All Devices' - : profileName.charAt(0).toUpperCase() + profileName.slice(1)) - ); + self.showToast('Editing: ' + (profileName === 'global' ? 'All Devices' : profileName.charAt(0).toUpperCase() + profileName.slice(1))); }); } // Sync toggle var syncToggle = this.dialog.querySelector('#moonfin-sync-toggle'); if (syncToggle) { - syncToggle.addEventListener('change', function () { + syncToggle.addEventListener('change', function() { Storage.setSyncEnabled(syncToggle.checked); self.showToast(syncToggle.checked ? 'Sync enabled' : 'Sync disabled'); self.updateSyncStatus(); @@ -1108,8 +645,8 @@ var Settings = { var checkboxes = this.dialog.querySelectorAll('input[type="checkbox"][name]'); for (var i = 0; i < checkboxes.length; i++) { - (function (cb) { - cb.addEventListener('change', function () { + (function(cb) { + cb.addEventListener('change', function() { self.saveSetting(cb.name, cb.checked); self.showToast(cb.checked ? 'Enabled' : 'Disabled'); @@ -1132,8 +669,8 @@ var Settings = { var selects = this.dialog.querySelectorAll('select'); for (var j = 0; j < selects.length; j++) { - (function (sel) { - sel.addEventListener('change', function () { + (function(sel) { + sel.addEventListener('change', function() { var val = sel.value; var numVal = parseInt(val, 10); self.saveSetting(sel.name, isNaN(numVal) ? val : numVal); @@ -1144,14 +681,14 @@ var Settings = { var ranges = this.dialog.querySelectorAll('input[type="range"]'); for (var k = 0; k < ranges.length; k++) { - (function (range) { - range.addEventListener('input', function () { + (function(range) { + range.addEventListener('input', function() { var valueSpan = self.dialog.querySelector('.moonfin-range-value[data-for="' + range.name + '"]'); if (valueSpan) { valueSpan.textContent = range.value + '%'; } }); - range.addEventListener('change', function () { + range.addEventListener('change', function() { self.saveSetting(range.name, parseInt(range.value, 10)); self.showToast('Setting updated'); }); @@ -1160,7 +697,7 @@ var Settings = { var colorSelect = this.dialog.querySelector('select[name="mediaBarOverlayColor"]'); if (colorSelect) { - colorSelect.addEventListener('change', function () { + colorSelect.addEventListener('change', function() { var preview = self.dialog.querySelector('#moonfin-color-preview'); if (preview) { preview.style.background = Storage.getColorHex(colorSelect.value); @@ -1168,10 +705,16 @@ var Settings = { }); } + this.dialog.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + self.hide(); + } + }); + // Media bar source type toggle var sourceTypeSelect = this.dialog.querySelector('select[name="mediaBarSourceType"]'); if (sourceTypeSelect) { - sourceTypeSelect.addEventListener('change', function () { + sourceTypeSelect.addEventListener('change', function() { var isCollection = sourceTypeSelect.value === 'collection'; var libraryOpts = self.dialog.querySelector('.moonfin-mediabar-library-options'); var collectionOpts = self.dialog.querySelector('.moonfin-mediabar-collection-options'); @@ -1192,24 +735,18 @@ var Settings = { } } - this.dialog.addEventListener('keydown', function (e) { - if (e.key === 'Escape') { - self.hide(); - } - }); - // MDBList API key - save on input with debounce + on blur var mdblistApiKeyInput = this.dialog.querySelector('#moonfin-mdblistApiKey'); if (mdblistApiKeyInput) { var mdblistKeyTimer = null; - mdblistApiKeyInput.addEventListener('input', function () { + mdblistApiKeyInput.addEventListener('input', function() { if (mdblistKeyTimer) clearTimeout(mdblistKeyTimer); - mdblistKeyTimer = setTimeout(function () { + mdblistKeyTimer = setTimeout(function() { self.saveSetting('mdblistApiKey', mdblistApiKeyInput.value.trim()); self.showToast('API key saved'); }, 800); }); - mdblistApiKeyInput.addEventListener('blur', function () { + mdblistApiKeyInput.addEventListener('blur', function() { if (mdblistKeyTimer) clearTimeout(mdblistKeyTimer); self.saveSetting('mdblistApiKey', mdblistApiKeyInput.value.trim()); }); @@ -1219,14 +756,14 @@ var Settings = { var tmdbApiKeyInput = this.dialog.querySelector('#moonfin-tmdbApiKey'); if (tmdbApiKeyInput) { var tmdbKeyTimer = null; - tmdbApiKeyInput.addEventListener('input', function () { + tmdbApiKeyInput.addEventListener('input', function() { if (tmdbKeyTimer) clearTimeout(tmdbKeyTimer); - tmdbKeyTimer = setTimeout(function () { + tmdbKeyTimer = setTimeout(function() { self.saveSetting('tmdbApiKey', tmdbApiKeyInput.value.trim()); self.showToast('TMDB API key saved'); }, 800); }); - tmdbApiKeyInput.addEventListener('blur', function () { + tmdbApiKeyInput.addEventListener('blur', function() { if (tmdbKeyTimer) clearTimeout(tmdbKeyTimer); self.saveSetting('tmdbApiKey', tmdbApiKeyInput.value.trim()); }); @@ -1239,7 +776,7 @@ var Settings = { var dragPlaceholder = document.createElement('div'); dragPlaceholder.className = 'moonfin-sortable-placeholder'; - var saveSortableState = function () { + var saveSortableState = function() { var items = sortableList.querySelectorAll('.moonfin-sortable-item'); var enabled = []; for (var i = 0; i < items.length; i++) { @@ -1251,7 +788,7 @@ var Settings = { self.showToast('Rating sources updated'); }; - sortableList.addEventListener('dragstart', function (e) { + sortableList.addEventListener('dragstart', function(e) { var item = e.target.closest('.moonfin-sortable-item'); if (!item) return; dragItem = item; @@ -1260,7 +797,7 @@ var Settings = { e.dataTransfer.setData('text/plain', ''); }); - sortableList.addEventListener('dragend', function () { + sortableList.addEventListener('dragend', function() { if (dragItem) { dragItem.classList.remove('moonfin-sortable-dragging'); dragItem = null; @@ -1270,7 +807,7 @@ var Settings = { } }); - sortableList.addEventListener('dragover', function (e) { + sortableList.addEventListener('dragover', function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; var target = e.target.closest('.moonfin-sortable-item'); @@ -1285,7 +822,7 @@ var Settings = { } }); - sortableList.addEventListener('drop', function (e) { + sortableList.addEventListener('drop', function(e) { e.preventDefault(); if (!dragItem) return; if (dragPlaceholder.parentNode) { @@ -1296,7 +833,7 @@ var Settings = { }); // Toggle enable/disable - sortableList.addEventListener('click', function (e) { + sortableList.addEventListener('click', function(e) { var toggleBtn = e.target.closest('.moonfin-sortable-toggle'); if (!toggleBtn) return; var item = toggleBtn.closest('.moonfin-sortable-item'); @@ -1304,94 +841,79 @@ var Settings = { var isActive = item.classList.toggle('moonfin-sortable-item-active'); var svg = toggleBtn.querySelector('path'); if (svg) { - svg.setAttribute( - 'd', - isActive ? 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' : 'M19 13H5v-2h14v2z' - ); + svg.setAttribute('d', isActive + ? 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' + : 'M19 13H5v-2h14v2z'); } toggleBtn.title = isActive ? 'Disable' : 'Enable'; saveSortableState(); }); // Touch drag support for mobile / TV - (function () { + (function() { var touchItem = null; var touchClone = null; var touchOffsetY = 0; - sortableList.addEventListener( - 'touchstart', - function (e) { - var handle = e.target.closest('.moonfin-sortable-handle'); - if (!handle) return; - var item = handle.closest('.moonfin-sortable-item'); - if (!item) return; - touchItem = item; - var rect = item.getBoundingClientRect(); - touchOffsetY = e.touches[0].clientY - rect.top; - touchClone = item.cloneNode(true); - touchClone.className = 'moonfin-sortable-item moonfin-sortable-touch-clone'; - touchClone.style.width = rect.width + 'px'; - touchClone.style.top = rect.top + 'px'; - touchClone.style.left = rect.left + 'px'; - document.body.appendChild(touchClone); - item.classList.add('moonfin-sortable-dragging'); - }, - { passive: true } - ); - - sortableList.addEventListener( - 'touchmove', - function (e) { - if (!touchItem || !touchClone) return; - e.preventDefault(); - var y = e.touches[0].clientY; - touchClone.style.top = y - touchOffsetY + 'px'; - - var items = sortableList.querySelectorAll( - '.moonfin-sortable-item:not(.moonfin-sortable-dragging)' - ); - for (var i = 0; i < items.length; i++) { - var rect = items[i].getBoundingClientRect(); - var midY = rect.top + rect.height / 2; - if (y < midY) { - sortableList.insertBefore(touchItem, items[i]); - return; - } - } - sortableList.appendChild(touchItem); - }, - { passive: false } - ); - - sortableList.addEventListener( - 'touchend', - function () { - if (touchItem) { - touchItem.classList.remove('moonfin-sortable-dragging'); - touchItem = null; + sortableList.addEventListener('touchstart', function(e) { + var handle = e.target.closest('.moonfin-sortable-handle'); + if (!handle) return; + var item = handle.closest('.moonfin-sortable-item'); + if (!item) return; + touchItem = item; + var rect = item.getBoundingClientRect(); + touchOffsetY = e.touches[0].clientY - rect.top; + touchClone = item.cloneNode(true); + touchClone.className = 'moonfin-sortable-item moonfin-sortable-touch-clone'; + touchClone.style.width = rect.width + 'px'; + touchClone.style.top = rect.top + 'px'; + touchClone.style.left = rect.left + 'px'; + document.body.appendChild(touchClone); + item.classList.add('moonfin-sortable-dragging'); + }, { passive: true }); + + sortableList.addEventListener('touchmove', function(e) { + if (!touchItem || !touchClone) return; + e.preventDefault(); + var y = e.touches[0].clientY; + touchClone.style.top = (y - touchOffsetY) + 'px'; + + var items = sortableList.querySelectorAll('.moonfin-sortable-item:not(.moonfin-sortable-dragging)'); + for (var i = 0; i < items.length; i++) { + var rect = items[i].getBoundingClientRect(); + var midY = rect.top + rect.height / 2; + if (y < midY) { + sortableList.insertBefore(touchItem, items[i]); + return; } - if (touchClone && touchClone.parentNode) { - touchClone.parentNode.removeChild(touchClone); - touchClone = null; - } - saveSortableState(); - }, - { passive: true } - ); + } + sortableList.appendChild(touchItem); + }, { passive: false }); + + sortableList.addEventListener('touchend', function() { + if (touchItem) { + touchItem.classList.remove('moonfin-sortable-dragging'); + touchItem = null; + } + if (touchClone && touchClone.parentNode) { + touchClone.parentNode.removeChild(touchClone); + touchClone = null; + } + saveSortableState(); + }, { passive: true }); })(); } var loginBtn = this.dialog.querySelector('.moonfin-jellyseerr-settings-login-btn'); if (loginBtn) { - loginBtn.addEventListener('click', function () { + loginBtn.addEventListener('click', function() { self.handleJellyseerrLogin(); }); } var passwordInput = this.dialog.querySelector('#jellyseerr-settings-password'); if (passwordInput) { - passwordInput.addEventListener('keydown', function (e) { + passwordInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') { self.handleJellyseerrLogin(); } @@ -1400,7 +922,7 @@ var Settings = { var authTypeBtns = this.dialog.querySelectorAll('.moonfin-segmented-btn[data-auth-type]'); for (var ati = 0; ati < authTypeBtns.length; ati++) { - authTypeBtns[ati].addEventListener('click', function () { + authTypeBtns[ati].addEventListener('click', function() { var wrapper = self.dialog.querySelector('.moonfin-settings-jellyseerr-wrapper'); if (!wrapper) return; var btns = wrapper.querySelectorAll('.moonfin-segmented-btn[data-auth-type]'); @@ -1409,10 +931,9 @@ var Settings = { var isLocal = this.getAttribute('data-auth-type') === 'local'; var desc = wrapper.querySelector('.moonfin-jellyseerr-login-desc'); var usernameLabel = wrapper.querySelector('.moonfin-jellyseerr-username-label'); - if (desc) - desc.textContent = isLocal - ? 'Enter your local Jellyseerr account credentials. Your session is stored on the server so all devices stay signed in.' - : 'Enter your Jellyfin credentials to sign in to Jellyseerr. Your session is stored on the server so all devices stay signed in.'; + if (desc) desc.textContent = isLocal + ? 'Enter your local Jellyseerr account credentials. Your session is stored on the server so all devices stay signed in.' + : 'Enter your Jellyfin credentials to sign in to Jellyseerr. Your session is stored on the server so all devices stay signed in.'; if (usernameLabel) usernameLabel.textContent = isLocal ? 'Email' : 'Username'; var passwordField = wrapper.querySelector('#jellyseerr-settings-password'); if (passwordField) passwordField.placeholder = isLocal ? '' : 'Leave empty if no password'; @@ -1421,9 +942,9 @@ var Settings = { var logoutBtn = this.dialog.querySelector('.moonfin-jellyseerr-settings-logout-btn'); if (logoutBtn) { - logoutBtn.addEventListener('click', function () { + logoutBtn.addEventListener('click', function() { if (confirm('Sign out of Jellyseerr? You will need to sign in again to use it.')) { - Jellyseerr.ssoLogout().then(function () { + Jellyseerr.ssoLogout().then(function() { self.updateJellyseerrSsoSection(); self.showToast('Signed out of Jellyseerr'); }); @@ -1432,7 +953,7 @@ var Settings = { } }, - handleJellyseerrLogin: function () { + handleJellyseerrLogin: function() { var self = this; var wrapper = this.dialog ? this.dialog.querySelector('.moonfin-settings-jellyseerr-wrapper') : null; if (!wrapper) return; @@ -1463,7 +984,7 @@ var Settings = { submitBtn.textContent = 'Signing in...'; errorEl.style.display = 'none'; - Jellyseerr.ssoLogin(usernameVal, passwordVal, authType).then(function (result) { + Jellyseerr.ssoLogin(usernameVal, passwordVal, authType).then(function(result) { if (result.success) { self.updateJellyseerrSsoSection(); self.showToast('Signed in to Jellyseerr'); @@ -1474,5 +995,155 @@ var Settings = { submitBtn.textContent = 'Sign In'; } }); + }, + + loadCollectionPicker: function() { + var self = this; + var picker = this.dialog ? this.dialog.querySelector('#moonfin-collection-picker') : null; + if (!picker) return; + + picker.innerHTML = '
Loading...
'; + + API.getCollectionsAndPlaylists().then(function(items) { + if (!self.dialog) return; + if (!items || items.length === 0) { + picker.innerHTML = '
No collections or playlists found on this server.
'; + return; + } + + var settings = Storage.getAll(); + var selectedIds = settings.mediaBarCollectionIds || []; + var serverUrl = (window.ApiClient && window.ApiClient.serverAddress ? window.ApiClient.serverAddress() : '') || ''; + var html = ''; + + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var isChecked = selectedIds.indexOf(item.Id) !== -1; + var typeBadge = item.Type === 'BoxSet' ? 'Collection' : 'Playlist'; + var posterUrl = ''; + if (item.ImageTags && item.ImageTags.Primary) { + posterUrl = serverUrl + '/Items/' + item.Id + '/Images/Primary?maxWidth=60&quality=80&tag=' + item.ImageTags.Primary; + } + + html += + ''; + } + + picker.innerHTML = html; + + // Event delegation for checkboxes + picker.addEventListener('change', function(e) { + var cb = e.target; + if (!cb.dataset.collectionId) return; + + var currentIds = Storage.get('mediaBarCollectionIds') || []; + currentIds = currentIds.slice(); + var id = cb.dataset.collectionId; + var idx = currentIds.indexOf(id); + + if (cb.checked && idx === -1) { + currentIds.push(id); + } else if (!cb.checked && idx !== -1) { + currentIds.splice(idx, 1); + } + + self.saveSetting('mediaBarCollectionIds', currentIds); + + var label = cb.closest('.moonfin-collection-item'); + if (label) label.classList.toggle('moonfin-collection-item-active', cb.checked); + + self.showToast(cb.checked ? 'Added to media bar' : 'Removed from media bar'); + }); + }).catch(function(e) { + console.error('[Moonfin] Failed to load collection picker:', e); + picker.innerHTML = '
Failed to load collections.
'; + }); + }, + + loadLibraryPicker: function() { + var self = this; + var picker = this.dialog ? this.dialog.querySelector('#moonfin-library-picker') : null; + if (!picker) return; + + picker.innerHTML = '
Loading...
'; + + API.getUserViews().then(function(views) { + if (!self.dialog) return; + + // Filter to media libraries (Movies, TV Shows, Music, etc.) + var libraries = []; + for (var i = 0; i < views.length; i++) { + var ct = views[i].CollectionType; + if (ct === 'movies' || ct === 'tvshows' || ct === 'music' || ct === 'mixed') { + libraries.push(views[i]); + } + } + + if (libraries.length === 0) { + picker.innerHTML = '
No media libraries found.
'; + return; + } + + var settings = Storage.getAll(); + var selectedIds = settings.mediaBarLibraryIds || []; + var html = ''; + + for (var j = 0; j < libraries.length; j++) { + var lib = libraries[j]; + var isChecked = selectedIds.indexOf(lib.Id) !== -1; + var typeBadge = (lib.CollectionType || 'library').charAt(0).toUpperCase() + + (lib.CollectionType || 'library').slice(1); + + html += + ''; + } + + picker.innerHTML = html; + + // Event delegation for library checkboxes + picker.addEventListener('change', function(e) { + var cb = e.target; + if (!cb.dataset.libraryId) return; + + var currentIds = Storage.get('mediaBarLibraryIds') || []; + currentIds = currentIds.slice(); + var id = cb.dataset.libraryId; + var idx = currentIds.indexOf(id); + + if (cb.checked && idx === -1) { + currentIds.push(id); + } else if (!cb.checked && idx !== -1) { + currentIds.splice(idx, 1); + } + + self.saveSetting('mediaBarLibraryIds', currentIds); + + var label = cb.closest('.moonfin-collection-item'); + if (label) label.classList.toggle('moonfin-collection-item-active', cb.checked); + + self.showToast(cb.checked ? 'Library added' : 'Library removed'); + }); + }).catch(function(e) { + console.error('[Moonfin] Failed to load library picker:', e); + picker.innerHTML = '
Failed to load libraries.
'; + }); } }; diff --git a/frontend/src/styles/settings.css b/frontend/src/styles/settings.css index 6ad7107..cffe0eb 100644 --- a/frontend/src/styles/settings.css +++ b/frontend/src/styles/settings.css @@ -14,9 +14,7 @@ justify-content: flex-end; opacity: 0; visibility: hidden; - transition: - opacity 0.3s ease, - visibility 0.3s ease; + transition: opacity 0.3s ease, visibility 0.3s ease; } .moonfin-settings-dialog.open { @@ -90,9 +88,7 @@ align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.7); - transition: - background 0.2s ease, - color 0.2s ease; + transition: background 0.2s ease, color 0.2s ease; } .moonfin-settings-close:hover { @@ -119,9 +115,7 @@ font-weight: 500; cursor: pointer; border-radius: 6px 6px 0 0; - transition: - background 0.2s ease, - color 0.2s ease; + transition: background 0.2s ease, color 0.2s ease; text-align: center; white-space: nowrap; position: relative; @@ -192,7 +186,7 @@ user-select: none; } -.moonfin-sync-toggle-label input[type='checkbox'] { +.moonfin-sync-toggle-label input[type="checkbox"] { width: 14px; height: 14px; margin: 0; @@ -293,7 +287,7 @@ cursor: pointer; } -.moonfin-toggle-label input[type='checkbox'] { +.moonfin-toggle-label input[type="checkbox"] { width: 18px; height: 18px; accent-color: #00a4dc; @@ -546,14 +540,8 @@ } @keyframes moonfin-pulse { - 0%, - 100% { - opacity: 1; - } - - 50% { - opacity: 0.4; - } + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } } .moonfin-jellyseerr-sso-status { @@ -646,7 +634,6 @@ justify-content: flex-end; } } - /* Collection / Playlist Picker */ .moonfin-mediabar-library-options, .moonfin-mediabar-collection-options { diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index a370609..1a2b066 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -1,5 +1,5 @@ const API = { - toCamelCase: function (obj) { + toCamelCase: function(obj) { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj; var result = {}; var keys = Object.keys(obj); @@ -18,7 +18,7 @@ const API = { async getCurrentUser() { const api = this.getApiClient(); if (!api) return null; - + try { const user = await api.getCurrentUser(); return user; @@ -52,10 +52,10 @@ const API = { const headers = token ? { Authorization: 'MediaBrowser Token="' + token + '"' } : {}; const profileParam = profile || 'global'; - const response = await fetch(serverUrl + '/Moonfin/MediaBar?profile=' + encodeURIComponent(profileParam), { - method: 'GET', - headers: headers - }); + const response = await fetch( + serverUrl + '/Moonfin/MediaBar?profile=' + encodeURIComponent(profileParam), + { method: 'GET', headers: headers } + ); if (response.ok) { const data = await response.json(); @@ -430,22 +430,24 @@ const API = { // extract the chunk IDs from their compiled source, load those chunks, then find the // editor modules by their export shapes. - getServerId: function () { + getServerId: function() { var api = this.getApiClient(); if (!api) return null; try { if (api.serverInfo && typeof api.serverInfo === 'function') return api.serverInfo().Id; if (api._serverInfo) return api._serverInfo.Id; if (api.serverId && typeof api.serverId === 'function') return api.serverId(); - } catch (e) {} + } catch(e) {} return null; }, - _initWebpackRequire: function () { + _initWebpackRequire: function() { if (window.__moonfin_wp_require) return true; // Auto-detect: the compiled global varies by build config // Production jellyfin-web uses just 'webpackChunk' (no suffix) - var chunkArr = window.webpackChunk || window.webpackChunkjellyfin_web || window['webpackChunk_jellyfin_web']; + var chunkArr = window.webpackChunk + || window.webpackChunkjellyfin_web + || window['webpackChunk_jellyfin_web']; if (!chunkArr) { // Scan window for any webpackChunk* array var keys = Object.keys(window); @@ -460,17 +462,13 @@ const API = { console.warn('[Moonfin] No webpackChunk array found'); return false; } - chunkArr.push([ - ['moonfin'], - {}, - function (__webpack_require__) { - window.__moonfin_wp_require = __webpack_require__; - } - ]); + chunkArr.push([['moonfin'], {}, function(__webpack_require__) { + window.__moonfin_wp_require = __webpack_require__; + }]); return !!window.__moonfin_wp_require; }, - _extractChunkIds: function (factorySource) { + _extractChunkIds: function(factorySource) { var ids = []; // Match numeric chunk IDs: .e(12345) var regex = /\.e\((\d+)\)/g; @@ -487,15 +485,15 @@ const API = { return ids; }, - _loadChunks: function (req, chunkIds) { + _loadChunks: function(req, chunkIds) { var loads = []; for (var i = 0; i < chunkIds.length; i++) { - loads.push(req.e(chunkIds[i]).catch(function () {})); + loads.push(req.e(chunkIds[i]).catch(function() {})); } return Promise.all(loads); }, - _findFactoryByHint: function (req, hint) { + _findFactoryByHint: function(req, hint) { var factories = req.m || {}; var keys = Object.keys(factories); for (var i = 0; i < keys.length; i++) { @@ -503,12 +501,12 @@ const API = { if (factories[keys[i]].toString().indexOf(hint) !== -1) { return keys[i]; } - } catch (e) {} + } catch(e) {} } return null; }, - _findFactoryByHints: function (req, hints) { + _findFactoryByHints: function(req, hints) { var factories = req.m || {}; var keys = Object.keys(factories); for (var i = 0; i < keys.length; i++) { @@ -516,27 +514,24 @@ const API = { var src = factories[keys[i]].toString(); var allMatch = true; for (var h = 0; h < hints.length; h++) { - if (src.indexOf(hints[h]) === -1) { - allMatch = false; - break; - } + if (src.indexOf(hints[h]) === -1) { allMatch = false; break; } } if (allMatch) return keys[i]; - } catch (e) {} + } catch(e) {} } return null; }, _editorModulesPromise: null, - _loadEditorModules: function () { + _loadEditorModules: function() { if (this._editorModulesPromise) return this._editorModulesPromise; if (window.__moonfin_editors && Object.keys(window.__moonfin_editors).length >= 4) { return Promise.resolve(true); } var apiSelf = this; - this._editorModulesPromise = new Promise(function (resolve) { + this._editorModulesPromise = new Promise(function(resolve) { if (!apiSelf._initWebpackRequire()) { resolve(false); return; @@ -556,94 +551,75 @@ const API = { var shortcutChunkIds = apiSelf._extractChunkIds(req.m[shortcutsId].toString()); console.log('[Moonfin] Loading shortcuts chunks:', shortcutChunkIds); - apiSelf - ._loadChunks(req, shortcutChunkIds) - .then(function () { - // Step 3: Find itemContextMenu module by its unique command strings - var icmId = apiSelf._findFactoryByHints(req, ['editimages', 'editsubtitles', 'identify']); - if (icmId) { - // Step 4: Extract and load itemContextMenu's chunks (editor modules) - var icmChunkIds = apiSelf._extractChunkIds(req.m[icmId].toString()); - console.log('[Moonfin] Loading itemContextMenu chunks:', icmChunkIds); - return apiSelf._loadChunks(req, icmChunkIds); - } - }) - .then(function () { - // Step 5: All editor chunks loaded. Find editor modules by export shape. - var editors = {}; - var factories = req.m; - var cache = req.c || {}; - var fkeys = Object.keys(factories); - - // Source hints to narrow down which factories to try instantiating - var editorHints = [ - 'editItemMetadataForm', - 'MessageItemSaved', - 'imageType', - 'hasChanges', - 'subtitleList', - 'btnOpenUploadMenu', - 'showFindNew', - 'identifyResults' - ]; - - for (var i = 0; i < fkeys.length; i++) { - var id = fkeys[i]; - var mod; - - // Check cache first (no side effects) - if (cache[id]) { - mod = cache[id].exports; - } else { - // Only try modules whose factory contains editor-related strings - var src; - try { - src = factories[id].toString(); - } catch (e) { - continue; - } - var isEditor = false; - for (var h = 0; h < editorHints.length; h++) { - if (src.indexOf(editorHints[h]) !== -1) { - isEditor = true; - break; - } - } - if (!isEditor) continue; - - try { - mod = req(id); - } catch (e) { - continue; - } + apiSelf._loadChunks(req, shortcutChunkIds).then(function() { + // Step 3: Find itemContextMenu module by its unique command strings + var icmId = apiSelf._findFactoryByHints(req, ['editimages', 'editsubtitles', 'identify']); + if (icmId) { + // Step 4: Extract and load itemContextMenu's chunks (editor modules) + var icmChunkIds = apiSelf._extractChunkIds(req.m[icmId].toString()); + console.log('[Moonfin] Loading itemContextMenu chunks:', icmChunkIds); + return apiSelf._loadChunks(req, icmChunkIds); + } + }).then(function() { + // Step 5: All editor chunks loaded. Find editor modules by export shape. + var editors = {}; + var factories = req.m; + var cache = req.c || {}; + var fkeys = Object.keys(factories); + + // Source hints to narrow down which factories to try instantiating + var editorHints = [ + 'editItemMetadataForm', 'MessageItemSaved', + 'imageType', 'hasChanges', + 'subtitleList', 'btnOpenUploadMenu', + 'showFindNew', 'identifyResults' + ]; + + for (var i = 0; i < fkeys.length; i++) { + var id = fkeys[i]; + var mod; + + // Check cache first (no side effects) + if (cache[id]) { + mod = cache[id].exports; + } else { + // Only try modules whose factory contains editor-related strings + var src; + try { src = factories[id].toString(); } catch(e) { continue; } + var isEditor = false; + for (var h = 0; h < editorHints.length; h++) { + if (src.indexOf(editorHints[h]) !== -1) { isEditor = true; break; } } + if (!isEditor) continue; - if (!mod) continue; - apiSelf._matchEditorExports(mod, editors); - - if (editors.metadata && editors.identifier && editors.image && editors.subtitle) break; + try { mod = req(id); } catch(e) { continue; } } - window.__moonfin_editors = editors; - var found = Object.keys(editors); - console.log('[Moonfin] Found editor modules:', found.join(', ') || 'none'); - resolve(found.length > 0); - }) - .catch(function (e) { - console.error('[Moonfin] Error loading editor chunks:', e); - resolve(false); - }); + if (!mod) continue; + apiSelf._matchEditorExports(mod, editors); + + if (editors.metadata && editors.identifier && editors.image && editors.subtitle) break; + } + + window.__moonfin_editors = editors; + var found = Object.keys(editors); + console.log('[Moonfin] Found editor modules:', found.join(', ') || 'none'); + resolve(found.length > 0); + }).catch(function(e) { + console.error('[Moonfin] Error loading editor chunks:', e); + resolve(false); + }); }); // Allow retry on failure - this._editorModulesPromise.then(function (success) { + this._editorModulesPromise.then(function(success) { if (!success) apiSelf._editorModulesPromise = null; }); return this._editorModulesPromise; }, - _matchEditorExports: function (mod, editors) { + _matchEditorExports: function(mod, editors) { if (!mod) return; var d = mod.default || mod; @@ -660,84 +636,109 @@ const API = { } // imageeditor: named export show(options), length <= 1, no showFindNew/embed - if ( - !editors.image && - typeof mod.show === 'function' && - !mod.showFindNew && - !(d && typeof d.embed === 'function') && - mod.show.length <= 1 && - mod !== (editors.identifier || null) - ) { + if (!editors.image && typeof mod.show === 'function' && !mod.showFindNew && + !(d && typeof d.embed === 'function') && mod.show.length <= 1 && + mod !== (editors.identifier || null)) { editors.image = mod; return; } // subtitleeditor: default export with show(itemId, serverId), no embed - if (!editors.subtitle && d && d !== mod && typeof d.show === 'function' && !d.embed && d.show.length >= 2) { + if (!editors.subtitle && d && d !== mod && typeof d.show === 'function' && + !d.embed && d.show.length >= 2) { editors.subtitle = d; return; } }, - openMetadataEditor: function (itemId) { + openMetadataEditor: function(itemId) { var serverId = this.getServerId(); - return this._loadEditorModules() - .then(function (loaded) { - var ed = window.__moonfin_editors && window.__moonfin_editors.metadata; - if (!ed) return false; - ed.show(itemId, serverId); - return true; - }) - .catch(function (e) { - console.warn('[Moonfin] Failed to open metadata editor:', e); - return false; - }); + return this._loadEditorModules().then(function(loaded) { + var ed = window.__moonfin_editors && window.__moonfin_editors.metadata; + if (!ed) return false; + ed.show(itemId, serverId); + return true; + }).catch(function(e) { + console.warn('[Moonfin] Failed to open metadata editor:', e); + return false; + }); }, - openImageEditor: function (itemId) { + openImageEditor: function(itemId) { var serverId = this.getServerId(); - return this._loadEditorModules() - .then(function (loaded) { - var ed = window.__moonfin_editors && window.__moonfin_editors.image; - if (!ed) return false; - var showFn = ed.show || (ed.default && ed.default.show); - if (!showFn) return false; - showFn({ itemId: itemId, serverId: serverId }); - return true; - }) - .catch(function (e) { - console.warn('[Moonfin] Failed to open image editor:', e); - return false; - }); + return this._loadEditorModules().then(function(loaded) { + var ed = window.__moonfin_editors && window.__moonfin_editors.image; + if (!ed) return false; + var showFn = ed.show || (ed.default && ed.default.show); + if (!showFn) return false; + showFn({ itemId: itemId, serverId: serverId }); + return true; + }).catch(function(e) { + console.warn('[Moonfin] Failed to open image editor:', e); + return false; + }); }, - openSubtitleEditor: function (itemId) { + openSubtitleEditor: function(itemId) { var serverId = this.getServerId(); - return this._loadEditorModules() - .then(function (loaded) { - var ed = window.__moonfin_editors && window.__moonfin_editors.subtitle; - if (!ed) return false; - ed.show(itemId, serverId); - return true; - }) - .catch(function (e) { - console.warn('[Moonfin] Failed to open subtitle editor:', e); - return false; - }); + return this._loadEditorModules().then(function(loaded) { + var ed = window.__moonfin_editors && window.__moonfin_editors.subtitle; + if (!ed) return false; + ed.show(itemId, serverId); + return true; + }).catch(function(e) { + console.warn('[Moonfin] Failed to open subtitle editor:', e); + return false; + }); }, - openItemIdentifier: function (itemId) { + openItemIdentifier: function(itemId) { var serverId = this.getServerId(); - return this._loadEditorModules() - .then(function (loaded) { - var ed = window.__moonfin_editors && window.__moonfin_editors.identifier; - if (!ed) return false; - ed.show(itemId, serverId); - return true; - }) - .catch(function (e) { - console.warn('[Moonfin] Failed to open item identifier:', e); - return false; - }); + return this._loadEditorModules().then(function(loaded) { + var ed = window.__moonfin_editors && window.__moonfin_editors.identifier; + if (!ed) return false; + ed.show(itemId, serverId); + return true; + }).catch(function(e) { + console.warn('[Moonfin] Failed to open item identifier:', e); + return false; + }); + }, + + _playbackManager: null, + + getPlaybackManager: function() { + if (this._playbackManager) return this._playbackManager; + if (!this._initWebpackRequire()) return null; + + var req = window.__moonfin_wp_require; + var cache = req.c || {}; + var factories = req.m || {}; + var keys = Object.keys(factories); + + for (var i = 0; i < keys.length; i++) { + var id = keys[i]; + var mod; + + if (cache[id]) { + mod = cache[id].exports; + } else { + var src; + try { src = factories[id].toString(); } catch(e) { continue; } + if (src.indexOf('playRequestToPlayer') === -1) continue; + try { mod = req(id); } catch(e) { continue; } + } + + if (!mod) continue; + var pm = mod.playbackManager || (mod.default && mod.default.playbackManager); + if (!pm && mod.default && typeof mod.default.play === 'function' && typeof mod.default.stop === 'function' && typeof mod.default.seek === 'function') { + pm = mod.default; + } + if (pm && typeof pm.play === 'function') { + this._playbackManager = pm; + return pm; + } + } + return null; } }; diff --git a/frontend/src/utils/storage.js b/frontend/src/utils/storage.js index 09865cd..bc0904b 100644 --- a/frontend/src/utils/storage.js +++ b/frontend/src/utils/storage.js @@ -57,27 +57,27 @@ const Storage = { }, colorOptions: { - gray: { name: 'Gray', hex: '#808080' }, - black: { name: 'Black', hex: '#000000' }, - dark_blue: { name: 'Dark Blue', hex: '#1A2332' }, - purple: { name: 'Purple', hex: '#4A148C' }, - teal: { name: 'Teal', hex: '#00695C' }, - navy: { name: 'Navy', hex: '#0D1B2A' }, - charcoal: { name: 'Charcoal', hex: '#36454F' }, - brown: { name: 'Brown', hex: '#3E2723' }, - dark_red: { name: 'Dark Red', hex: '#8B0000' }, - dark_green: { name: 'Dark Green', hex: '#0B4F0F' }, - slate: { name: 'Slate', hex: '#475569' }, - indigo: { name: 'Indigo', hex: '#1E3A8A' } + 'gray': { name: 'Gray', hex: '#808080' }, + 'black': { name: 'Black', hex: '#000000' }, + 'dark_blue': { name: 'Dark Blue', hex: '#1A2332' }, + 'purple': { name: 'Purple', hex: '#4A148C' }, + 'teal': { name: 'Teal', hex: '#00695C' }, + 'navy': { name: 'Navy', hex: '#0D1B2A' }, + 'charcoal': { name: 'Charcoal', hex: '#36454F' }, + 'brown': { name: 'Brown', hex: '#3E2723' }, + 'dark_red': { name: 'Dark Red', hex: '#8B0000' }, + 'dark_green': { name: 'Dark Green', hex: '#0B4F0F' }, + 'slate': { name: 'Slate', hex: '#475569' }, + 'indigo': { name: 'Indigo', hex: '#1E3A8A' } }, seasonalOptions: { - none: { name: 'None' }, - winter: { name: 'Winter' }, - spring: { name: 'Spring' }, - summer: { name: 'Summer' }, - fall: { name: 'Fall' }, - halloween: { name: 'Halloween' } + 'none': { name: 'None' }, + 'winter': { name: 'Winter' }, + 'spring': { name: 'Spring' }, + 'summer': { name: 'Summer' }, + 'fall': { name: 'Fall' }, + 'halloween': { name: 'Halloween' } }, // ─── Profile Storage ──────────────────────────────────────────── @@ -142,7 +142,7 @@ const Storage = { resolveSettings(profileName) { const profiles = this.getProfiles(); const global = profiles.global || {}; - const device = profileName !== 'global' ? profiles[profileName] || {} : {}; + const device = (profileName !== 'global') ? (profiles[profileName] || {}) : {}; const adminDefaults = this.syncState.adminDefaults || {}; const resolved = {}; @@ -166,7 +166,7 @@ const Storage = { get(key, defaultValue = null) { const settings = this.getAll(); - return key in settings ? settings[key] : defaultValue !== null ? defaultValue : this.defaults[key]; + return key in settings ? settings[key] : (defaultValue !== null ? defaultValue : this.defaults[key]); }, set(key, value, profileName) { @@ -284,7 +284,7 @@ const Storage = { } catch (e) { console.log('[Moonfin] Server plugin not available:', e.message); } - + this.syncState.serverAvailable = false; return null; }, @@ -292,7 +292,7 @@ const Storage = { getAuthHeader() { const token = window.ApiClient?.accessToken?.(); if (token) { - return { Authorization: `MediaBrowser Token="${token}"` }; + return { 'Authorization': `MediaBrowser Token="${token}"` }; } return {}; }, @@ -324,7 +324,7 @@ const Storage = { console.error('[Moonfin] Failed to fetch from server:', e); this.syncState.lastSyncError = e.message; } - + return null; }, @@ -336,7 +336,7 @@ const Storage = { try { this.syncState.syncing = true; const serverUrl = window.ApiClient?.serverAddress?.() || ''; - + const envelope = this._mapEnvelopeToServer(profiles); envelope.syncEnabled = this.isSyncEnabled(); @@ -365,7 +365,7 @@ const Storage = { } finally { this.syncState.syncing = false; } - + return false; }, @@ -378,7 +378,7 @@ const Storage = { this.syncState.syncing = true; const serverUrl = window.ApiClient?.serverAddress?.() || ''; const serverProfile = this._mapProfileToServer(profileSettings); - + const response = await fetch(`${serverUrl}/Moonfin/Settings/Profile/${profileName}`, { method: 'POST', headers: { @@ -403,7 +403,7 @@ const Storage = { } finally { this.syncState.syncing = false; } - + return false; }, @@ -425,6 +425,7 @@ const Storage = { return false; }, + // ─── Server ↔ Local Mapping ───────────────────────────────────── _mapProfileFromServer(serverProfile) { @@ -630,8 +631,7 @@ const Storage = { return true; } if (typeof a === 'object' && typeof b === 'object') { - const ka = Object.keys(a), - kb = Object.keys(b); + const ka = Object.keys(a), kb = Object.keys(b); if (ka.length !== kb.length) return false; for (const k of ka) { if (!this._deepEqual(a[k], b[k])) return false; @@ -645,7 +645,7 @@ const Storage = { async sync(forceFromServer = false) { console.log('[Moonfin] Starting settings sync...' + (forceFromServer ? ' (server wins)' : '')); - + const pingResult = await this.pingServer(); if (!pingResult?.installed || !pingResult?.settingsSyncEnabled) { console.log('[Moonfin] Server sync not available'); @@ -670,10 +670,7 @@ const Storage = { merged = {}; const allNames = new Set([...Object.keys(localProfiles), ...Object.keys(serverProfiles)]); for (const name of allNames) { - merged[name] = { - ...(localProfiles[name] || {}), - ...(serverProfiles[name] || {}) - }; + merged[name] = { ...(localProfiles[name] || {}), ...(serverProfiles[name] || {}) }; } console.log('[Moonfin] Applied server profiles (manual sync)'); } else if (serverProfiles && hasLocalProfiles && snapshot) { @@ -684,10 +681,7 @@ const Storage = { merged = {}; const allNames = new Set([...Object.keys(serverProfiles), ...Object.keys(localProfiles)]); for (const name of allNames) { - merged[name] = { - ...(serverProfiles[name] || {}), - ...(localProfiles[name] || {}) - }; + merged[name] = { ...(serverProfiles[name] || {}), ...(localProfiles[name] || {}) }; } console.log('[Moonfin] First sync — local wins, pushed to server'); } else if (serverProfiles && !hasLocalProfiles) { From 0b0223de9424d4461f906809a78112152b152ce6 Mon Sep 17 00:00:00 2001 From: enyineer Date: Sun, 15 Mar 2026 02:10:32 +0100 Subject: [PATCH 07/11] Implement HTML escaping for displayed names in frontend and config pages, filter backend item processing to movies and series, and ensure the mediabar is hidden when not on the home page. Signed-off-by: enyineer --- backend/Api/MoonfinController.cs | 10 ++++++---- backend/Pages/configPage.html | 6 ++++-- frontend/src/components/mediabar.js | 2 ++ frontend/src/components/settings.js | 6 ++++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/backend/Api/MoonfinController.cs b/backend/Api/MoonfinController.cs index 966fd32..4ba0d7c 100644 --- a/backend/Api/MoonfinController.cs +++ b/backend/Api/MoonfinController.cs @@ -598,10 +598,12 @@ private List GetCollectionItems(List collectionIds, int limit, { if (!linkedChild.ItemId.HasValue) continue; var item = _libraryManager.GetItemById(linkedChild.ItemId.Value); - if (item != null && seenIds.Add(item.Id)) - { - allItems.Add(item); - } + if (item == null || !seenIds.Add(item.Id)) continue; + + var kind = item.GetBaseItemKind(); + if (kind != BaseItemKind.Movie && kind != BaseItemKind.Series) continue; + + allItems.Add(item); } } diff --git a/backend/Pages/configPage.html b/backend/Pages/configPage.html index d068092..d6a324d 100644 --- a/backend/Pages/configPage.html +++ b/backend/Pages/configPage.html @@ -268,6 +268,8 @@

Web Plugin Status