diff --git a/README.md b/README.md index 8781833..5cc894f 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,15 @@ I think it's also worth mentioning that before the 1.0 release, you can expect t - [Configuration](#configuration) - [Features Overview](#features-overview) - [Data Caching](#data-caching) - - [Feature Requests Completed](#feature-requests-completed) - - [Feature Requests Planned](#feature-requests-planned) + - [Community Requests Progress](#community-request-progress) + - [Feature Requests Completed](#feature-requests-completed) + - [Feature Requests Planned](#feature-requests-planned) - [Core Features](#core-features) - [Watchlist](#watchlist) + - Watchlist Page + - Series Progress Page + - Movie History Page + - User Statistics Page - [Enhanced Home Screen](#enhanced-home-screen) - [Enhanced Search](#enhanced-search) - [UI Enhancements](#ui-enhancements) @@ -42,7 +47,8 @@ I think it's also worth mentioning that before the 1.0 release, you can expect t - [Script Details](#script-details) - [Dependency Scripts](#dependency-scripts) - [Feature Scripts](#feature-scripts) -- [Auto-Inject Dependencies](#auto-inject-dependencies) + - [Auto-Inject Dependencies](#auto-inject-dependencies) + - [Info For Developers](#info-for-developers) - [License](#license) - [Acknowledgments](#acknowledgments) - [Support](#support) @@ -153,6 +159,9 @@ The features in KefinTweaks which use local data caching are listed below, along - Collections on Details Page - **Collections** [24h]: Sadly I know no way to retreive a list of Collections that an Item is a child of from the API directly. The Ancestors endpoint only returns the physical ancestors of an item. In order to be able to populate the "Included In" section, we fetch the children from every Collection in your library and add the Item ID of each child to the cache. +
+ +## Community Request Progress ### ✅ **Feature Requests Completed** @@ -445,7 +454,7 @@ Coming soon...requires the [Updoot](https://github.com/BobHasNoSoul/jellyfin-upd | `deviceManager.js` | Device management with remove device functionality | `utils` |
-## Auto-Inject Dependencies +### Auto-Inject Dependencies KefinTweaks automatically enables required dependencies when you enable a script that needs them. For example: @@ -454,17 +463,108 @@ KefinTweaks automatically enables required dependencies when you enable a script This ensures all scripts have their required dependencies without manual configuration.
+### Info for Developers + +This section will detail any information relevant for other developers who wish to take advatange of KefinTweaks features in their own projects: + +Currently the only functionality that can be very easily included by developers of other projects is the Watchlist functionality. + +The KefinTweaks Watchlist leverages the existing Jellyfin Item UserData field: "Likes". Here is how KefinTweaks handles the funcionality and how you can as well: + +- Add a section (on the home screen or elsewhere) which queries the Items endpoint with Likes=True +- Add a Watchlist toggle button to all card overlays which toggles the UserData Likes value +- Add a listener for PlaybackStopped to check if a Watchlisted item has been watched and automatically remove it from the Watchlist + +Even if you only implement the first bullet point and add a section for Watchlist items, KefinTweaks will automatically handle Watchlist syncing whenever the user connects with a KefinTweaks supported client. + +## Client Support + +KefinTweaks is a front end plugin that relies on the Jellyfin Web UI to function. This means that it will function wherever the Jellyfin Web UI is used, and won't function in any apps that don't use the Web UI. + +Here is a list of known supported clients: + +- Jellyfin Web - Works on any web browser +- [Jellyfin Media Player](https://github.com/jellyfin/jellyfin-media-player) +- [Jellyfin for Android](https://github.com/jellyfin/jellyfin-android) +- [Jellyfin for iOS](https://github.com/jellyfin/jellyfin-ios) +- [Jellyfin for WebOS](https://github.com/jellyfin/jellyfin-webos) +- [Jellyfin for Tizen](https://github.com/jellyfin/jellyfin-tizen) +
+ +## Changes to default Jellyfin functionality + +A few things to note about KefinTweaks functionality in general and how it interacts with the default Jellyfin functionality: + +### What functionality is additive? + +- Watchlist +- Series Progress +- Movie History +- User Statistics +- Enhanced Home Screen +- Appearance/Skin Manager +- Subtitle Search in Video OSD +- Remove from Continue Watching +- Breadcrumb Navigation +- Custom Menu Links +- Collections on Details Page + +## What functionality is modified? + +- Infinite Scroll for Library Pages + - Pagination controls are hidden in favor of infinite scrolling +- Playlist Page UX + - Clicking playlist items navigates to the item page instead of playing the item + - A play button is added to each item on the Playlist page to allow the user to play the item directly +- Flatten Single Season Shows + - The normal Seasons section is hidden for shows with only 1 season + - Episodes from the single season are displayed in a section with the Next Up item being the one focused +- Background Leak Fix + - This removes extra backdrops from the DOM + - It fixes an issue which causes Jellyfin to endless add backdrop items to the DOM if the Jellyfin browser tab is not focused + + +### What functionality is overridden? + +- Search + - The default Jellyfin search is hidden in favor of the KefinTweaks search + - KefinTweaks blocks default Jellyfin search requests, and only performs its own search requests +- Dashboard Button Fix + - This fix prevents the Back button in the Dashboad from navigating back to the "new tab" page in your browser +
+ +## Design Philosophy + +The general design philosophy here is to utilize as much pre-existing Jellyfin functionality as possible in order to ensure long term compatibility, and also to potentially make it a bit easier for anyone interested in taking these ideas into the core Jellyfin projects. Even where functionality is being "overridden" we still typically utilize underlying Jellyfin functionality to achieve the results desired. + +The only real Plugin dependency that KefinTweaks has is JS Injector which is being used to both load the KefinTweaks scripts, and save configuration data on the server. Loading the scripts without JS Injector is very simple, but without another way of storing configuration data on the server, JS Injector is absolutely required if you wish to override any of the default configuration options in the UI. + +The main goal of this project is to push the limits of what is possible through a "front end plugin". All of the code in this project is executed by the client. This is quite unusual in terms of the typical Jellyfin plugin, but I hope it may be inspiring for other future creations down the road. I would also be very happy if the changes included here helped push forward the progress to get these changes implemented in the core Jellyfin project so that everyone can take advantage of them without needing a third party plugin. +
+ +## AI Disclaimer + +To preface this, I want to say that historically, all of the community oriented projects I have worked on in the past have been composed nearly entirely by code that I have written myself. As new development tools have been released, I have tried to take advantage of them as much as possible in order to improve my own learning and to make my projects better. I understand that everyone has different personal feelings regarding the use of AI, so I wanted to leave a statement to make it clear about how I have chosen to use AI for this project in particular. + +I have started this project with a polar opposite approach from my past endeavors. With the rise of "vibe coding" I wanted to see what I could achieve using a similar philosophy. I understand the obvious short comings of using such a methodology, and I believe that in the majority of the situations it will be very detrimental in the long run. Where I can see this type of tool excelling, is the exact use case of KefinTweaks: mostly small self contained scripts which don't rely on any external pieces of logic. This is one of the main motivating factors that led me to believe I could achieve what I desire purely through "vibe coding". + +As it stands right now, and how I expect to proceed going forward, is to act in a role of "Project Manager" instead of "coder/developer". I don't blindly include every bit of code that is provided by my prompts. I make very thorough prompts that essentially provide very detailed documentation of the features to be implemented, in the same way a real project manager might provide this documentation to their team. I am not doing this for practice as I no longer professionally work in the design industry and have no plans to, but moreso just for fun and to see what the results are. + +The journey so far has been quite enjoyable. The scale of KefinTweaks has expanded a lot over time, so I do certainly have my concerns about the sustainability of this approach, but I plan to continue on like this unless I hit a roadblock. I hope that this choice will not deter you from trying this plugin, but I did want to make the use of AI clear as I understand that it is a hot issue in the industry and around the globe. My personal view is that AI should be used responsibly as a tool, just like any other tool. I think that AI has no place in replacing artists and other creative work that humans carry out. + + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ## Acknowledgments -- **Jellyfin Team**: For the amazing media server platform +- **Jellyfin Team**: For the fantastic Jellyfin media server - **[n00bcodr](https://github.com/n00bcodr)**: For JellyfinEnhanced, the JS Injector Plugin and moral support - **[IAmParadox27](https://github.com/IAmParadox27)**: For the Custom Tabs Plugin - **[BobHasNoSoul](https://github.com/BobHasNoSoul)**: For the jellyfin-updoot functionality -- **[The Jellyfin Community](https://discord.gg/v7P9CAvCKZhttps://discord.gg/v7P9CAvCKZ)**: For sharing knowledge and providing a welcoming enviornment to ask questions and learn +- **[The Jellyfin Community](https://discord.gg/v7P9CAvCKZhttps://discord.gg/v7P9CAvCKZ)**: For sharing knowledge and providing a welcoming environment to ask questions and learn +- **[movie-monk-b0t](https://github.com/movie-monk-b0t)**: For the IMDb Top 250 JSON data ## Support @@ -478,11 +578,13 @@ This README and the inline comments in kefinTweaks.js and the other scripts is t ## Roadmap ### Planned Features -- **Jellyfin 10.11 Support**: +- **Jellyfin 10.11 Support** +- **Additional options for User configuration in the Jellyfin UI**
### Version History +- **v0.3.1**: New Home Screen Section feature: Spotlights! Improved Configuration UI. - **v0.3.0**: KefinTweaks configuration for admins in the Jellyfin UI! - **v0.2.4**: New Feature: Watchlist Export/Import - **v0.2.3**: New Feature: Collection Sorting, general fixes diff --git a/injector.js b/injector.js index 8f3a4de..04f24d8 100644 --- a/injector.js +++ b/injector.js @@ -6,7 +6,7 @@ 'use strict'; console.log('[KefinTweaks Injector] Initializing...'); - const versionNumber = "0.3.0"; + const versionNumber = "0.3.1"; // Configuration: Start with defaults, then merge user config // This allows new scripts to work out of the box without requiring config updates @@ -62,10 +62,17 @@ description: 'Adds skin selection dropdown to display preferences page and manages skin CSS loading' }, { - name: 'cardBuilder', - script: 'cardBuilder.js', + name: 'apiHelper', + script: 'apiHelper.js', css: null, dependencies: [], + description: 'API helper functions for common Jellyfin operations' + }, + { + name: 'cardBuilder', + script: 'cardBuilder.js', + css: 'cardBuilder.css', + dependencies: ['apiHelper'], description: 'Core card building functionality (required by other scripts)' }, { @@ -261,21 +268,39 @@ document.head.appendChild(style); } - // Auto-enable dependencies for enabled scripts + // Auto-enable dependencies for enabled scripts (iteratively to handle transitive dependencies) function autoEnableDependencies() { let hasChanges = false; + let maxIterations = 10; // Prevent infinite loops + let iterations = 0; - SCRIPT_DEFINITIONS.forEach(script => { - if (ENABLED_SCRIPTS[script.name]) { - script.dependencies.forEach(dep => { - if (!ENABLED_SCRIPTS[dep]) { - ENABLED_SCRIPTS[dep] = true; - hasChanges = true; - console.log(`[KefinTweaks Injector] Auto-enabled dependency '${dep}' for '${script.name}'`); - } - }); + // Keep running until no more dependencies need to be enabled + while (iterations < maxIterations) { + let iterationChanges = false; + + SCRIPT_DEFINITIONS.forEach(script => { + if (ENABLED_SCRIPTS[script.name]) { + script.dependencies.forEach(dep => { + if (!ENABLED_SCRIPTS[dep]) { + ENABLED_SCRIPTS[dep] = true; + iterationChanges = true; + hasChanges = true; + console.log(`[KefinTweaks Injector] Auto-enabled dependency '${dep}' for '${script.name}'`); + } + }); + } + }); + + if (!iterationChanges) { + break; // No more dependencies to enable } - }); + + iterations++; + } + + if (iterations >= maxIterations) { + console.warn('[KefinTweaks Injector] Reached max iterations while auto-enabling dependencies'); + } return hasChanges; } diff --git a/kefinTweaks-default-config.js b/kefinTweaks-default-config.js index 4f868ae..2c8903a 100644 --- a/kefinTweaks-default-config.js +++ b/kefinTweaks-default-config.js @@ -21,18 +21,349 @@ window.KefinTweaksDefaultConfig = { "skinManager": true }, "homeScreen": { - "enableNewAndTrending": true, - "enableNewMovies": true, - "enableNewEpisodes": true, - "enableTrending": false, - "enableDiscovery": true, - "enableInfiniteScroll": true, - "minPeopleAppearances": 10, - "minGenreMovieCount": 50, - "minimumShowsForNetwork": 5, - "enableWatchlist": true, - "enableSeasonal": true, - "seasonalItemLimit": 16, + "defaultItemLimit": 16, + "defaultSortOrder": "Random", + "defaultCardFormat": "Poster", + "recentlyReleased": { + "enabled": true, + "order": 30, + "movies": { + "name": "Recently Released Movies", + "enabled": true, + "itemLimit": 16, + "sortOrder": "ReleaseDate", + "sortOrderDirection": "Descending", + "cardFormat": "Poster", + "order": 30 + }, + "episodes": { + "name": "Recently Aired Episodes", + "enabled": true, + "itemLimit": 16, + "sortOrder": "PremiereDate", + "sortOrderDirection": "Descending", + "cardFormat": "Backdrop", + "order": 31 + } + }, + "trending": { + "name": "Trending", + "enabled": false, + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 32 + }, + "popularTVNetworks": { + "name": "Popular TV Networks", + "enabled": true, + "minimumShowsForNetwork": 5, + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 61 + }, + "watchlist": { + "name": "Watchlist", + "enabled": true, + "itemLimit": 16, + "sortOrder": "DateAdded", + "sortOrderDirection": "Descending", + "cardFormat": "Poster", + "order": 60 + }, + "watchAgain": { + "name": "Watch Again", + "enabled": true, + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 62 + }, + "upcoming": { + "name": "Upcoming", + "enabled": true, + "itemLimit": 48, + "cardFormat": "Thumb", + "order": 20 + }, + "imdbTop250": { + "name": "IMDb Top 250", + "enabled": true, + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 21 + }, + "seasonal": { + "enabled": true, + "defaultItemLimit": 16, + "defaultSortOrder": "Random", + "defaultCardFormat": "Poster", + "seasons": [ + { + "id": "halloween", + "name": "Halloween", + "enabled": true, + "startDate": "10-01", + "endDate": "10-31", + "sections": [ + { + "id": "halloween-tag", + "enabled": true, + "name": "Halloween Movies", + "type": "Tag", + "source": "halloween", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 50 + }, + { + "id": "halloween-horror", + "enabled": true, + "name": "Horror Genre", + "type": "Genre", + "source": "Horror", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 51 + }, + { + "id": "halloween-thriller", + "enabled": true, + "name": "Thriller Genre", + "type": "Genre", + "source": "Thriller", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 52 + } + ] + }, + { + "id": "thanksgiving", + "name": "Thanksgiving", + "enabled": true, + "startDate": "11-20", + "endDate": "11-30", + "order": 50, + "sections": [ + { + "id": "seasonal-2-section-0", + "enabled": true, + "name": "Thanksgiving Movies", + "type": "Tag", + "source": "thanksgiving", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 50, + "spotlight": true, + "discoveryEnabled": false, + "searchTerm": "", + "includeItemTypes": [ + "Movie" + ] + }, + { + "id": "seasonal-2-section-1", + "enabled": true, + "name": "Thanksgiving Episodes", + "type": "Parent", + "source": "", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Thumb", + "order": 51, + "spotlight": false, + "discoveryEnabled": false, + "searchTerm": "thanksgiving", + "includeItemTypes": [ + "Episode" + ] + } + ] + }, + { + "id": "christmas", + "name": "Christmas", + "enabled": true, + "startDate": "12-01", + "endDate": "12-31", + "sections": [ + { + "id": "christmas-genre", + "enabled": true, + "name": "Christmas Movies", + "type": "Genre", + "source": "Christmas", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 60 + }, + { + "id": "christmas-family", + "enabled": true, + "name": "Family Movies", + "type": "Genre", + "source": "Family", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 61 + } + ] + } + ] + }, + "discovery": { + "enabled": true, + "infiniteScroll": true, + "minPeopleAppearances": 10, + "minGenreMovieCount": 50, + "defaultItemLimit": 16, + "defaultSortOrder": "Random", + "defaultCardFormat": "Poster", + "sectionTypes": { + "spotlightGenre": { + "enabled": true, + "name": "Top Rated [Genre] Movies", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "spotlightNetwork": { + "enabled": true, + "name": "Top Rated Shows from [Studio]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "genreMovies": { + "enabled": true, + "name": "[Genre] Movies", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "studioShows": { + "enabled": true, + "name": "Shows from [Studio]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "collections": { + "enabled": true, + "name": "[Collection Name]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "minimumItems": 10 + }, + "becauseYouWatched": { + "enabled": true, + "name": "Because you watched [Movie]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "becauseYouLiked": { + "enabled": true, + "name": "Because you liked [Movie]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "starringTopActor": { + "enabled": true, + "name": "Starring [Actor]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "directedByTopDirector": { + "enabled": true, + "name": "Directed by [Director]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "writtenByTopWriter": { + "enabled": true, + "name": "Written by [Writer]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "becauseYouRecentlyWatched": { + "enabled": true, + "name": "Because you recently watched [Movie]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "starringActorRecentlyWatched": { + "enabled": true, + "name": "Starring [Actor] because you recently watched [Movie]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "directedByDirectorRecentlyWatched": { + "enabled": true, + "name": "Directed by [Director] because you recently watched [Movie]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "writtenByWriterRecentlyWatched": { + "enabled": true, + "name": "Written by [Writer] because you recently watched [Movie]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + } + }, + "spotlightDiscoveryChance": 0.5, + "spotlight": { + "autoPlay": true, + "interval": 5000, + "showDots": true, + "showNavButtons": true, + "showClearArt": true + }, + "randomizeOrder": false + }, "customSections": [] }, "exclusiveElsewhere": { diff --git a/pages/videos/home-screen-example.mp4 b/pages/videos/home-screen-example.mp4 new file mode 100644 index 0000000..a089e48 Binary files /dev/null and b/pages/videos/home-screen-example.mp4 differ diff --git a/scripts/apiHelper.js b/scripts/apiHelper.js new file mode 100644 index 0000000..3a4099b --- /dev/null +++ b/scripts/apiHelper.js @@ -0,0 +1,421 @@ +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks APIHelper]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks APIHelper]', ...args); + const ERR = (...args) => console.error('[KefinTweaks APIHelper]', ...args); + + /** + * Ensure ApiClient is available before using helper methods + */ + function ensureApiClient() { + if (typeof ApiClient === 'undefined' || !ApiClient) { + throw new Error('ApiClient is not available'); + } + } + + /** + * Retrieves the active playback session for this device + * @returns {Promise} - Active session object + */ + async function getActiveSession() { + ensureApiClient(); + + const token = ApiClient.accessToken(); + const serverUrl = ApiClient.serverAddress(); + const deviceId = ApiClient.deviceId(); + + if (!deviceId) { + throw new Error('Device ID not available'); + } + + if (!serverUrl) { + throw new Error('Server URL not available'); + } + + LOG('Fetching active session for device:', deviceId); + + const response = await fetch(`${serverUrl}/Sessions?deviceId=${deviceId}`, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Authorization': `MediaBrowser Token="${token}"` + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const sessions = await response.json(); + + if (Array.isArray(sessions) && sessions.length > 0) { + return sessions[0]; + } + + if (sessions && !Array.isArray(sessions)) { + return sessions; + } + + throw new Error('No active session found for device'); + } + + /** + * Sends a PlayNow command directly to the active session + * @param {Array} ids - Item IDs to play + * @param {Object} options - Additional playback options + */ + async function sendPlayNow(ids, options = {}) { + const session = await getActiveSession(); + const sessionId = session.Id; + + const token = ApiClient.accessToken(); + const serverUrl = ApiClient.serverAddress(); + + const params = new URLSearchParams({ + playCommand: options.playCommand || 'PlayNow', + itemIds: ids.join(',') + }); + + if (options.startPositionTicks !== undefined) { + params.set('startPositionTicks', options.startPositionTicks.toString()); + } + if (options.subtitleStreamIndex !== undefined) { + params.set('subtitleStreamIndex', options.subtitleStreamIndex.toString()); + } + if (options.audioStreamIndex !== undefined) { + params.set('audioStreamIndex', options.audioStreamIndex.toString()); + } + if (options.mediaSourceId) { + params.set('mediaSourceId', options.mediaSourceId); + } + + LOG('Sending PlayNow command:', { + sessionId, + params: params.toString() + }); + + const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, { + method: 'POST', + headers: { + 'X-Emby-Token': token + } + }); + + if (!response.ok) { + throw new Error(`PlayNow request failed with HTTP ${response.status}: ${response.statusText}`); + } + } + + /** + * Query cache for API responses + * Key: stringified options object + * Value: { data: response, timestamp: Date.now() } + */ + const queryCache = new Map(); + + /** + * Generate a cache key from options object + * @param {Object} options - Query options + * @returns {string} - Cache key + */ + function getCacheKey(options) { + // Sort keys to ensure consistent cache keys regardless of property order + const sorted = Object.keys(options).sort().reduce((acc, key) => { + acc[key] = options[key]; + return acc; + }, {}); + return JSON.stringify(sorted); + } + + /** + * Get cached query result if valid + * @param {string} cacheKey - Cache key + * @param {number} ttl - Time to live in milliseconds + * @returns {Object|null} - Cached data or null if expired/missing + */ + function getCachedQuery(cacheKey, ttl) { + const entry = queryCache.get(cacheKey); + if (!entry) return null; + + const age = Date.now() - entry.timestamp; + if (age > ttl) { + queryCache.delete(cacheKey); + return null; + } + return entry.data; + } + + /** + * Store query result in cache + * @param {string} cacheKey - Cache key + * @param {Object} data - Response data to cache + */ + function setCachedQuery(cacheKey, data) { + queryCache.set(cacheKey, { + data: data, + timestamp: Date.now() + }); + } + + /** + * Build URL from options for getItems + * @param {string} userId - User ID + * @param {Object} options - Query options + * @returns {string} - Constructed URL + */ + function buildItemsUrl(userId, options) { + const serverAddress = ApiClient.serverAddress(); + const params = new URLSearchParams(); + + // Add all options as query parameters + Object.keys(options).forEach(key => { + const value = options[key]; + if (value !== undefined && value !== null) { + params.append(key, value); + } + }); + + return `${serverAddress}/Users/${userId}/Items?${params.toString()}`; + } + + /** + * API Helper functions for Jellyfin operations + */ + const apiHelper = { + /** + * Generic data fetcher with universal caching + * Always caches successful responses, but only checks cache when useCache is true + * @param {string} url - Full URL to fetch + * @param {boolean} useCache - Whether to use cache (skip fetch if valid cache exists, default: false) + * @param {number} ttl - Cache time to live in milliseconds (default: 300000 = 5 minutes) + * @returns {Promise} - Parsed JSON response + */ + getData: async function(url, useCache = false, ttl = 300000) { + ensureApiClient(); + + const cacheKey = url; + + // If useCache is true, check cache first + if (useCache) { + const cached = getCachedQuery(cacheKey, ttl); + if (cached !== null) { + LOG('Cache hit for URL:', url.substring(0, 100)); + return cached; + } + } + + // Fetch from API + try { + const token = ApiClient.accessToken(); + const response = await fetch(url, { + headers: { + 'X-Emby-Token': token, + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + // Always cache successful responses (regardless of useCache flag) + if (data) { + setCachedQuery(cacheKey, data); + LOG('Cached data for URL:', url.substring(0, 100)); + } + + return data; + } catch (error) { + ERR('Failed to fetch URL:', error); + throw error; + } + }, + + getWatchlistItems: async function(options = {}, useCache = false) { + // First fetch the Library Items that contain items of the supported types from ApiClient.getJSON(ApiClient.getUrl("Library/MediaFolders")) + const libraryItems = await ApiClient.getJSON(ApiClient.getUrl("Library/MediaFolders")); + + // We now want to include the library items with ColletionType "tvshows", or "movies" + const supportedLibraryItems = libraryItems.Items.filter(item => item.CollectionType === 'tvshows' || item.CollectionType === 'movies'); + + // Now use these support library items as the parent ids for the Items query, since the Items endpoint doesn't support ParentIds as an array we need to make a different call for each library. Querying with the parent id will dramatically reduce the time for the request to return so this is fine + let watchlistItems = []; + for (const libraryItem of supportedLibraryItems.reverse()) { + // Get the item types to include based on the library type. if its a Movies library only use the Movie type. If it's tvshows then use Series, Season and Episode + let itemTypes = options.IncludeItemTypes.split(','); + if (itemTypes.length > 1) { + if (libraryItem.CollectionType === 'movies') { + itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Movie'); + } else if (libraryItem.CollectionType === 'tvshows') { + itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Series' || item === 'Season' || item === 'Episode'); + } + } + + // Use getItems to fetch the items for the library item + const data = await this.getItems({ + ParentId: libraryItem.Id, + Filters: 'Likes', + IncludeItemTypes: itemTypes.join(','), + Recursive: true, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', + ...options + }, useCache); + + //const url = `${ApiClient.serverAddress()}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${ApiClient.getCurrentUserId()}&Recursive=true&ImageTypeLimit=1&EnableImageTypes=Primary,Backdrop,Thumb&ParentId=${libraryItem.Id}`; + //const data = await this.getData(url, useCache); + watchlistItems.push(...data.Items); + } + + // Deuplicate any duplicate Id's in the watchlistItems array + watchlistItems = watchlistItems.filter((item, index, self) => + index === self.findIndex((t) => t.Id === item.Id) + ); + + return { + Items: watchlistItems, + TotalRecordCount: watchlistItems.length + }; + }, + + /** + * Get items from Jellyfin API with optional caching + * @param {Object} options - Query options (same as ApiClient.getItems) + * @param {boolean} useCache - Whether to use cache (default: false) + * @param {number} ttl - Cache time to live in milliseconds (default: 300000 = 5 minutes) + * @returns {Promise} - API response + */ + getItems: async function(options = {}, useCache = false, ttl = 300000) { + ensureApiClient(); + + const userId = ApiClient.getCurrentUserId(); + const url = buildItemsUrl(userId, options); + + // Use getData which handles caching + return await this.getData(url, useCache, ttl); + }, + + /** + * Fetch a URL with optional caching (deprecated - use getData instead) + * @param {string} url - URL to fetch + * @param {Object} fetchOptions - Fetch options (headers, method, etc.) + * @param {boolean} useCache - Whether to use cache (default: false) + * @param {number} ttl - Cache time to live in milliseconds (default: 300000 = 5 minutes) + * @returns {Promise} - Parsed JSON response + * @deprecated Use getData instead. This method is kept for backward compatibility. + */ + fetchCached: async function(url, fetchOptions = {}, useCache = false, ttl = 300000) { + ensureApiClient(); + + const cacheKey = getCacheKey({ url, fetchOptions }); + + // If useCache is true, check cache first + if (useCache) { + const cached = getCachedQuery(cacheKey, ttl); + if (cached !== null) { + LOG('Cache hit for URL:', url.substring(0, 100)); + return cached; + } + } + + // Fetch from API + try { + const response = await fetch(url, fetchOptions); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const data = await response.json(); + + // Always cache successful responses + if (data) { + setCachedQuery(cacheKey, data); + LOG('Cached fetch result:', url.substring(0, 100)); + } + + return data; + } catch (error) { + ERR('Failed to fetch URL:', error); + throw error; + } + }, + + /** + * Sets user rating for an item + * @param {string} itemId - The item ID + * @param {number} rating - The rating value + * @returns {Promise} - Promise that resolves when rating is set + */ + setUserRating: async function(itemId, rating) { + ensureApiClient(); + + const userId = ApiClient.getCurrentUserId(); + return fetch(`${ApiClient._serverAddress}/Users/${userId}/Items/${itemId}/UserData`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Emby-Token': ApiClient.accessToken() + }, + body: JSON.stringify({ + Rating: rating, + Likes: false + }) + }) + .then(r => r.ok ? r.json() : r.text().then(t => Promise.reject(t))) + .then(response => { + LOG('Rating updated for item:', itemId, response); + return response; + }) + .catch(error => { + ERR('Failed to set rating:', error); + throw error; + }); + }, + + /** + * Plays an item using Jellyfin's playback system + * @param {string|Array} itemId - Single item ID or array of item IDs to play + * @param {Object} options - Additional playback options + * @returns {Promise} - Promise that resolves when play command is sent + */ + playItem: async function(itemId, options = {}) { + const ids = Array.isArray(itemId) ? itemId : [itemId]; + + try { + ensureApiClient(); + await sendPlayNow(ids, options); + return; + } catch (error) { + WARN('PlayNow command failed, attempting fallbacks:', error); + } + + try { + if (typeof ApiClient !== 'undefined' && typeof ApiClient.play === 'function') { + await ApiClient.play({ ids: ids }); + return; + } + } catch (fallbackError) { + WARN('ApiClient.play fallback failed:', fallbackError); + } + + if (typeof PlaybackManager !== 'undefined' && PlaybackManager.play) { + await PlaybackManager.play({ + ids: ids, + serverId: ApiClient.serverId() + }); + return; + } + + // Last resort: Navigate to item details page + const itemUrl = `/web/#/details?id=${ids[0]}&serverId=${ApiClient.serverId()}`; + window.location.href = `${ApiClient.serverAddress()}${itemUrl}`; + } + }; + + // Expose apiHelper to global window object + window.apiHelper = apiHelper; + + console.log('[KefinTweaks APIHelper] Module loaded and available at window.apiHelper'); +})(); \ No newline at end of file diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css new file mode 100644 index 0000000..7128ac8 --- /dev/null +++ b/scripts/cardBuilder.css @@ -0,0 +1,393 @@ +/* Spotlight Section Styles */ + +.spotlight-section { + position: relative; + width: calc(100% - (2 * max(env(safe-area-inset-left), 3.3%))); + margin-bottom: 2em; +} + +.spotlight-banner-container { + position: relative; + width: 100%; + height: 450px; + overflow: hidden; + border-radius: 8px; +} + +.spotlight-items-container { + position: relative; + width: 100%; + height: 100%; +} + +.spotlight-item { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center; + transition: opacity 0.8s ease-in-out; +} + +.spotlight-item[data-index="0"] { + opacity: 1; +} + +.spotlight-item:not([data-index="0"]) { + opacity: 0; +} + +.spotlight-overlay { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 45%; + background: linear-gradient(to right, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.7) 70%, transparent 100%); + padding: 40px; + color: white; + display: flex; + flex-direction: column; + z-index: 2; +} + +.spotlight-overlay.has-title { + padding: 70px 40px 20px 40px; +} + +.spotlight-section-title { + position: absolute; + top: 20px; + left: 20px; + color: white; + font-size: 18px; + font-weight: 600; + z-index: 10; + background: rgba(0,0,0,0.4); + padding: 8px 16px; + border-radius: 4px; + backdrop-filter: blur(4px); +} + +.spotlight-item-title { + margin: 0 0 16px 0; + font-size: 1.6em; + font-weight: bold; + height: 80px; + align-content: end; +} + +.spotlight-item-logo { + max-width: 206.8px; + height: 80px; + object-fit: contain; + margin-bottom: 16px; + margin-top: 10px; +} + +.spotlight-clearart { + position: absolute; + bottom: 0px; + right: 0px; + max-height: 70%; + object-fit: contain; + z-index: 5; + opacity: 0.9; + pointer-events: none; +} + +.spotlight-rating-container { + display: flex; + align-items: center; + gap: 8px; +} + +.spotlight-rating-star { + color: #4A9EFF; + font-size: 24px; +} + +.spotlight-rating-value { + font-size: 20px; + font-weight: 500; +} + +.spotlight-rating-value.na { + opacity: 0.7; +} + +.spotlight-metadata-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + font-size: 14px; + opacity: 0.9; +} + +.spotlight-metadata-row .spotlight-end-time { + opacity: 0.8; +} + +.metadata-container { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 2em; +} + +.spotlight-truncated-list { + font-size: 14px; + opacity: 0.9; + position: relative; +} + +.spotlight-truncated-list .spotlight-ellipsis { + cursor: pointer; +} + +.spotlight-tagline { + margin: 0 0 24px 0; + font-size: 18px; + font-style: italic; + opacity: 0.9; + line-height: 1.4; + max-width: 90%; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + min-height: 75px; + max-height: 75px; +} + +.spotlight-buttons-container { + display: flex; + gap: 12px; + align-items: center; + margin-top: auto; +} + +.spotlight-play-button { + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(255,255,255,0.9); + color: #000; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background 0.2s; +} + +.spotlight-play-button:hover { + background: rgba(255,255,255,1); +} + +.spotlight-play-button .material-icons { + font-size: 28px; +} + +.spotlight-watchlist-button { + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(255,255,255,0.2); + color: white; + border: 2px solid rgba(255,255,255,0.5); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: all 0.2s; +} + +.spotlight-watchlist-button:hover { + background: rgba(255,255,255,0.3); +} + +.spotlight-watchlist-button.watchlisted { + border-color: #4CAF50; +} + +.spotlight-watchlist-button.watchlisted .material-icons { + color: #4CAF50; +} + +.spotlight-watchlist-button:not(.watchlisted):hover { + border-color: rgba(255,255,255,0.7); +} + +.spotlight-watchlist-button .material-icons { + font-size: 28px; + color: white; +} + +.spotlight-info-button { + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(255,255,255,0.2); + color: white; + border: 2px solid rgba(255,255,255,0.5); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: all 0.2s; + position: relative; +} + +.spotlight-info-button:hover { + background: rgba(255,255,255,0.3); + border-color: rgba(255,255,255,0.7); +} + +.spotlight-info-button .material-icons { + font-size: 28px; +} + +.spotlight-overview-tooltip { + position: absolute; + text-align: left; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 12px; + padding: 16px; + background: rgba(0,0,0,0.95); + color: white; + border-radius: 8px; + font-size: 14px; + line-height: 1.6; + max-width: 400px; + min-width: 300px; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0,0,0,0.5); + word-wrap: break-word; +} + +.spotlight-info-button:hover .spotlight-overview-tooltip { + opacity: 1; +} + +.spotlight-nav-container { + position: absolute; + top: 20px; + right: 20px; + display: flex; + gap: 8px; + z-index: 10; + background: rgba(0,0,0,0.4); + border-radius: 4px; + padding: 4px; +} + +.spotlight-nav-button { + width: 36px; + height: 36px; + background: transparent; + color: white; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background 0.2s; +} + +.spotlight-nav-button:hover { + background: rgba(255,255,255,0.2); +} + +.spotlight-nav-button .material-icons { + font-size: 24px; +} + +.spotlight-pause-button { + width: 36px; + height: 36px; + background: transparent; + color: white; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + padding: 0; + transition: background 0.2s; +} + +.spotlight-pause-button .material-icons { + font-size: 24px; +} + +.spotlight-dots { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 8px; + z-index: 10; +} + +.spotlight-dot { + width: 8px; + height: 8px; + border-radius: 50%; + border: none; + background: rgba(255,255,255,0.5); + cursor: pointer; + padding: 0; + transition: background 0.2s; +} + +.spotlight-dot.active { + background: white; +} + +.spotlight-genre-link, +.spotlight-person-link { + color: inherit; + text-decoration: underline; + cursor: pointer; +} + +.spotlight-empty-writer-container { + font-size: 14px; + opacity: 0.9; + height: 1em; +} + +/* Mobile Styles */ +@media (max-width: 768px) { + .metadata-container { + display: none !important; + } + + .spotlight-nav-container { + display: none !important; + } + + .spotlight-pause-button { + display: none !important; + } + + .spotlight-clearart { + display: none !important; + } +} diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index 91a74f8..fcd8b24 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -5,17 +5,90 @@ (function() { 'use strict'; + /** + * Helper function to shuffle array (Fisher-Yates) + */ + function shuffle(array) { + const arr = [...array]; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; + } + + /** + * Sorts items based on sort order and direction + * @param {Array} items - Array of Jellyfin items + * @param {string} sortOrder - Sort order: Random, ReleaseDate, CriticRating, CommunityRating, SortTitle, DateAdded + * @param {string} sortOrderDirection - Direction: Ascending or Descending + * @returns {Array} - Sorted array + */ + function sortItems(items, sortOrder = 'Random', sortOrderDirection = 'Ascending') { + if (!items || items.length === 0) return items; + + const ascending = sortOrderDirection === 'Ascending'; + const sorted = [...items]; + + switch(sortOrder) { + case 'Random': + return shuffle(sorted); + case 'ReleaseDate': + return sorted.sort((a, b) => { + const dateA = new Date(a.PremiereDate || a.ProductionYear || 0); + const dateB = new Date(b.PremiereDate || b.ProductionYear || 0); + return ascending ? dateA - dateB : dateB - dateA; + }); + case 'CriticRating': + return sorted.sort((a, b) => { + const ratingA = a.CriticRating || 0; + const ratingB = b.CriticRating || 0; + return ascending ? ratingA - ratingB : ratingB - ratingA; + }); + case 'CommunityRating': + return sorted.sort((a, b) => { + const ratingA = a.CommunityRating || 0; + const ratingB = b.CommunityRating || 0; + return ascending ? ratingA - ratingB : ratingB - ratingA; + }); + case 'SortTitle': + return sorted.sort((a, b) => { + const titleA = (a.SortTitle || a.Name || '').toLowerCase(); + const titleB = (b.SortTitle || b.Name || '').toLowerCase(); + return ascending ? titleA.localeCompare(titleB) : titleB.localeCompare(titleA); + }); + case 'DateAdded': + return sorted.sort((a, b) => { + const dateA = new Date(a.DateCreated || 0); + const dateB = new Date(b.DateCreated || 0); + return ascending ? dateA - dateB : dateB - dateA; + }); + default: + return sorted; + } + } + + /** + * Picks a random card format for a section + * @returns {string} - Random card format: 'portrait', 'thumb' + */ + function getRandomCardFormat() { + const formats = ['portrait', 'thumb']; + return formats[Math.floor(Math.random() * formats.length)]; + } + // Main card builder object const cardBuilder = { /** * Main entry point function to build a Jellyfin card * @param {Object} item - The Jellyfin item object * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes - * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square' + * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', or 'square' + * @param {string} customFooterText - Optional custom footer text (e.g., air date for episodes) * @returns {HTMLElement} - The constructed card element */ - buildCard: function(item, overflowCard = false, cardFormat = null) { - return createJellyfinCardElement(item, overflowCard, cardFormat); + buildCard: function(item, overflowCard = false, cardFormat = null, customFooterText = null) { + return createJellyfinCardElement(item, overflowCard, cardFormat, customFooterText); }, /** @@ -24,11 +97,27 @@ * @param {string} title - Title for the scrollable container * @param {string|Function} viewMoreUrl - Optional URL to make title clickable, or function to call on click * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes - * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square' + * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', or 'random' + * @param {string} sortOrder - Sort order: 'Random', 'ReleaseDate', 'CriticRating', 'CommunityRating', 'SortTitle', 'DateAdded' + * @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending' * @returns {HTMLElement} - The constructed scrollable container */ - renderCards: function(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) { - return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat); + renderCards: function(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null, sortOrder = null, sortOrderDirection = 'Ascending') { + // Handle Random card format - pick one format for entire section + let finalCardFormat = cardFormat; + if (cardFormat === 'random' || cardFormat === 'Random') { + finalCardFormat = getRandomCardFormat(); + } + + // Sort items if sortOrder is provided + let sortedItems = items; + if (sortOrder && sortOrder !== 'Random') { + sortedItems = sortItems(items, sortOrder, sortOrderDirection); + } else if (sortOrder === 'Random') { + sortedItems = shuffle([...items]); + } + + return createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat); }, /** @@ -37,10 +126,12 @@ * @param {string} title - Title for the scrollable container * @param {string} viewMoreUrl - Optional URL to make title clickable * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes - * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square' + * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', or 'random' + * @param {string} sortOrder - Sort order: 'Random', 'ReleaseDate', 'CriticRating', 'CommunityRating', 'SortTitle', 'DateAdded' + * @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending' * @returns {Promise} - The constructed scrollable container */ - renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) { + renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, overflowCard = false, cardFormat = null, sortOrder = null, sortOrderDirection = 'Ascending') { const LOG = (...args) => console.log('[KefinTweaks CardBuilder]', ...args); if (!itemIds || itemIds.length === 0) { @@ -57,11 +148,48 @@ const items = response.Items; LOG(`Fetched ${items.length} items for section: ${title}`); - return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat); + + // Handle Random card format - pick one format for entire section + let finalCardFormat = cardFormat; + if (cardFormat === 'random' || cardFormat === 'Random') { + finalCardFormat = getRandomCardFormat(); + } + + // Sort items if sortOrder is provided + let sortedItems = items; + if (sortOrder && sortOrder !== 'Random') { + sortedItems = sortItems(items, sortOrder, sortOrderDirection); + } else if (sortOrder === 'Random') { + sortedItems = shuffle([...items]); + } + + return createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat); } catch (error) { console.error('[KefinTweaks CardBuilder] Error fetching items:', error); return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat); } + }, + + /** + * Renders a spotlight section (Netflix-style slim banner carousel) + * @param {Array} items - Array of Jellyfin item objects + * @param {string} title - Title for the spotlight section + * @param {Object} options - Options for the spotlight carousel + * @returns {HTMLElement} - The constructed spotlight container + */ + renderSpotlightSection: function(items, title, options = {}) { + return createSpotlightSection(items, title, options); + }, + + /** + * Sorts items based on sort order and direction (exposed for use in homeScreen) + * @param {Array} items - Array of Jellyfin items + * @param {string} sortOrder - Sort order + * @param {string} sortOrderDirection - Direction + * @returns {Array} - Sorted array + */ + sortItems: function(items, sortOrder, sortOrderDirection) { + return sortItems(items, sortOrder, sortOrderDirection); } }; @@ -69,15 +197,17 @@ * Creates a Jellyfin card element from an item * @param {Object} item - The Jellyfin item object * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes - * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square' + * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', or 'square' + * @param {string} customFooterText - Optional custom footer text (e.g., air date for episodes) * @returns {HTMLElement} - The constructed card element */ - function createJellyfinCardElement(item, overflowCard = false, cardFormat = null) { + function createJellyfinCardElement(item, overflowCard = false, cardFormat = null, customFooterText = null) { const serverId = ApiClient.serverId(); const serverAddress = ApiClient.serverAddress(); // Determine card type based on cardFormat override or item type let cardClass, padderClass, imageParams; + cardFormat = cardFormat?.toLowerCase() || null; if (cardFormat) { // Use specified cardFormat @@ -208,8 +338,12 @@ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`; } else if (item.ParentThumbImageTag) { imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`; - } else { - imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`; + } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) { + imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; + } else if (item.SeriesPrimaryImageTag) { + imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; + } else if (item.ImageTags?.Primary) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`; } cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; } else if (item.ImageTags?.Primary) { @@ -434,6 +568,16 @@ episodeBdi.appendChild(episodeLink); secondaryText.appendChild(episodeBdi); cardBox.appendChild(secondaryText); + + // Add custom footer text if provided (e.g., air date) + if (customFooterText) { + const footerText = document.createElement('div'); + footerText.className = 'cardText cardTextCentered cardText-secondary'; + const footerBdi = document.createElement('bdi'); + footerBdi.textContent = customFooterText; + footerText.appendChild(footerBdi); + cardBox.appendChild(footerText); + } } else { const secondaryText = document.createElement('div'); secondaryText.className = 'cardText cardTextCentered cardText-secondary'; @@ -448,6 +592,742 @@ return card; } + /** + * Renders a spotlight section (Netflix-style slim banner carousel) + * @param {Array} items - Array of Jellyfin item objects + * @param {string} title - Title for the spotlight section + * @param {Object} options - Options for the spotlight carousel + * @param {boolean} options.autoPlay - Auto-cycle through items (default: true) + * @param {number} options.interval - Auto-play interval in ms (default: 5000) + * @param {boolean} options.showDots - Show dot indicators (default: true) + * @param {boolean} options.showNavButtons - Show prev/next buttons (default: true) + * @returns {HTMLElement} - The constructed spotlight container + */ + function createSpotlightSection(items, title, options = {}) { + if (!items || items.length === 0) { + return document.createElement('div'); + } + + const { + autoPlay = true, + interval = 5000, + showDots = true, + showNavButtons = true, + showClearArt = false, + viewMoreUrl = null + } = options; + + const serverId = ApiClient.serverId(); + const serverAddress = ApiClient.serverAddress(); + let currentIndex = 0; + let autoPlayTimer = null; + let isPaused = false; + + // Create main container + const container = document.createElement('div'); + container.className = 'spotlight-section padded-left'; + + // Create banner container + const bannerContainer = document.createElement('div'); + bannerContainer.className = 'spotlight-banner-container'; + + // Add section title in top left corner (absolute positioning, aligned with controls) + if (title) { + const sectionTitleEl = document.createElement('div'); + sectionTitleEl.className = 'spotlight-section-title'; + sectionTitleEl.textContent = title; + bannerContainer.appendChild(sectionTitleEl); + } + + // Create items container (for fade transitions) + const itemsContainer = document.createElement('div'); + itemsContainer.className = 'spotlight-items-container'; + + // Create items + items.forEach((item, index) => { + // Get item type early for use throughout + const itemType = item.Type || 'Movie'; + + const itemDiv = document.createElement('div'); + itemDiv.className = 'spotlight-item'; + itemDiv.setAttribute('data-index', index); + if (index === 0) { + itemDiv.style.opacity = '1'; + } else { + itemDiv.style.opacity = '0'; + } + + let imageUrl = ''; + + if (item.Type === 'Episode' && item.ParentBackdropImageTags?.[0]) { + // Series backdrop image + imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?fillHeight=450&fillWidth=1920&quality=96&tag=${item.ParentBackdropImageTags[0]}`; + } else if (item.BackdropImageTags?.[0]) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?fillHeight=450&fillWidth=1920&quality=96&tag=${item.BackdropImageTags[0]}`; + } else if (item.ImageTags?.Primary) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?fillHeight=450&fillWidth=1920&quality=96&tag=${item.ImageTags.Primary}`; + } + + if (imageUrl) { + itemDiv.style.backgroundImage = `url("${imageUrl}")`; + } + + // Create left overlay (40-50% width, dark semi-transparent) + // Add top padding to prevent content from overlapping with section title + const overlay = document.createElement('div'); + overlay.className = 'spotlight-overlay' + (title ? ' has-title' : ''); + + // Item title or logo + let titleEl = null; + const hasLogo = item.ImageTags?.Logo; + + if (hasLogo) { + // Use logo image instead of text title + const logoUrl = `${serverAddress}/Items/${item.Id}/Images/Logo?fillHeight=200&quality=96&tag=${item.ImageTags.Logo}`; + titleEl = document.createElement('img'); + titleEl.className = 'spotlight-item-logo'; + titleEl.src = logoUrl; + titleEl.alt = item.Name || 'Unknown'; + } else { + // Use text title as fallback + titleEl = document.createElement('h3'); + titleEl.className = 'spotlight-item-title'; + titleEl.textContent = item.Name || 'Unknown'; + } + + // Combined: Year + Time + Ends At + Genres (all on one line) + const metadataRow = document.createElement('div'); + metadataRow.className = 'spotlight-metadata-row'; + + // Year + const year = item.ProductionYear || (item.PremiereDate ? new Date(item.PremiereDate).getFullYear() : null); + if (year) { + const yearEl = document.createElement('span'); + yearEl.textContent = year; + metadataRow.appendChild(yearEl); + } + + // Runtime + if (item.RunTimeTicks) { + const runtimeMinutes = Math.round(item.RunTimeTicks / 10000000 / 60); + const hours = Math.floor(runtimeMinutes / 60); + const minutes = runtimeMinutes % 60; + let runtimeText = ''; + if (hours > 0) { + runtimeText = `${hours}h ${minutes > 0 ? minutes + 'm' : ''}`.trim(); + } else { + runtimeText = `${minutes}m`; + } + + const runtimeEl = document.createElement('span'); + runtimeEl.textContent = runtimeText; + metadataRow.appendChild(runtimeEl); + + // End time (when it would end if started now) + const now = new Date(); + // RunTimeTicks is in 100-nanosecond intervals, convert to milliseconds + const runtimeMs = item.RunTimeTicks / 10000; + const endTime = new Date(now.getTime() + runtimeMs); + const endTimeEl = document.createElement('span'); + endTimeEl.className = 'spotlight-end-time'; + endTimeEl.textContent = `Ends at ${endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; + metadataRow.appendChild(endTimeEl); + } + + // Genres (on same line) - make clickable if IDs are available + if (item.Genres && item.Genres.length > 0) { + const genresContainer = document.createElement('span'); + + if (item.GenreItems && item.GenreItems.length > 0) { + item.GenreItems.forEach((genreItem, index) => { + const genreName = genreItem.Name; + const genreId = genreItem.Id; + + if (genreName) { + const genreLink = document.createElement('a'); + genreLink.className = 'spotlight-genre-link'; + genreLink.href = `${serverAddress}/web/#/list.html?genreId=${genreId}&serverId=${serverId}`; + genreLink.textContent = genreName; + genreLink.addEventListener('click', (e) => { + e.stopPropagation(); + }); + if (index > 0) { + const comma = document.createElement('span'); + comma.textContent = ', '; + genresContainer.appendChild(comma); + } + + genresContainer.appendChild(genreLink); + } + }); + } + + if (genresContainer.children.length > 0) { + metadataRow.appendChild(genresContainer); + } + } + + // Helper function to create truncated list that expands on ellipsis hover + // items can be array of strings (names) or array of objects with Name and Id + function createTruncatedList(label, items, maxItems = 3, isPeople = false) { + const container = document.createElement('div'); + container.className = 'spotlight-truncated-list'; + + if (!items || items.length === 0) { + return container; + } + + const displayContainer = document.createElement('span'); + const labelSpan = document.createElement('span'); + labelSpan.textContent = `${label} `; + displayContainer.appendChild(labelSpan); + + // Extract names and IDs from items (handle both strings and objects) + const itemsData = items.map(item => { + if (typeof item === 'string') { + return { name: item, id: null }; + } else if (item && typeof item === 'object') { + return { + name: item.Name || item.name || null, + id: item.Id || item.id || null + }; + } + return container; + }).filter(item => item && item.name); + + if (itemsData.length === 0) return null; + + const hasMore = itemsData.length > maxItems; + + // Store references to hidden elements for easy show/hide + const hiddenElements = []; + + // Create spans for all names (but initially hide the ones beyond maxItems) + itemsData.forEach((itemData, index) => { + let nameElement; + + // Create link if it's a person with an ID, otherwise just text + if (isPeople && itemData.id) { + nameElement = document.createElement('a'); + nameElement.className = 'spotlight-person-link'; + nameElement.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`; + nameElement.textContent = itemData.name; + nameElement.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } else { + nameElement = document.createElement('span'); + nameElement.textContent = itemData.name; + } + + nameElement.setAttribute('data-name-index', index); + + // Hide names beyond maxItems initially + if (index >= maxItems) { + nameElement.style.display = 'none'; + hiddenElements.push(nameElement); + } + + displayContainer.appendChild(nameElement); + + // Add comma separator (except for last item) + if (index < itemsData.length - 1) { + const comma = document.createElement('span'); + comma.textContent = ', '; + comma.setAttribute('data-comma-after', index); + + // Hide comma if it's after the last visible item + if (index >= maxItems - 1) { + comma.style.cssText = 'display: none;'; + hiddenElements.push(comma); + } + displayContainer.appendChild(comma); + } + }); + + // Add ellipsis as separate hoverable element if there are more items + if (hasMore) { + const ellipsisElement = document.createElement('span'); + ellipsisElement.className = 'spotlight-ellipsis'; + ellipsisElement.textContent = '...'; + displayContainer.appendChild(ellipsisElement); + + let isExpanded = false; + + // On ellipsis hover, reveal all hidden names and hide ellipsis + ellipsisElement.addEventListener('mouseenter', () => { + if (!isExpanded) { + // Show all hidden elements + hiddenElements.forEach(el => { + el.style.display = ''; + }); + ellipsisElement.style.display = 'none'; + isExpanded = true; + } + }); + + // Keep expanded state when mouse moves within the container + // Only collapse when mouse leaves the entire container + container.addEventListener('mouseleave', () => { + if (isExpanded) { + // Hide names beyond maxItems again + hiddenElements.forEach(el => { + el.style.display = 'none'; + }); + ellipsisElement.style.display = ''; + isExpanded = false; + } + }); + } + + container.appendChild(displayContainer); + return container; + } + + // For Series: show seasons/episodes. For Movies: show director/writer + let directorContainer = null; + let writerContainer = null; + let combinedContainer = null; + let seriesInfoContainer = null; + + // Create series info container + seriesInfoContainer = document.createElement('div'); + seriesInfoContainer.className = 'spotlight-truncated-list'; + + if (itemType === 'Series') { + // For Series, show seasons and episodes count + const seasonsCount = item.ChildCount || 0; + + // Initial text with seasons count + const seasonsText = `${seasonsCount} ${seasonsCount === 1 ? 'season' : 'seasons'}`; + seriesInfoContainer.textContent = seasonsText; + + // Fetch episode count asynchronously and update + (async () => { + try { + const episodesCount = item.RecursiveItemCount || 0; + + // Update the series info container with episode count + if (seriesInfoContainer) { + const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`; + seriesInfoContainer.textContent = `${seasonsText} - ${episodesText}`; + } + } catch (err) { + console.error('Failed to fetch episode count:', err); + // Keep just the seasons text if episode fetch fails + } + })(); + } else if (itemType === 'Movie') { + // For Movies, show director/writer (existing logic) + if (item.People && Array.isArray(item.People)) { + const directors = item.People.filter(p => p.Type === 'Director'); + const writers = item.People.filter(p => p.Type === 'Writer'); + + // Extract names for comparison + const directorNames = directors.map(p => p.Name); + const writerNames = writers.map(p => p.Name); + + // Check if directors and writers are identical (same length and same names) + const areIdentical = directorNames.length === writerNames.length && + directorNames.length > 0 && + directorNames.every((name, index) => name === writerNames[index]); + + if (areIdentical) { + // Combine into single "Directed and Written by" row (with person objects for links) + combinedContainer = createTruncatedList('Directed and Written by', directors, 3, true); + // Add empty writer container to maintain spacing + writerContainer = document.createElement('div'); + writerContainer.className = 'spotlight-empty-writer-container'; + } else { + // Show separately (with person objects for links) + directorContainer = createTruncatedList('Directed by', directors, 3, true); + writerContainer = createTruncatedList('Written by', writers, 3, true); + } + } + } else if (itemType === 'Season') { + // For Seasons, show episodes count + const episodesCount = item.RecursiveItemCount || 0; + const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`; + seriesInfoContainer.textContent = episodesText; + } else if (itemType === 'Episode') { + // For Episodes, show episode number + const seasonNumber = item.ParentIndexNumber || 0; + const seasonText = `Season ${seasonNumber}`; + const episodeNumber = item.IndexNumber || 0; + const episodeText = `Episode ${episodeNumber}`; + seriesInfoContainer.textContent = `${seasonText} - ${episodeText}`; + } + + // Taglines for Movies, Overview for Series + let taglineEl = null; + taglineEl = document.createElement('p'); + taglineEl.className = 'spotlight-tagline'; + if (itemType === 'Series') { + // For Series, show Overview instead of Tagline + if (item.Overview) { + taglineEl.textContent = item.Overview; + } + } else { + // For Movies, show Tagline (if available) + if (item.Taglines && Array.isArray(item.Taglines) && item.Taglines.length > 0) { + taglineEl.textContent = item.Taglines[0]; // Show first tagline + } else if (item.Overview) { + taglineEl.textContent = item.Overview; + } + } + + // Buttons container + const buttonsContainer = document.createElement('div'); + buttonsContainer.className = 'spotlight-buttons-container'; + + // Play button (material icon span button) + const playButton = document.createElement('button'); + playButton.className = 'spotlight-play-button emby-button'; + const playIcon = document.createElement('span'); + playIcon.className = 'material-icons'; + playIcon.textContent = 'play_arrow'; + playButton.appendChild(playIcon); + playButton.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + if (window.apiHelper && window.apiHelper.playItem) { + await window.apiHelper.playItem(item.Id); + } else { + // Fallback to ApiClient.play if apiHelper is not available + if (window.ApiClient && typeof window.ApiClient.play === 'function') { + window.ApiClient.play({ ids: [item.Id] }); + } else { + console.error('[KefinTweaks CardBuilder] No play method available'); + } + } + } catch (error) { + console.error('[KefinTweaks CardBuilder] Error playing item:', error); + } + }); + + // Watchlist button (material icon span button with bookmark icon) + const watchlistButton = document.createElement('button'); + watchlistButton.className = 'spotlight-watchlist-button emby-button'; + + // Check watchlist status - try to get from cache or API + let isInWatchlist = false; + const sectionName = itemType === 'Movie' ? 'movies' : + itemType === 'Series' ? 'series' : + itemType === 'Season' ? 'seasons' : + itemType === 'Episode' ? 'episodes' : null; + + // Check watchlist cache if available + if (sectionName && typeof window.watchlistCache !== 'undefined' && window.watchlistCache[sectionName]?.data) { + isInWatchlist = window.watchlistCache[sectionName].data.some(watchlistItem => watchlistItem.Id === item.Id); + } + + const watchlistIcon = document.createElement('span'); + watchlistIcon.className = 'material-icons'; + watchlistIcon.textContent = 'bookmark'; + watchlistButton.appendChild(watchlistIcon); + if (isInWatchlist) { + watchlistButton.classList.add('watchlisted'); + } + watchlistButton.addEventListener('click', async (e) => { + e.stopPropagation(); + const userId = ApiClient.getCurrentUserId(); + const newStatus = !isInWatchlist; + + try { + // Update watchlist status using proper API + await ApiClient.updateUserItemRating(userId, item.Id, newStatus ? 'true' : 'false'); + + // Update local state + isInWatchlist = newStatus; + + // Update button class + if (isInWatchlist) { + watchlistButton.classList.add('watchlisted'); + } else { + watchlistButton.classList.remove('watchlisted'); + } + + // Update watchlist cache if available + if (sectionName && typeof window.updateWatchlistCacheOnToggle === 'function') { + await window.updateWatchlistCacheOnToggle(item.Id, itemType, isInWatchlist); + } + } catch (err) { + console.error('Failed to update watchlist status:', err); + } + }); + + // Info button (shows overview on hover) + const infoButton = document.createElement('button'); + infoButton.className = 'spotlight-info-button emby-button'; + const infoIcon = document.createElement('span'); + infoIcon.className = 'material-icons'; + infoIcon.textContent = 'info'; + infoButton.appendChild(infoIcon); + + // Overview tooltip (hidden by default, shown on hover) + if (item.Overview) { + const overviewTooltip = document.createElement('div'); + overviewTooltip.className = 'spotlight-overview-tooltip'; + overviewTooltip.textContent = item.Overview; + infoButton.appendChild(overviewTooltip); + } + + buttonsContainer.appendChild(playButton); + buttonsContainer.appendChild(watchlistButton); + buttonsContainer.appendChild(infoButton); + + // Rating with star icon + const rating = item.CommunityRating || item.CriticRating; + + if (rating && typeof rating === 'number') { + const ratingContainer = document.createElement('div'); + ratingContainer.className = 'spotlight-rating-container'; + + const starIcon = document.createElement('span'); + starIcon.className = 'material-icons spotlight-rating-star'; + starIcon.textContent = 'star'; + + const ratingValue = document.createElement('span'); + ratingValue.className = 'spotlight-rating-value'; + if (rating && typeof rating === 'number') { + ratingValue.textContent = rating.toFixed(1); + } else { + ratingValue.textContent = 'N/A'; + ratingValue.classList.add('na'); + } + + ratingContainer.appendChild(starIcon); + ratingContainer.appendChild(ratingValue); + buttonsContainer.appendChild(ratingContainer); + } + + // Build overlay content in order: Name, Rating, Year+Time+EndsAt+Genres, Directed by, Written by, Taglines, Buttons + overlay.appendChild(titleEl); + + const metadataContainer = document.createElement('div'); + metadataContainer.className = 'metadata-container'; + + if (metadataRow.children.length > 0) { + metadataContainer.appendChild(metadataRow); + } + // For Series, show seasons/episodes. For Movies, show director/writer + if (itemType === 'Series' || itemType === 'Season' || itemType === 'Episode') { + if (seriesInfoContainer) { + metadataContainer.appendChild(seriesInfoContainer); + } + } else { + let emptyDirectorContainer = document.createElement('div'); + emptyDirectorContainer.className = 'spotlight-empty-director-container'; + + let emptyWriterContainer = document.createElement('div'); + emptyWriterContainer.className = 'spotlight-empty-writer-container'; + + if (combinedContainer) { + metadataContainer.appendChild(combinedContainer); + metadataContainer.appendChild(emptyWriterContainer); + } else { + if (directorContainer) { + metadataContainer.appendChild(directorContainer); + } + + if (writerContainer) { + metadataContainer.appendChild(writerContainer); + if (!directorContainer) { + metadataContainer.appendChild(emptyDirectorContainer); + } + } else { + if (!directorContainer) { + metadataContainer.appendChild(emptyDirectorContainer); + } + metadataContainer.appendChild(emptyWriterContainer); + } + } + } + if (metadataContainer.children.length > 0) { + overlay.appendChild(metadataContainer); + } + if (taglineEl) { + overlay.appendChild(taglineEl); + } + overlay.appendChild(buttonsContainer); + + itemDiv.appendChild(overlay); + + // ClearArt image (bottom right corner, if enabled and available) + if (showClearArt && item.ImageTags?.Art) { + const clearArtUrl = `${serverAddress}/Items/${item.Id}/Images/Art?fillHeight=300&quality=96&tag=${item.ImageTags.Art}`; + const clearArtEl = document.createElement('img'); + clearArtEl.className = 'spotlight-clearart'; + clearArtEl.src = clearArtUrl; + clearArtEl.alt = item.Name || 'Unknown'; + itemDiv.appendChild(clearArtEl); + } + + // Click to navigate (but not on buttons) + infoButton.addEventListener('click', (e) => { + window.location.href = `${serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`; + }); + + itemsContainer.appendChild(itemDiv); + }); + + bannerContainer.appendChild(itemsContainer); + + // Navigation buttons (top right) + if (showNavButtons && items.length > 1) { + const navContainer = document.createElement('div'); + navContainer.className = 'spotlight-nav-container'; + + const prevButton = document.createElement('button'); + prevButton.className = 'spotlight-nav-button spotlight-nav-prev emby-button'; + const prevIcon = document.createElement('span'); + prevIcon.className = 'material-icons'; + prevIcon.textContent = 'chevron_left'; + prevButton.appendChild(prevIcon); + prevButton.addEventListener('click', (e) => { + e.stopPropagation(); + // Reset timer when manually navigating + goToItem((currentIndex - 1 + items.length) % items.length, true); + }); + + const nextButton = document.createElement('button'); + nextButton.className = 'spotlight-nav-button spotlight-nav-next emby-button'; + const nextIcon = document.createElement('span'); + nextIcon.className = 'material-icons'; + nextIcon.textContent = 'chevron_right'; + nextButton.appendChild(nextIcon); + nextButton.addEventListener('click', (e) => { + e.stopPropagation(); + // Reset timer when manually navigating + goToItem((currentIndex + 1) % items.length, true); + }); + + navContainer.appendChild(prevButton); + navContainer.appendChild(nextButton); + bannerContainer.appendChild(navContainer); + } + + // Pause button (bottom right) + if (autoPlay && items.length > 1) { + const navContainer = bannerContainer.querySelector('.spotlight-nav-container'); + const pauseButton = document.createElement('button'); + pauseButton.className = 'spotlight-pause-button spotlight-nav-button emby-button'; + const pauseIcon = document.createElement('span'); + pauseIcon.className = 'material-icons'; + pauseIcon.textContent = 'pause'; + pauseButton.appendChild(pauseIcon); + pauseButton.addEventListener('click', (e) => { + e.stopPropagation(); + isPaused = !isPaused; + if (isPaused) { + clearInterval(autoPlayTimer); + pauseIcon.textContent = 'play_arrow'; + } else { + startAutoPlay(); + pauseIcon.textContent = 'pause'; + } + }); + navContainer.insertBefore(pauseButton, navContainer.firstChild); + } + + // Dot indicators + if (showDots && items.length > 1) { + const dotsContainer = document.createElement('div'); + dotsContainer.className = 'spotlight-dots'; + + items.forEach((_, index) => { + const dot = document.createElement('button'); + dot.className = 'spotlight-dot' + (index === 0 ? ' active' : ''); + dot.setAttribute('data-index', index); + dot.addEventListener('click', (e) => { + e.stopPropagation(); + // Reset timer when manually navigating + goToItem(index, true); + }); + dotsContainer.appendChild(dot); + }); + + bannerContainer.appendChild(dotsContainer); + } + + // Go to item function (fade transition) + function goToItem(index, resetTimer = true) { + if (index === currentIndex) return; + + const currentItem = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`); + const nextItem = bannerContainer.querySelector(`.spotlight-item[data-index="${index}"]`); + + if (currentItem && nextItem) { + currentItem.style.opacity = '0'; + nextItem.style.opacity = '1'; + } + + currentIndex = index; + + // Update dots + if (showDots) { + const dots = bannerContainer.querySelectorAll('.spotlight-dot'); + dots.forEach((dot, i) => { + if (i === index) { + dot.classList.add('active'); + } else { + dot.classList.remove('active'); + } + }); + } + + // Reset auto-play timer when manually navigating (always reset delay) + if (resetTimer && autoPlay && !isPaused) { + clearInterval(autoPlayTimer); + autoPlayTimer = null; + startAutoPlay(); + } + } + + // Auto-play function + function startAutoPlay() { + // Always clear any existing timer first + if (autoPlayTimer) { + clearInterval(autoPlayTimer); + autoPlayTimer = null; + } + + if (!autoPlay || items.length <= 1 || isPaused) return; + + autoPlayTimer = setInterval(() => { + // Don't reset timer when auto-advancing (to maintain interval) + goToItem((currentIndex + 1) % items.length, false); + }, interval); + } + + // Pause on hover - prevent auto-cycling when hovering + let isHovering = false; + bannerContainer.addEventListener('mouseenter', () => { + isHovering = true; + if (autoPlayTimer) { + clearInterval(autoPlayTimer); + autoPlayTimer = null; + } + }); + + bannerContainer.addEventListener('mouseleave', () => { + isHovering = false; + // Only restart if auto-play is enabled, not paused, and not hovering + if (autoPlay && !isPaused && !isHovering) { + startAutoPlay(); + } + }); + + // Start auto-play + if (autoPlay) { + startAutoPlay(); + } + + container.appendChild(bannerContainer); + + return container; + } + /** * Creates a scrollable container with horizontal scrolling functionality * @param {Array} items - Array of Jellyfin item objects diff --git a/scripts/configuration.css b/scripts/configuration.css index f6426ac..c1fc378 100644 --- a/scripts/configuration.css +++ b/scripts/configuration.css @@ -32,6 +32,10 @@ transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); } +.kefin-config-button:hover { + background-color: var(--jf-palette-action-hover); +} + .customMenuOptions { display: flex; flex-direction: column; @@ -41,24 +45,139 @@ order: 1; } -a[href="#"].navMenuOption:has(.build) { +.navMenuOption:has(.build) { order: 9999; margin-top: 2em !important; position: relative; } -a[href="#"].navMenuOption:has(.build)::before { +.navMenuOption:has(.build)>.material-icons { + position: relative; +} + +.navMenuOption:has(.build)>.material-icons::after { content: 'KefinTweaks'; position: absolute; - top: -1.5em; - left: 0; + top: -2.5em; + left: -1.25em; + font-family: 'Noto Sans'; + font-size: 14.88px; } .listItemContent:has([data-skin-name]) { justify-content: space-between; +} + +.listItemContent { width: 100%; } #kefinTweaksConfigModal .listItem { align-items: start; + overflow: visible; +} + +#customSections_list .listItem { + overflow: visible; +} + +/* Autocomplete suggestions styling */ +.autocomplete-suggestions { + background: #1a1a1a !important; + color: rgba(255,255,255,0.87) !important; + box-shadow: 0 4px 8px rgba(0,0,0,0.5) !important; + opacity: 1 !important; + z-index: 10000 !important; +} + +.autocomplete-suggestion { + color: rgba(255,255,255,0.87) !important; + background: transparent !important; +} + +.autocomplete-suggestion:hover { + background: rgba(255,255,255,0.1) !important; +} + +#kefinTweaksConfigModal .checkboxContainer, #kefinTweaksConfigModal .checkboxListContainer { + margin-bottom: 0 !important; +} + +#kefinTweaksConfigModal .info-tooltip { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: 1px solid rgba(255,255,255,0.4); + border-radius: 50%; + cursor: help; + color: rgba(255,255,255,0.8); + transition: border-color 0.15s ease, color 0.15s ease; + outline: none; + font-family: 'Material Icons'; + font-style: normal; +} + +#kefinTweaksConfigModal .info-tooltip:hover, +#kefinTweaksConfigModal .info-tooltip:focus-visible { + border-color: rgba(255,255,255,0.8); + color: #ffffff; +} + +#kefinTweaksConfigModal .info-tooltip::after { + content: attr(data-tooltip); + position: absolute; + bottom: 180%; + left: 50%; + transform: translate(-50%, 0); + background: rgba(0,0,0,0.9); + color: #fff; + padding: 0.4em 0.6em; + border-radius: 4px; + font-size: 0.9rem; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease, transform 0.15s ease; + z-index: 20; + font-family: 'Noto Sans'; +} + +#kefinTweaksConfigModal .info-tooltip:hover::after, +#kefinTweaksConfigModal .info-tooltip:focus-visible::after { + opacity: 1; + transform: translate(-50%, 6px); +} + +details[open] summary.listItemBodyText::after { + content: '▲'; +} + +summary.listItemBodyText::after { + content: '▼'; + position: absolute; + bottom: 0px; + left: 0; + right: 0; + text-align: center; + font-size: 0.8rem; +} + +summary.listItemBodyText { + position: relative; +} + +.kefin-config-button:hover { + background-color: rgba(255, 255, 255, 0.08); +} + +.preview-section-container .custom-scroller-container { + max-width: calc(100% - 4rem); +} + +.navMenuOption:has(.build):hover:not(.navMenuOption-selected) { + transform: none !important; + margin-top: 2em !important; } \ No newline at end of file diff --git a/scripts/configuration.js b/scripts/configuration.js index 455ce4d..cbb9899 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -8,6 +8,24 @@ console.log('[KefinTweaks Configuration] Initializing...'); const MODAL_ID = 'kefinTweaksConfigModal'; + let currentLoadedConfig = null; + + const DISCOVERY_SECTION_DEFINITIONS = [ + { key: 'spotlightGenre', label: 'Spotlight Sections (Genre)', defaultName: 'Spotlight' }, + { key: 'spotlightNetwork', label: 'Spotlight Sections (Network)', defaultName: 'Spotlight' }, + { key: 'genreMovies', label: 'Genre Movies', defaultName: '[Genre] Movies' }, + { key: 'studioShows', label: 'Shows from [Studio]', defaultName: 'Shows from [Studio]' }, + { key: 'collections', label: 'Collection Spotlight', defaultName: '[Collection Name]', extras: { minimumItems: 10 } }, + { key: 'becauseYouWatched', label: 'Because you watched [Movie]', defaultName: 'Because you watched [Movie]' }, + { key: 'becauseYouLiked', label: 'Because you liked [Movie]', defaultName: 'Because you liked [Movie]' }, + { key: 'starringTopActor', label: 'Starring [Top Actor]', defaultName: 'Starring [Actor]' }, + { key: 'directedByTopDirector', label: 'Directed by [Top Director]', defaultName: 'Directed by [Director]' }, + { key: 'writtenByTopWriter', label: 'Written by [Top Writer]', defaultName: 'Written by [Writer]' }, + { key: 'becauseYouRecentlyWatched', label: 'Because you recently watched [Movie]', defaultName: 'Because you recently watched [Movie]' }, + { key: 'starringActorRecentlyWatched', label: 'Starring [Actor] because you recently watched [Movie]', defaultName: 'Starring [Actor] because you recently watched [Movie]' }, + { key: 'directedByDirectorRecentlyWatched', label: 'Directed by [Director] because you recently watched [Movie]', defaultName: 'Directed by [Director] because you recently watched [Movie]' }, + { key: 'writtenByWriterRecentlyWatched', label: 'Written by [Writer] because you recently watched [Movie]', defaultName: 'Written by [Writer] because you recently watched [Movie]' } + ]; // Check if user is admin async function isAdmin() { @@ -92,7 +110,14 @@ // If no config found in JS Injector, load defaults console.log('[KefinTweaks Configuration] No config found in JS Injector, loading defaults'); - const defaultConfig = await loadDefaultConfig(); + const rawDefaultConfig = await loadDefaultConfig(); + const defaultConfig = JSON.parse(JSON.stringify(rawDefaultConfig)); + + const currentConfigSource = currentLoadedConfig || window.KefinTweaksCurrentConfig || window.KefinTweaksConfig || {}; + const currentKefinRoot = currentConfigSource.kefinTweaksRoot || defaultConfig.kefinTweaksRoot; + const currentScriptRoot = currentConfigSource.scriptRoot || defaultConfig.scriptRoot; + defaultConfig.kefinTweaksRoot = currentKefinRoot; + defaultConfig.scriptRoot = currentScriptRoot; // Save defaults to JS Injector for future use await saveConfigToJavaScriptInjector(defaultConfig); @@ -166,6 +191,7 @@ #${MODAL_ID} details summary { user-select: none; + overflow: visible; } #${MODAL_ID} details summary:hover { @@ -177,6 +203,116 @@ word-wrap: break-word; } + /* Custom section collapsible styling */ + #${MODAL_ID} .custom-section-summary { + position: relative; + list-style: none; + } + + #${MODAL_ID} .custom-section-summary::-webkit-details-marker { + display: none; + } + + #${MODAL_ID} .custom-section-summary::marker { + display: none; + } + + #${MODAL_ID} details[open] .custom-section-summary .material-icons { + transform: rotate(90deg); + } + + #${MODAL_ID} .custom-section-summary .material-icons { + transition: transform 0.2s ease; + } + + /* Recently released subsection styling */ + #${MODAL_ID} .recently-released-subsection-summary { + position: relative; + list-style: none; + } + + #${MODAL_ID} .recently-released-subsection-summary::-webkit-details-marker { + display: none; + } + + #${MODAL_ID} .recently-released-subsection-summary::marker { + display: none; + } + + #${MODAL_ID} details[open] .recently-released-subsection-summary .material-icons { + transform: rotate(90deg); + } + + #${MODAL_ID} .recently-released-subsection-summary .material-icons { + transition: transform 0.2s ease; + } + + /* Seasonal season collapsible styling */ + #${MODAL_ID} .seasonal-season-summary { + position: relative; + list-style: none; + } + + #${MODAL_ID} .seasonal-season-summary::-webkit-details-marker { + display: none; + } + + #${MODAL_ID} .seasonal-season-summary::marker { + display: none; + } + + #${MODAL_ID} details[open] .seasonal-season-summary .material-icons { + transform: rotate(90deg); + } + + #${MODAL_ID} .seasonal-season-summary .material-icons { + transition: transform 0.2s ease; + } + + /* Seasonal section collapsible styling (sections within seasons) */ + #${MODAL_ID} .seasonal-section-summary { + position: relative; + list-style: none; + } + + #${MODAL_ID} .seasonal-section-summary::-webkit-details-marker { + display: none; + } + + #${MODAL_ID} .seasonal-section-summary::marker { + display: none; + } + + #${MODAL_ID} details[open] .seasonal-section-summary .material-icons { + transform: rotate(90deg); + } + + #${MODAL_ID} .seasonal-section-summary .material-icons { + transition: transform 0.2s ease; + } + + /* Discovery section type collapsible styling */ + #${MODAL_ID} .discovery-section-type-summary { + position: relative; + list-style: none; + } + + #${MODAL_ID} .discovery-section-type-summary::-webkit-details-marker { + display: none; + } + + #${MODAL_ID} .discovery-section-type-summary::marker { + display: none; + } + + #${MODAL_ID} details[open] .discovery-section-type-summary .material-icons { + transform: rotate(90deg); + } + + #${MODAL_ID} .discovery-section-type-summary .material-icons { + transition: transform 0.2s ease; + } + /* Responsive design */ @media (max-width: 768px) { #${MODAL_ID} .modal-content-wrapper { @@ -284,7 +420,7 @@

Script Root Configuration

Configure where KefinTweaks scripts are loaded from
- @@ -326,7 +462,7 @@
Configure custom home screen sections and discovery features
-
+
${buildHomeScreenConfig(homeScreen)}
@@ -361,7 +497,7 @@
Default Skin
- ${allSkinNames.map(name => ``).join('')}
Select a default skin for all users
@@ -526,9 +662,7 @@
${skin.name}
${githubUrl ? `info` : ''}
- + ${buildJellyfinCheckbox(`skin_${skin.name.replace(/[^a-zA-Z0-9]/g, '_')}`, isEnabled, '', { 'data-skin-name': skin.name })}
`; @@ -538,23 +672,23 @@ // Build script toggle switches function buildScriptToggles(scripts) { const scriptNames = [ - { key: 'homeScreen', label: 'Home Screen', desc: 'Custom home screen sections and discovery engine. These can be configured below.' }, - { key: 'search', label: 'Search', desc: 'Search speed is improved by searching less content types by default. Provides options to specify which content type to search. Fully compatible with Jellysearch Search from Jellyfin Enhanced plugin.' }, { key: 'watchlist', label: 'Watchlist', desc: 'Allows your users to add items to their Watchlist. The Watchlist page shows an overview of all items on a user\'s Watchlist, as well as their Series Progress and Movie History. It also includes a Statistics page with an overview of your user watched stats.' }, + { key: 'homeScreen', label: 'Enhanced Home Screen', desc: 'Add custom home screen sections and a "discovery engine" to your Home Page. Create sections based on Genre, Tags, Playlists, Collections or Search Terms for specific Item Types. Use the "Spotlight" feature to highlight specific sections with an image carousel/slideshow.' }, + { key: 'search', label: 'Enhanced Search', desc: 'Search speed is improved by searching less content types by default. Provides options to specify which content type to search. Fully compatible with both the Jellysearch Search from Jellyfin Enhanced plugin and the Meilisearch plugin.' }, + { key: 'infiniteScroll', label: 'Infinite Scroll', desc: 'Adds infinite scrolling to the library pages for Movies and TV' }, + { key: 'removeContinue', label: 'Remove Continue', desc: 'Adds the ability to remove items from the Continue Watching sections' }, + { key: 'skinManager', label: 'Skin Manager', desc: 'Skin selection and management - adds skin dropdown to header and display preferences' }, { key: 'headerTabs', label: 'Header Tabs', desc: 'Allows direct navigation to specific Tab sections of a given library page. Full support for default Jellyfin landing pages.' }, { key: 'customMenuLinks', label: 'Custom Menu Links', desc: 'Add custom menu links to the left side navigation menu' }, { key: 'breadcrumbs', label: 'Breadcrumbs', desc: 'Breadcrumb navigation for Movies, TV and Music. Allows quick navigation between Seasons/Episodes and Albums/Songs.' }, - { key: 'playlist', label: 'Playlist', desc: 'Improved Playlist page navigation: Items only play when the Play button is pressed. Clicking an item navigates to the item\'s page.' }, - { key: 'itemDetailsCollections', label: 'Item Details Collections', desc: 'Displays collections on item details pages (Included In section)' }, + { key: 'playlist', label: 'Playlist UX', desc: 'Improved Playlist page navigation: Items only play when the Play button is pressed. Clicking an item navigates to the item\'s page.' }, + { key: 'itemDetailsCollections', label: 'Collections on Details Page', desc: 'Displays collections on item details pages (Included In section)' }, { key: 'flattenSingleSeasonShows', label: 'Flatten Single Season Shows', desc: 'Flattens single-season shows to display episodes directly on series page' }, - { key: 'collections', label: 'Collections', desc: 'Collection sorting functionality on the Collection page' }, + { key: 'collections', label: 'Collection Sorting', desc: 'Collection sorting functionality on the Collection page' }, + { key: 'subtitleSearch', label: 'Subtitle Search', desc: 'Search and download subtitles directly from the video OSD' }, { key: 'exclusiveElsewhere', label: 'Exclusive Elsewhere', desc: 'Custom branding when items aren\'t available on streaming services' }, { key: 'backdropLeakFix', label: 'Backdrop Leak Fix', desc: 'Fixes memory leaks from backdrop images when tab isn\'t focused' }, - { key: 'dashboardButtonFix', label: 'Dashboard Button Fix', desc: 'Improved back button behavior on dashboard - prevents navigating back to the new tab browser page' }, - { key: 'infiniteScroll', label: 'Infinite Scroll', desc: 'Adds infinite scrolling to the library pages for Movies and TV' }, - { key: 'removeContinue', label: 'Remove Continue', desc: 'Adds the ability to remove items from the Continue Watching sections' }, - { key: 'subtitleSearch', label: 'Subtitle Search', desc: 'Search and download subtitles directly from the video OSD' }, - { key: 'skinManager', label: 'Skin Manager', desc: 'Skin selection and management - adds skin dropdown to header and display preferences' } + { key: 'dashboardButtonFix', label: 'Dashboard Button Fix', desc: 'Improved back button behavior on dashboard - prevents navigating back to the new tab browser page' } ]; return scriptNames.map(script => { @@ -564,9 +698,7 @@
${script.label}
- + ${buildJellyfinCheckbox(`script_${script.key}`, isEnabled, '', { 'data-script-key': script.key })}
${script.desc}
@@ -575,136 +707,814 @@ }).join(''); } - // Build home screen configuration - function buildHomeScreenConfig(homeScreen) { - const exampleCustomSections = `[ - { - "name": "My Favorite Movies", - "id": "acc84f03475eb51602b13cde158d21ad", - "maxItems": 20, - "order": 5 - }, - { - "name": "Action Collection", - "id": "bdd95f14586eb51703c24def269e32be", - "maxItems": 15, - "order": 10 - } -]`; + // Helper function to build Include Item Types badge field + function buildIncludeItemTypesField(prefix, sectionIndex, sanitizedPrefix, currentTypes, sourceType) { + // Available item types (excluding Collections and Playlists unless source type is Parent) + const availableTypes = ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum', 'Audio', 'MusicVideo', 'Book']; + const parentOnlyTypes = ['BoxSet', 'Playlist']; // Only available when source type is Parent + + // All available types for this source type + const allAvailableTypes = sourceType === 'Parent' ? [...availableTypes, ...parentOnlyTypes] : availableTypes; + const selectionLabel = currentTypes.length ? `${currentTypes.length} selected` : 'Select item types'; + + // Current types as badges + const badgesHtml = currentTypes.map(typeName => ` + + ${typeName} + + + `).join(''); + + const dropdownOptions = allAvailableTypes.map(typeName => { + const isChecked = currentTypes.includes(typeName); + return ` + + `; + }).join(''); return ` +
+
+
+
Include Item Types
+ +
+
+ + + +
+ No item types selected + ${badgesHtml} +
+
+ +
+
+ `; + } + + // Helper function to build Jellyfin-style checkbox + function buildJellyfinCheckbox(id, checked, label, dataAttributes = {}) { + const dataAttrs = Object.entries(dataAttributes).map(([key, value]) => `${key}="${value}"`).join(' '); + const checkedAttr = checked ? 'checked' : ''; + return ` +
+ +
+ `; + } + + // Helper function to build a section UI (for nested sections in seasonal and custom sections) + function buildSectionUI(prefix, section, sectionIndex, isSeasonalNested = false) { + const sectionId = section.id || `${prefix}_section_${sectionIndex}`; + const enabled = section.enabled !== false; + const name = section.name || ''; + const type = section.type || 'Genre'; + const source = section.source || ''; + const itemLimit = section.itemLimit || 16; + const sortOrder = section.sortOrder || 'Random'; + const sortOrderDirection = section.sortOrderDirection || 'Ascending'; + const cardFormat = section.cardFormat || 'Poster'; + const order = section.order || 100; + const spotlightEnabled = section.spotlight === true; + const discoveryEnabled = section.discoveryEnabled === true; + const searchTerm = section.searchTerm || ''; + const includeItemTypes = Array.isArray(section.includeItemTypes) ? section.includeItemTypes : (section.includeItemTypes ? section.includeItemTypes.split(',').map(s => s.trim()).filter(Boolean) : (type === 'Parent' ? [] : ['Movie'])); + + // Determine source field based on type + let sourceFieldHtml = ''; + if (type === 'Parent') { + // Parent type uses simple text input for parent IDs (comma-separated) + const parentIds = source || ''; + sourceFieldHtml = ` +
+
+
Parent IDs (comma-separated, leave empty for "Any")
+ + +
+
+ `; + } else if (type === 'Tag' || type === 'Genre' || type === 'Collection' || type === 'Playlist') { + // Parse existing source (comma-separated) into array + const sourceArray = source ? source.split(',').map(s => s.trim()).filter(Boolean) : []; + + // For Collections/Playlists, we'll show IDs initially and load names after + // For Tags/Genres, show names directly + let badgesHtml = ''; + if (type === 'Tag' || type === 'Genre') { + badgesHtml = sourceArray.map(val => ` + + ${val} + + + `).join(''); + } else { + // Collections/Playlists: display IDs initially, will be replaced with names after fetch + badgesHtml = sourceArray.map(id => ` + + ${id} + + + `).join(''); + } + + sourceFieldHtml = ` +
+
+
+
${type}
+ +
+
+ +
+ ${badgesHtml} +
+
+ + +
+
+ `; + } + + // Build section fields HTML for right column + const sanitizedSectionPrefix = `${prefix}_section_${sectionIndex.toString().replace(/[^a-zA-Z0-9]/g, '_')}`; + const spotlightCheckboxId = `${sanitizedSectionPrefix}_spotlight`; + const discoveryCheckboxId = `${sanitizedSectionPrefix}_discovery`; + const sectionFieldsHtml = createSectionConfiguration( + sanitizedSectionPrefix, + section, + { + includeName: true, + defaultName: name || 'Unnamed Section', + nameInputAttributes: { + class: `${prefix}_section_name`, + 'data-section-index': sectionIndex, + 'data-prefix': prefix + } + } + ); + + // For custom sections and seasonal nested sections, use the improved layout + const useImprovedLayout = prefix === 'customSection' || isSeasonalNested; + + // Build section content - improved layout for custom sections and seasonal nested sections + let sectionContent; + if (useImprovedLayout) { + // Custom sections: Row 1 (checkboxes + type), Row 2 (source), Row 3+ (remaining fields in 3 columns) + sectionContent = ` + +
+
+
Enabled
+ ${buildJellyfinCheckbox(`${prefix}_section_enabled_${sectionIndex}`, enabled, '', { 'data-section-index': sectionIndex, 'data-prefix': prefix, 'class': `${prefix}_section_enabled` })} +
+
+
Spotlight
+ ${buildJellyfinCheckbox(spotlightCheckboxId, spotlightEnabled, 'Enable Spotlight', { 'data-section-index': sectionIndex, 'data-prefix': prefix, 'class': `${prefix}_section_spotlight` })} +
+
+
Discovery
+ ${buildJellyfinCheckbox(discoveryCheckboxId, discoveryEnabled, 'Enable as Discovery', { 'data-section-index': sectionIndex, 'data-prefix': prefix, 'class': `${prefix}_section_discovery` })} +
+
+
Type
+ +
+
+ +
+ ${sourceFieldHtml} +
+ +
+ ${sectionFieldsHtml} + +
+
+
Search Term
+ +
+
+ + ${buildIncludeItemTypesField(prefix, sectionIndex, sanitizedSectionPrefix, includeItemTypes, type)} +
+ `; + } + + // Wrap sections in collapsible details for both custom sections and seasonal nested sections + if (useImprovedLayout) { + // Determine summary class and name class based on prefix + const summaryClass = prefix === 'customSection' ? 'custom-section-summary' : 'seasonal-section-summary'; + const nameClass = prefix === 'customSection' ? 'custom-section-name' : 'seasonal-section-name'; + const enabledClass = prefix === 'customSection' ? 'custom-section-enabled-display' : 'seasonal-section-enabled-display'; + const orderClass = prefix === 'customSection' ? 'custom-section-order' : 'seasonal-section-order'; + const showPreviewButton = prefix === 'customSection' || prefix === 'seasonal_season'; + const previewButtonHtml = showPreviewButton + ? `` + : ''; + + return ` +
+ +
+ chevron_right + ${name || 'Unnamed Section'} + (${enabled ? 'Enabled' : 'Disabled'}, Order: ${order}) +
+
+ ${previewButtonHtml} + +
+
+
+ ${sectionContent} +
+
+ `; + } else { + // Fallback for any other section types (shouldn't happen in current code) + const showPreviewButton = prefix === 'customSection' || prefix === 'seasonal_season'; + const previewButtonHtml = showPreviewButton + ? `` + : ''; + return ` +
+
+
${name || 'Unnamed Section'}
+
+ ${previewButtonHtml} + +
+
+ ${sectionContent} +
+ `; + } + } + + function mergeAttributes(defaultAttrs = {}, customAttrs = {}) { + const merged = { ...defaultAttrs }; + if (customAttrs && typeof customAttrs === 'object') { + Object.entries(customAttrs).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (key === 'class' && merged.class) { + merged.class = `${merged.class} ${value}`.trim(); + } else { + merged[key] = value; + } + }); + } + return merged; + } + + function attributesToString(attrs = {}) { + return Object.entries(attrs) + .map(([key, value]) => { + if (value === undefined || value === null) return ''; + return `${key}="${value}"`; + }) + .filter(Boolean) + .join(' '); + } + + function parseNumberOrFallback(value, fallback) { + if (value === null || value === undefined || value === '') { + return fallback; + } + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : fallback; + } + + function normalizeDiscoverySectionConfigEntry(rawValue, definition, discovery, legacyName = '') { + const defaultItemLimit = discovery.defaultItemLimit ?? 16; + const defaultSortOrder = discovery.defaultSortOrder ?? 'Random'; + const defaultCardFormat = discovery.defaultCardFormat ?? 'Poster'; + const defaults = { + enabled: true, + name: legacyName || definition.defaultName || '', + itemLimit: defaultItemLimit, + sortOrder: defaultSortOrder, + sortOrderDirection: 'Ascending', + cardFormat: defaultCardFormat + }; + + if (definition.extras?.minimumItems !== undefined) { + defaults.minimumItems = definition.extras.minimumItems; + } + + let normalized = { ...defaults }; + + if (rawValue === false) { + normalized.enabled = false; + } else if (rawValue && typeof rawValue === 'object') { + normalized = { ...normalized, ...rawValue }; + } else if (rawValue === true) { + normalized.enabled = true; + } + + normalized.itemLimit = parseNumberOrFallback(normalized.itemLimit, defaultItemLimit); + normalized.sortOrder = normalized.sortOrder || defaultSortOrder; + normalized.sortOrderDirection = normalized.sortOrderDirection || 'Ascending'; + normalized.cardFormat = normalized.cardFormat || defaultCardFormat; + normalized.name = normalized.name || definition.defaultName || ''; + + if (definition.extras?.minimumItems !== undefined) { + normalized.minimumItems = parseNumberOrFallback( + normalized.minimumItems, + definition.extras.minimumItems + ); + } + + return normalized; + } + + function getNormalizedDiscoverySectionConfigs(discovery = {}) { + const sectionTypes = discovery.sectionTypes || {}; + const legacyNames = discovery.sectionNames || {}; + const normalized = {}; + + DISCOVERY_SECTION_DEFINITIONS.forEach(def => { + normalized[def.key] = normalizeDiscoverySectionConfigEntry( + sectionTypes[def.key], + def, + discovery, + legacyNames[def.key] + ); + }); + + return normalized; + } + + // Helper function to build section configuration fields (Name, Item Limit, Sort Order, Card Format, Order) + function createSectionConfiguration(prefix, config = {}, options = {}) { + const { + includeSortOrder = true, + includeOrder = true, + includeName = true, + defaultName = '', + nameLabel = 'Section Name', + nameInputAttributes = {}, + includeCardFormat = true, + includeItemLimit = true + } = options; + + const nameValue = config.name ?? defaultName ?? ''; + const itemLimit = config.itemLimit ?? 16; + const sortOrder = config.sortOrder ?? 'Random'; + const sortOrderDirection = config.sortOrderDirection ?? 'Ascending'; + const cardFormat = config.cardFormat ?? 'Poster'; + const order = config.order ?? 100; + + let html = ''; + + if (includeName) { + const nameAttrs = mergeAttributes( + { id: `${prefix}_name`, class: 'fld emby-input' }, + nameInputAttributes + ); + html += `
-
Enable New and Trending
- +
${nameLabel}
+
-
-
+
`; + } + + if (includeCardFormat) { + html += ` +
-
Enable New Movies
- +
Card Format
+
-
-
+
`; + } + + if (includeSortOrder) { + html += ` +
-
Enable New Episodes
- -
-
-
-
-
Enable Trending
- -
This feature is not yet implemented
+
Sort Order
+
-
Enable Discovery
- +
Sort Order Direction
+ +
+
`; + } + + if (includeOrder) { + html += ` +
+
+
+ Order + +
+ +
+
`; + } + + if (includeItemLimit) { + html += ` +
+
+
Item Limit
+ +
+
`; + } + + return html; + } + + // Build home screen configuration + function buildHomeScreenConfig(homeScreen) { + const recentlyReleased = homeScreen.recentlyReleased || {}; + const trending = homeScreen.trending || {}; + const popularTVNetworks = homeScreen.popularTVNetworks || {}; + const watchlist = homeScreen.watchlist || {}; + const upcoming = homeScreen.upcoming || {}; + const imdbTop250 = homeScreen.imdbTop250 || {}; + const seasonal = homeScreen.seasonal || {}; + const watchAgain = homeScreen.watchAgain || {}; + const discovery = homeScreen.discovery || {}; + const customSections = homeScreen.customSections || []; + const normalizedDiscoverySections = getNormalizedDiscoverySectionConfigs(discovery); + + // Build seasonal sections HTML + const seasonalSectionsHtml = (seasonal.seasons || []).map((season, seasonIndex) => { + const sections = season.sections || []; + const sectionsHtml = sections.map((section, sectionIndex) => { + return buildSectionUI('seasonal_season', section, `${seasonIndex}_${sectionIndex}`, true); + }).join(''); + + const seasonOrder = season.order || 100; + return ` +
+ +
+ chevron_right + ${season.name || 'Unnamed Season'} + (${season.enabled !== false ? 'Enabled' : 'Disabled'}, Order: ${seasonOrder}) +
+
+ +
+
+
+
+
+
Enabled
+ ${buildJellyfinCheckbox(`seasonal-season-enabled-${seasonIndex}`, season.enabled !== false, 'Enabled', { 'data-index': seasonIndex, 'class': 'seasonal-season-enabled' })} +
+
+
Name
+ +
+
+
Start Date (MM-DD)
+ +
+
+
End Date (MM-DD)
+ +
+
+
Order
+ +
+
+
+
Sections
+
+ ${sectionsHtml} +
+ +
+
+
`; + }).join(''); + + return ` + +
+ + ${buildJellyfinCheckbox('homeScreen_recentlyReleased_enabled', recentlyReleased.enabled !== false, 'Recently Released Sections')} + +
+
+
+
+ +
+ chevron_right + Recently Released Movies + (${(recentlyReleased.movies || {}).enabled !== false ? 'Enabled' : 'Disabled'}, Order: ${(recentlyReleased.movies || {}).order || 30}) +
+
+
+
+ ${buildJellyfinCheckbox('homeScreen_recentlyReleased_movies_enabled', (recentlyReleased.movies || {}).enabled !== false, 'Enabled')} +
+
+ ${createSectionConfiguration('homeScreen_recentlyReleased_movies', recentlyReleased.movies || {}, { includeName: true, defaultName: 'Recently Released Movies', includeSortOrder: false, includeOrder: true })} +
+
+
+
+ +
+ chevron_right + Recently Aired Episodes + (${(recentlyReleased.episodes || {}).enabled !== false ? 'Enabled' : 'Disabled'}, Order: ${(recentlyReleased.episodes || {}).order || 31}) +
+
+
+
+ ${buildJellyfinCheckbox('homeScreen_recentlyReleased_episodes_enabled', (recentlyReleased.episodes || {}).enabled !== false, 'Enabled')} +
+
+ ${createSectionConfiguration('homeScreen_recentlyReleased_episodes', recentlyReleased.episodes || {}, { includeName: true, defaultName: 'Recently Aired Episodes', includeSortOrder: false, includeOrder: true })} +
+
+
+
+
+
+
+ + +
+ + ${buildJellyfinCheckbox('homeScreen_trending_enabled', trending.enabled === true, 'Trending Sections', { disabled: 'true' })} + (Order: ${trending.order || 100}) + Under development + +
+ +
+
+ + +
+ + ${buildJellyfinCheckbox('homeScreen_popularTVNetworks_enabled', popularTVNetworks.enabled === true, 'Popular TV Networks Sections')} + (Order: ${popularTVNetworks.order || 100}) + +
+
+
+ ${createSectionConfiguration('homeScreen_popularTVNetworks', popularTVNetworks, { includeName: true, defaultName: 'Popular TV Networks' })} +
+
+
Minimum Shows for Network to Appear
+
-
+
+
+
+
+ + +
+ + ${buildJellyfinCheckbox('homeScreen_watchlist_enabled', watchlist.enabled === true, 'Watchlist')} + (Order: ${watchlist.order || 100}) + +
+
+
+ ${createSectionConfiguration('homeScreen_watchlist', watchlist, { includeName: true, defaultName: 'Watchlist' })} +
+
+
+
+ + +
+ + ${buildJellyfinCheckbox('homeScreen_upcoming_enabled', upcoming.enabled !== false, 'Upcoming')} + (Order: ${upcoming.order || 100}) + +
+
+
+ ${createSectionConfiguration('homeScreen_upcoming', upcoming, { includeName: true, defaultName: 'Upcoming', includeSortOrder: false })} +
+
+
+
+ + +
+ + ${buildJellyfinCheckbox('homeScreen_imdbTop250_enabled', imdbTop250.enabled !== false, 'IMDb Top 250')} + (Order: ${imdbTop250.order || 100}) + +
+
+
+ ${createSectionConfiguration('homeScreen_imdbTop250', imdbTop250, { includeName: true, defaultName: 'IMDb Top 250' })} +
+
+
+
+ + +
+ + ${buildJellyfinCheckbox('homeScreen_seasonal_enabled', seasonal.enabled !== false, 'Seasonal Sections')} + +
+
+
+ ${seasonalSectionsHtml} +
+ +
+
+
+ + +
+ + ${buildJellyfinCheckbox('homeScreen_watchAgain_enabled', watchAgain.enabled === true, 'Watch Again')} + (Order: ${watchAgain.order || 100}) + +
+
+
+ ${createSectionConfiguration('homeScreen_watchAgain', watchAgain, { includeName: true, defaultName: 'Watch Again' })} +
+
+
+
+ + +
+ + ${buildJellyfinCheckbox('homeScreen_discovery_enabled', discovery.enabled !== false, 'Discovery Sections')} + +
+
+
+
-
Enable Infinite Scroll
- +
Infinite Scroll / Load More Button
+ ${buildJellyfinCheckbox('homeScreen_discovery_infiniteScroll', discovery.infiniteScroll !== false, 'Infinite Scroll')}
-
Minimum People Appearances
- -
Minimum movie appearances for people to be featured
+
Randomize Section Order
+ ${buildJellyfinCheckbox('homeScreen_discovery_randomizeOrder', discovery.randomizeOrder === true, 'Randomize Order')}
-
Minimum Genre Movie Count
- -
Minimum movie count for genres to be included
+
Minimum Appearances for Top People
+
-
Minimum Shows For Network
- -
Minimum show count for TV networks to be featured
+
Minimum Movie count for Genres
+
-
Enable Watchlist
- +
Spotlight & Collection Spawn Chance
+ +
0.0 = never, 1.0 = always
-
-
-
Enable Seasonal
- -
-
-
-
-
Seasonal Item Limit
- -
Maximum items to show in seasonal sections
-
-
-
-
-
Custom Sections JSON
-
Add custom playlist/collection-based sections. Get collection/playlist IDs from Jellyfin URLs.
- -
- View Example Format -
${exampleCustomSections}
-
+ ${createSectionConfiguration('homeScreen_discovery', discovery, { includeName: false, includeOrder: false, includeCardFormat: false, includeSortOrder: false, includeItemLimit: false })} +
+
+
+
Discovery Sections
+
Section names support placeholders such as [Genre], [Director], [Writer], [Actor], [Movie], [Studio], [Collection Name]
+
+ ${DISCOVERY_SECTION_DEFINITIONS.map(section => { + const sectionConfig = normalizedDiscoverySections[section.key]; + const prefix = `homeScreen_discovery_sectionTypes_${section.key}`; + const configHtml = createSectionConfiguration(prefix, sectionConfig, { includeName: true, includeOrder: true, includeSortOrder: false, defaultName: section.defaultName }); + const extraFields = section.extras?.minimumItems !== undefined + ? ` +
+
+
Minimum Items
+ +
+
+ ` + : ''; + + const discoveryOrder = sectionConfig.order || 100; + return ` +
+ +
+ chevron_right + ${section.label} + (${sectionConfig.enabled !== false ? 'Enabled' : 'Disabled'}, Order: ${discoveryOrder}) +
+
+
+
+ ${buildJellyfinCheckbox(`${prefix}_enabled`, sectionConfig.enabled !== false, 'Enabled')} +
+
+ ${configHtml} + ${extraFields} +
+
+
+ `; + }).join('')} +
+
+
+
+
+ + +
+ Custom Sections +
+
Sections
+
+ ${(customSections || []).map((section, index) => { + return buildSectionUI('customSection', section, index, false); + }).join('')} +
+
+
`; } @@ -715,10 +1525,7 @@
Hide Server Name
Set to true to hide server name, useful when you want to only show a logo or icon via CSS
- + ${buildJellyfinCheckbox('exclusiveElsewhere_hideServerName', exclusiveElsewhere.hideServerName === true, 'Enabled')}
`; @@ -731,10 +1538,7 @@
Enable Jellyseerr
Enable Jellyseerr integration for request functionality
- + ${buildJellyfinCheckbox('search_enableJellyseerr', search.enableJellyseerr === true, 'Enabled')}
`; @@ -757,6 +1561,9 @@ return; } + currentLoadedConfig = config; + window.KefinTweaksCurrentConfig = config; + // Build the content (without title, as ModalSystem adds it) const content = document.createElement('div'); content.className = 'modal-content-wrapper'; @@ -776,6 +1583,9 @@ + @@ -830,43 +1640,1910 @@ // No validation needed for default skin dropdown - it only allows valid options - // Home Screen: Toggle visibility of New Movies/Episodes/Trending based on New and Trending - const enableNewAndTrendingCheckbox = modalInstance.dialogContent.querySelector('#homeScreen_enableNewAndTrending'); - if (enableNewAndTrendingCheckbox) { - const newMoviesContainer = modalInstance.dialogContent.querySelector('#homeScreen_newMoviesContainer'); - const newEpisodesContainer = modalInstance.dialogContent.querySelector('#homeScreen_newEpisodesContainer'); - const trendingContainer = modalInstance.dialogContent.querySelector('#homeScreen_trendingContainer'); + // Helper function to toggle container visibility + function setupToggleVisibility(checkboxId, containerId) { + const checkbox = modalInstance.dialogContent.querySelector(checkboxId); + const container = modalInstance.dialogContent.querySelector(containerId); + if (checkbox && container) { + const toggle = () => { + container.style.display = checkbox.checked ? '' : 'none'; + }; + checkbox.addEventListener('change', toggle); + // Prevent details from toggling when clicking checkbox + checkbox.addEventListener('click', (e) => { + e.stopPropagation(); + }); + toggle(); // Set initial state + } + } + + // Home Screen: Setup all visibility toggles + setupToggleVisibility('#homeScreen_recentlyReleased_enabled', '#homeScreen_recentlyReleased_container'); + setupToggleVisibility('#homeScreen_trending_enabled', '#homeScreen_trending_container'); + setupToggleVisibility('#homeScreen_popularTVNetworks_enabled', '#homeScreen_popularTVNetworks_container'); + setupToggleVisibility('#homeScreen_watchlist_enabled', '#homeScreen_watchlist_container'); + setupToggleVisibility('#homeScreen_upcoming_enabled', '#homeScreen_upcoming_container'); + setupToggleVisibility('#homeScreen_imdbTop250_enabled', '#homeScreen_imdbTop250_container'); + setupToggleVisibility('#homeScreen_seasonal_enabled', '#homeScreen_seasonal_container'); + setupToggleVisibility('#homeScreen_watchAgain_enabled', '#homeScreen_watchAgain_container'); + setupToggleVisibility('#homeScreen_discovery_enabled', '#homeScreen_discovery_container'); + + // Setup event listeners for section management + // Add section to seasonal season + modalInstance.dialogContent.addEventListener('click', (e) => { + const addBtn = e.target.closest('.add-section-to-seasonal-btn'); + if (addBtn) { + const seasonIndex = parseInt(addBtn.getAttribute('data-season-index')); + addSectionToSeasonalSeason(seasonIndex); + } + }); + + // Add custom section + modalInstance.dialogContent.addEventListener('click', (e) => { + const addBtn = e.target.closest('.add-custom-section-btn'); + if (addBtn) { + addCustomSection(); + } + }); + + // Delete section + modalInstance.dialogContent.addEventListener('click', (e) => { + const deleteBtn = e.target.closest('.delete-section-btn'); + if (deleteBtn) { + e.preventDefault(); + e.stopPropagation(); + const prefix = deleteBtn.getAttribute('data-prefix'); + const sectionIndexRaw = deleteBtn.getAttribute('data-section-index'); + const isSeasonal = deleteBtn.getAttribute('data-is-seasonal') === 'true'; + if (prefix && sectionIndexRaw !== null) { + // For seasonal sections, sectionIndex is a compound string like "0_1", don't parse it + const sectionIndex = isSeasonal || sectionIndexRaw.includes('_') ? sectionIndexRaw : parseInt(sectionIndexRaw); + deleteSection(prefix, sectionIndex, isSeasonal); + } + } + }); + + // Preview section + modalInstance.dialogContent.addEventListener('click', (e) => { + const previewBtn = e.target.closest('.preview-section-btn'); + if (previewBtn) { + e.preventDefault(); + e.stopPropagation(); + const prefix = previewBtn.getAttribute('data-prefix'); + const sectionIndexRaw = previewBtn.getAttribute('data-section-index'); + if (prefix && sectionIndexRaw !== null) { + // For seasonal sections, sectionIndex is a compound string like "0_1", don't parse it + const isSeasonal = prefix === 'seasonal_season' || sectionIndexRaw.includes('_'); + const sectionIndex = isSeasonal ? sectionIndexRaw : parseInt(sectionIndexRaw, 10); + previewSection(prefix, sectionIndex); + } + } + }); + + // Type dropdown change + modalInstance.dialogContent.addEventListener('change', (e) => { + const typeSelect = e.target.closest(`[class*="_section_type"]`); + if (typeSelect && typeSelect.hasAttribute('data-prefix')) { + const prefix = typeSelect.getAttribute('data-prefix'); + const sectionIndex = typeSelect.getAttribute('data-section-index'); + const isSeasonal = typeSelect.getAttribute('data-is-seasonal') === 'true'; + updateSectionSourceField(prefix, sectionIndex, isSeasonal); + } + }); + + // Initialize autocomplete for all badge-based sections + setTimeout(async () => { + const badgeContainers = modalInstance.dialogContent.querySelectorAll('.tag-badge-container:not(.includeItemTypes-badge-container)'); + for (const container of badgeContainers) { + const prefix = container.getAttribute('data-prefix'); + const sectionIndex = container.getAttribute('data-section-index'); + const type = container.getAttribute('data-type'); + if (prefix && sectionIndex && type) { + await setupBadgeAutocomplete(prefix, sectionIndex, type); + if (type === 'Collection' || type === 'Playlist') { + await loadBadgeNames(prefix, sectionIndex, type); + } + } + } - const toggleNewMoviesEpisodesTrendingVisibility = () => { - const isEnabled = enableNewAndTrendingCheckbox.checked; - if (newMoviesContainer) { - newMoviesContainer.style.display = isEnabled ? '' : 'none'; + // Initialize autocomplete for Include Item Types fields + const includeItemTypesContainers = modalInstance.dialogContent.querySelectorAll('.includeItemTypes-badge-container'); + for (const container of includeItemTypesContainers) { + const prefix = container.getAttribute('data-prefix'); + const sectionIndex = container.getAttribute('data-section-index'); + const availableTypes = container.getAttribute('data-available-types')?.split(',').filter(Boolean) || []; + if (prefix && sectionIndex) { + setupIncludeItemTypesDropdown(prefix, sectionIndex, availableTypes); } - if (newEpisodesContainer) { - newEpisodesContainer.style.display = isEnabled ? '' : 'none'; + } + + // Setup summary update listeners for existing custom sections + // Setup summary listeners for custom sections + const customSectionItems = modalInstance.dialogContent.querySelectorAll('.customSection_section_item'); + customSectionItems.forEach(sectionItem => { + const sectionIndex = sectionItem.getAttribute('data-section-index'); + if (sectionIndex !== null) { + setupCustomSectionSummaryListeners(sectionItem, sectionIndex); } - if (trendingContainer) { - trendingContainer.style.display = isEnabled ? '' : 'none'; + }); + + // Setup summary listeners for seasonal nested sections + const seasonalSectionItems = modalInstance.dialogContent.querySelectorAll('.seasonal_season_section_item'); + seasonalSectionItems.forEach(sectionItem => { + const sectionIndex = sectionItem.getAttribute('data-section-index'); + if (sectionIndex !== null) { + setupCustomSectionSummaryListeners(sectionItem, sectionIndex); } + }); + + // Setup order update listeners for home screen sections + setupHomeSectionOrderListeners(); + + // Setup order update listeners for recently released subsections + setupRecentlyReleasedOrderListeners(); + + // Setup order update listeners for seasonal seasons + setupSeasonalSeasonOrderListeners(); + + // Setup order update listeners for discovery section types + setupDiscoverySectionTypeOrderListeners(); + + // Setup name update listeners for seasonal seasons + setupSeasonalSeasonNameListeners(); + }, 100); + + // Setup event listeners to update home section order displays + function setupHomeSectionOrderListeners() { + const orderSpans = modalInstance.dialogContent.querySelectorAll('.home-section-order'); + orderSpans.forEach(span => { + const prefix = span.getAttribute('data-prefix'); + if (prefix) { + const orderInput = modalInstance.dialogContent.querySelector(`#${prefix}_order`); + if (orderInput) { + orderInput.addEventListener('input', () => { + span.textContent = orderInput.value || '100'; + }); + } + } + }); + } + + // Setup event listeners to update recently released subsection order displays + function setupRecentlyReleasedOrderListeners() { + const moviesOrderSpan = modalInstance.dialogContent.querySelector('.recently-released-movies-order'); + const moviesOrderInput = modalInstance.dialogContent.querySelector('#homeScreen_recentlyReleased_movies_order'); + if (moviesOrderSpan && moviesOrderInput) { + moviesOrderInput.addEventListener('input', () => { + moviesOrderSpan.textContent = moviesOrderInput.value || '30'; + }); + } + + const episodesOrderSpan = modalInstance.dialogContent.querySelector('.recently-released-episodes-order'); + const episodesOrderInput = modalInstance.dialogContent.querySelector('#homeScreen_recentlyReleased_episodes_order'); + if (episodesOrderSpan && episodesOrderInput) { + episodesOrderInput.addEventListener('input', () => { + episodesOrderSpan.textContent = episodesOrderInput.value || '31'; + }); + } + + // Setup enabled status listeners + const moviesEnabledSpan = modalInstance.dialogContent.querySelector('.recently-released-movies-enabled'); + const moviesEnabledCheckbox = modalInstance.dialogContent.querySelector('#homeScreen_recentlyReleased_movies_enabled'); + if (moviesEnabledSpan && moviesEnabledCheckbox) { + moviesEnabledCheckbox.addEventListener('change', () => { + moviesEnabledSpan.textContent = moviesEnabledCheckbox.checked ? 'Enabled' : 'Disabled'; + }); + } + + const episodesEnabledSpan = modalInstance.dialogContent.querySelector('.recently-released-episodes-enabled'); + const episodesEnabledCheckbox = modalInstance.dialogContent.querySelector('#homeScreen_recentlyReleased_episodes_enabled'); + if (episodesEnabledSpan && episodesEnabledCheckbox) { + episodesEnabledCheckbox.addEventListener('change', () => { + episodesEnabledSpan.textContent = episodesEnabledCheckbox.checked ? 'Enabled' : 'Disabled'; + }); + } + } + + // Setup event listeners to update seasonal season order displays + function setupSeasonalSeasonOrderListeners() { + const orderInputs = modalInstance.dialogContent.querySelectorAll('.seasonal-season-order-input'); + orderInputs.forEach(input => { + const seasonIndex = input.getAttribute('data-index'); + const orderSpan = modalInstance.dialogContent.querySelector(`.seasonal-season-order[data-season-index="${seasonIndex}"]`); + if (orderSpan) { + input.addEventListener('input', () => { + orderSpan.textContent = input.value || '100'; + }); + } + }); + + // Setup enabled status listeners + const enabledCheckboxes = modalInstance.dialogContent.querySelectorAll('.seasonal-season-enabled'); + enabledCheckboxes.forEach(checkbox => { + const seasonIndex = checkbox.getAttribute('data-index'); + const enabledSpan = modalInstance.dialogContent.querySelector(`.seasonal-season-enabled-display[data-season-index="${seasonIndex}"]`); + if (enabledSpan) { + checkbox.addEventListener('change', () => { + enabledSpan.textContent = checkbox.checked ? 'Enabled' : 'Disabled'; + }); + } + }); + } + + // Setup event listeners to update discovery section type order displays + function setupDiscoverySectionTypeOrderListeners() { + DISCOVERY_SECTION_DEFINITIONS.forEach(section => { + const prefix = `homeScreen_discovery_sectionTypes_${section.key}`; + const orderInput = modalInstance.dialogContent.querySelector(`#${prefix}_order`); + const orderSpan = modalInstance.dialogContent.querySelector(`.discovery-section-type-order[data-section-key="${section.key}"]`); + if (orderInput && orderSpan) { + orderInput.addEventListener('input', () => { + orderSpan.textContent = orderInput.value || '100'; + }); + } + + // Setup enabled status listeners + const enabledCheckbox = modalInstance.dialogContent.querySelector(`#${prefix}_enabled`); + const enabledSpan = modalInstance.dialogContent.querySelector(`.discovery-section-type-enabled[data-section-key="${section.key}"]`); + if (enabledCheckbox && enabledSpan) { + enabledCheckbox.addEventListener('change', () => { + enabledSpan.textContent = enabledCheckbox.checked ? 'Enabled' : 'Disabled'; + }); + } + }); + } + + // Setup event listeners to update seasonal season name displays + function setupSeasonalSeasonNameListeners() { + const nameInputs = modalInstance.dialogContent.querySelectorAll('.seasonal-season-name'); + nameInputs.forEach(input => { + const seasonIndex = input.getAttribute('data-index'); + const nameSpan = modalInstance.dialogContent.querySelector(`.seasonal-season-name-display[data-season-index="${seasonIndex}"]`); + if (nameSpan) { + input.addEventListener('input', () => { + nameSpan.textContent = input.value || 'Unnamed Season'; + }); + } + }); + } + + // Section management functions + async function addSectionToSeasonalSeason(seasonIndex) { + const sectionsList = modalInstance.dialogContent.querySelector(`.seasonal_season_sections_list[data-season-index="${seasonIndex}"]`); + if (!sectionsList) return; + + const sectionIndex = sectionsList.querySelectorAll('.seasonal_season_section_item').length; + const fullIndex = `${seasonIndex}_${sectionIndex}`; + const newSection = { + id: `seasonal-${seasonIndex}-section-${sectionIndex}`, + enabled: true, + name: 'New Section', + type: 'Genre', + source: '', + itemLimit: 16, + sortOrder: 'Random', + sortOrderDirection: 'Ascending', + cardFormat: 'Poster', + order: 100 }; - enableNewAndTrendingCheckbox.addEventListener('change', toggleNewMoviesEpisodesTrendingVisibility); + const sectionHtml = buildSectionUI('seasonal_season', newSection, fullIndex, true); + sectionsList.insertAdjacentHTML('beforeend', sectionHtml); + + // Initialize the new section's source field + const newSectionItem = sectionsList.querySelector(`.seasonal_season_section_item[data-section-index="${fullIndex}"]`); + if (newSectionItem) { + // Setup summary listeners for the new section + setupCustomSectionSummaryListeners(newSectionItem, fullIndex); + + const typeSelect = newSectionItem.querySelector('.seasonal_season_section_type'); + if (typeSelect) { + const type = typeSelect.value; + if (type === 'Parent') { + // Setup Parent type input sync + const parentInput = newSectionItem.querySelector(`.seasonal_season_section_source[data-section-index="${fullIndex}"]`); + const parentHidden = newSectionItem.querySelector(`.seasonal_season_section_source_hidden[data-section-index="${fullIndex}"]`); + if (parentInput && parentHidden) { + parentInput.addEventListener('input', () => { + parentHidden.value = parentInput.value; + }); + } + } else { + await setupBadgeAutocomplete('seasonal_season', fullIndex, type); + if (type === 'Collection' || type === 'Playlist') { + await loadBadgeNames('seasonal_season', fullIndex, type); + } + } + } + + // Initialize Include Item Types autocomplete + const includeItemTypesContainer = newSectionItem.querySelector(`.includeItemTypes-badge-container[data-section-index="${fullIndex}"]`); + if (includeItemTypesContainer) { + const availableTypes = includeItemTypesContainer.getAttribute('data-available-types')?.split(',').filter(Boolean) || []; + setupIncludeItemTypesDropdown('seasonal_season', fullIndex, availableTypes); + } + } + }; + + async function addCustomSection() { + const sectionsList = modalInstance.dialogContent.querySelector('#customSections_list'); + if (!sectionsList) return; + + const sectionIndex = sectionsList.querySelectorAll('.customSection_section_item').length; + const newSection = { + id: `custom-section-${sectionIndex}`, + enabled: true, + name: 'New Custom Section', + type: 'Collection', + source: '', + itemLimit: 16, + sortOrder: 'Random', + sortOrderDirection: 'Ascending', + cardFormat: 'Poster', + order: 100, + spotlight: false, + discoveryEnabled: false + }; + + const sectionHtml = buildSectionUI('customSection', newSection, sectionIndex, false); + sectionsList.insertAdjacentHTML('beforeend', sectionHtml); + + // Initialize the new section's source field + const newSectionItem = sectionsList.querySelector(`.customSection_section_item[data-section-index="${sectionIndex}"]`); + if (newSectionItem) { + const typeSelect = newSectionItem.querySelector('.customSection_section_type'); + if (typeSelect) { + const type = typeSelect.value; + if (type === 'Parent') { + // Setup Parent type input sync + const parentInput = newSectionItem.querySelector(`.customSection_section_source[data-section-index="${sectionIndex}"]`); + const parentHidden = newSectionItem.querySelector(`.customSection_section_source_hidden[data-section-index="${sectionIndex}"]`); + if (parentInput && parentHidden) { + parentInput.addEventListener('input', () => { + parentHidden.value = parentInput.value; + }); + } + } else { + await setupBadgeAutocomplete('customSection', sectionIndex, type); + if (type === 'Collection' || type === 'Playlist') { + await loadBadgeNames('customSection', sectionIndex, type); + } + } + } + // Setup summary update listeners for custom sections + setupCustomSectionSummaryListeners(newSectionItem, sectionIndex); + + // Initialize Include Item Types autocomplete + const includeItemTypesContainer = newSectionItem.querySelector(`.includeItemTypes-badge-container[data-section-index="${sectionIndex}"]`); + if (includeItemTypesContainer) { + const availableTypes = includeItemTypesContainer.getAttribute('data-available-types')?.split(',').filter(Boolean) || []; + setupIncludeItemTypesDropdown('customSection', sectionIndex, availableTypes); + } + } + }; + + // Setup event listeners to update section summary when name/order changes + // Works for both custom sections and seasonal nested sections + function setupCustomSectionSummaryListeners(sectionItem, sectionIndex) { + const prefix = sectionItem.classList.contains('customSection_section_item') ? 'customSection' : 'seasonal_season'; + const sanitizedIndex = sectionIndex.toString().replace(/[^a-zA-Z0-9]/g, '_'); + + // Find inputs based on prefix + const nameInput = sectionItem.querySelector(`.${prefix}_section_name[data-section-index="${sectionIndex}"]`); + const orderInput = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_order`); + const enabledCheckbox = sectionItem.querySelector(`#${prefix}_section_enabled_${sanitizedIndex}`) || sectionItem.querySelector(`.${prefix}_section_enabled[data-section-index="${sectionIndex}"]`); + + // Find summary display spans based on prefix + const nameClass = prefix === 'customSection' ? 'custom-section-name' : 'seasonal-section-name'; + const orderClass = prefix === 'customSection' ? 'custom-section-order' : 'seasonal-section-order'; + const enabledClass = prefix === 'customSection' ? 'custom-section-enabled-display' : 'seasonal-section-enabled-display'; + + const nameSpan = sectionItem.querySelector(`.${nameClass}[data-section-index="${sectionIndex}"]`); + const orderSpan = sectionItem.querySelector(`.${orderClass}[data-section-index="${sectionIndex}"]`); + const enabledSpan = sectionItem.querySelector(`.${enabledClass}[data-section-index="${sectionIndex}"]`); + + if (nameInput && nameSpan) { + nameInput.addEventListener('input', () => { + nameSpan.textContent = nameInput.value || 'Unnamed Section'; + }); + } + + if (orderInput && orderSpan) { + orderInput.addEventListener('input', () => { + orderSpan.textContent = orderInput.value || '100'; + }); + } + + if (enabledCheckbox && enabledSpan) { + enabledCheckbox.addEventListener('change', () => { + enabledSpan.textContent = enabledCheckbox.checked ? 'Enabled' : 'Disabled'; + }); + } } + + function deleteSection(prefix, sectionIndex, isSeasonalNested) { + // Try both string and number comparison for data-section-index + const sectionItem = modalInstance.dialogContent.querySelector( + `.${prefix}_section_item[data-section-index="${sectionIndex}"], .${prefix}_section_item[data-section-index='${sectionIndex}']` + ); + if (sectionItem && confirm('Are you sure you want to delete this section?')) { + sectionItem.remove(); + + // For seasonal sections, sectionIndex is compound (e.g., "0_1"), need special handling + if (isSeasonalNested && typeof sectionIndex === 'string' && sectionIndex.includes('_')) { + const [seasonIndex, deletedSectionIndex] = sectionIndex.split('_'); + const sectionsList = modalInstance.dialogContent.querySelector(`.seasonal_season_sections_list[data-season-index="${seasonIndex}"]`); + if (sectionsList) { + // Re-index only sections within the same season + const remainingItems = Array.from(sectionsList.querySelectorAll(`.${prefix}_section_item`)) + .sort((a, b) => { + const aIdx = a.getAttribute('data-section-index'); + const bIdx = b.getAttribute('data-section-index'); + if (!aIdx || !bIdx) return 0; + // Extract section index part (after underscore) + const aSectionIdx = aIdx.includes('_') ? parseInt(aIdx.split('_')[1]) : parseInt(aIdx); + const bSectionIdx = bIdx.includes('_') ? parseInt(bIdx.split('_')[1]) : parseInt(bIdx); + return aSectionIdx - bSectionIdx; + }); + + remainingItems.forEach((item, newSectionIndex) => { + const newFullIndex = `${seasonIndex}_${newSectionIndex}`; + const oldIndex = item.getAttribute('data-section-index'); + item.setAttribute('data-section-index', newFullIndex); + + // Update all inputs within this section + item.querySelectorAll(`[data-section-index="${oldIndex}"]`).forEach(el => { + el.setAttribute('data-section-index', newFullIndex); + }); + + // Update summary displays + const summaryName = item.querySelector(`.seasonal-section-name[data-section-index="${oldIndex}"]`); + if (summaryName) { + summaryName.setAttribute('data-section-index', newFullIndex); + } + const summaryEnabled = item.querySelector(`.seasonal-section-enabled-display[data-section-index="${oldIndex}"]`); + if (summaryEnabled) { + summaryEnabled.setAttribute('data-section-index', newFullIndex); + } + const summaryOrder = item.querySelector(`.seasonal-section-order[data-section-index="${oldIndex}"]`); + if (summaryOrder) { + summaryOrder.setAttribute('data-section-index', newFullIndex); + } + + // Update delete and preview button data attributes + const deleteBtn = item.querySelector('.delete-section-btn'); + if (deleteBtn) { + deleteBtn.setAttribute('data-section-index', newFullIndex); + } + const previewBtn = item.querySelector('.preview-section-btn'); + if (previewBtn) { + previewBtn.setAttribute('data-section-index', newFullIndex); + } + }); + } + } else { + // Regular custom sections - simple re-indexing + const remainingItems = Array.from(modalInstance.dialogContent.querySelectorAll(`.${prefix}_section_item`)) + .sort((a, b) => { + const aIndex = parseInt(a.getAttribute('data-section-index')) || 0; + const bIndex = parseInt(b.getAttribute('data-section-index')) || 0; + return aIndex - bIndex; + }); + + remainingItems.forEach((item, newIndex) => { + const oldIndex = item.getAttribute('data-section-index'); + item.setAttribute('data-section-index', newIndex); + + // Update all inputs within this section + item.querySelectorAll(`[data-section-index="${oldIndex}"]`).forEach(el => { + el.setAttribute('data-section-index', newIndex); + }); + + // Update summary displays that reference section index + const summaryName = item.querySelector(`.custom-section-name[data-section-index="${oldIndex}"]`); + if (summaryName) { + summaryName.setAttribute('data-section-index', newIndex); + } + const summaryEnabled = item.querySelector(`.custom-section-enabled-display[data-section-index="${oldIndex}"]`); + if (summaryEnabled) { + summaryEnabled.setAttribute('data-section-index', newIndex); + } + const summaryOrder = item.querySelector(`.custom-section-order[data-section-index="${oldIndex}"]`); + if (summaryOrder) { + summaryOrder.setAttribute('data-section-index', newIndex); + } + + // Update delete button data attribute + const deleteBtn = item.querySelector('.delete-section-btn'); + if (deleteBtn) { + deleteBtn.setAttribute('data-section-index', newIndex); + } + const previewBtn = item.querySelector('.preview-section-btn'); + if (previewBtn) { + previewBtn.setAttribute('data-section-index', newIndex); + } + }); + } + } + }; + + async function previewSection(prefix, sectionIndex) { + if (!window.ModalSystem) { + alert('Modal system not available. Please ensure modal.js is loaded.'); + return; + } + + const sectionConfig = getSectionConfigFromUI(modalInstance.dialogContent, prefix, sectionIndex); + if (!sectionConfig) { + alert('Unable to build preview for this section. Please check the configuration values.'); + return; + } + + const modalId = `sectionPreview_${prefix}_${sectionIndex}_${Date.now()}`; + const contentId = `${modalId}_content`; + + const previewModal = window.ModalSystem.create({ + id: modalId, + title: `Preview: ${sectionConfig.name || 'Custom Section'}`, + content: ` +
Preparing preview...
+ + `, + footer: ` + + `, + closeOnBackdrop: true, + closeOnEscape: true, + size: 'large', + onOpen: async (previewInstance) => { + previewInstance.dialogContent.style.width = '1400px'; - // Home Screen: Toggle visibility of Infinite Scroll based on Discovery - const enableDiscoveryCheckbox = modalInstance.dialogContent.querySelector('#homeScreen_enableDiscovery'); - if (enableDiscoveryCheckbox) { - const infiniteScrollContainer = modalInstance.dialogContent.querySelector('#homeScreen_infiniteScrollContainer'); + const closeBtn = previewInstance.dialogFooter?.querySelector(`#${modalId}_closeBtn`); + if (closeBtn) { + closeBtn.addEventListener('click', () => previewInstance.close()); + } + + const container = previewInstance.dialogContent.querySelector(`#${contentId}`); + if (!container) { + return; + } + + container.innerHTML = `
Loading preview...
`; + + try { + const items = await fetchItemsForSectionPreview(sectionConfig); + if (!items || items.length === 0) { + container.innerHTML = `
No items match this configuration yet.
`; + return; + } + + if (!window.cardBuilder) { + container.innerHTML = `
Card builder utilities are not available.
`; + return; + } + + let previewElement = null; + if (sectionConfig.spotlight && typeof window.cardBuilder.renderSpotlightSection === 'function') { + previewElement = window.cardBuilder.renderSpotlightSection(items, sectionConfig.name || 'Preview', { + autoPlay: false, + showDots: true, + showNavButtons: true, + viewMoreUrl: null + }); + } else if (typeof window.cardBuilder.renderCards === 'function') { + previewElement = window.cardBuilder.renderCards( + items, + sectionConfig.name || 'Preview', + null, + true, + sectionConfig.cardFormat || 'Poster', + sectionConfig.sortOrder || 'Random', + sectionConfig.sortOrderDirection || 'Ascending' + ); + } + + container.innerHTML = ''; + if (previewElement) { + container.appendChild(previewElement); + } else { + container.innerHTML = `
Unable to render preview with the current configuration.
`; + } + } catch (error) { + console.error('[KefinTweaks Configuration] Error rendering preview:', error); + container.innerHTML = `
Failed to load preview: ${error?.message || error}
`; + } + } + }); - const toggleInfiniteScrollVisibility = () => { - const isEnabled = enableDiscoveryCheckbox.checked; - if (infiniteScrollContainer) { - infiniteScrollContainer.style.display = isEnabled ? '' : 'none'; + previewModal.open(); + } + + async function updateSectionSourceField(prefix, sectionIndex, isSeasonalNested) { + const sectionItem = modalInstance.dialogContent.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`); + if (!sectionItem) { + console.warn(`[KefinTweaks Configuration] Section item not found for prefix: ${prefix}, index: ${sectionIndex}`); + return; + } + + const typeSelect = sectionItem.querySelector(`.${prefix}_section_type[data-section-index="${sectionIndex}"]`); + if (!typeSelect) { + console.warn(`[KefinTweaks Configuration] Type select not found for prefix: ${prefix}, index: ${sectionIndex}`); + return; + } + const type = typeSelect.value || 'Genre'; + + // Find the source field container using the specific class + const sourceContainer = sectionItem.querySelector(`.${prefix}_section_source_container[data-section-index="${sectionIndex}"]`); + if (!sourceContainer) { + console.warn(`[KefinTweaks Configuration] Source container not found for prefix: ${prefix}, index: ${sectionIndex}`); + return; + } + + // Get current source value + const currentSourceInput = sectionItem.querySelector(`.${prefix}_section_source[data-section-index="${sectionIndex}"]`); + const badgeContainer = sourceContainer?.querySelector(`.tag-badge-container[data-section-index="${sectionIndex}"][data-type]`) || null; + let currentSource = ''; + + if (badgeContainer) { + // Extract from existing badges + const badges = badgeContainer.querySelectorAll('.tag-badge'); + currentSource = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean).join(', '); + } else if (currentSourceInput) { + // Extract from dropdown/input + currentSource = currentSourceInput.value || ''; + } + + // Clear badges whenever the source type changes + // This ensures a clean state when switching between any types + const currentType = currentSourceInput?.dataset?.type || badgeContainer?.dataset?.type; + if (currentType && currentType !== type) { + currentSource = ''; + } + + let newSourceHtml = ''; + + if (type === 'Parent') { + // Parent type uses simple text input + newSourceHtml = ` +
+
Parent IDs (comma-separated, leave empty for "Any")
+ + +
+ `; + } else { + // Build new badge-based UI for other types + const sourceArray = currentSource ? currentSource.split(',').map(s => s.trim()).filter(Boolean) : []; + const badgesHtml = sourceArray.map(val => { + if (type === 'Tag' || type === 'Genre') { + return ` + ${val} + + `; + } else { + return ` + ${val} + + `; + } + }).join(''); + + newSourceHtml = ` +
+
+
${type}
+ +
+
+ +
+ ${badgesHtml} +
+
+ + +
+ `; + } + + sourceContainer.innerHTML = newSourceHtml; + + // Setup event listener for Parent type to sync input with hidden field + if (type === 'Parent') { + const parentInput = sourceContainer.querySelector(`.${prefix}_section_source[data-section-index="${sectionIndex}"]`); + const parentHidden = sourceContainer.querySelector(`.${prefix}_section_source_hidden[data-section-index="${sectionIndex}"]`); + if (parentInput && parentHidden) { + parentInput.addEventListener('input', () => { + parentHidden.value = parentInput.value; + }); + } + } else { + // Initialize autocomplete for other types + await setupBadgeAutocomplete(prefix, sectionIndex, type); + + // Load names for Collections/Playlists + if (type === 'Collection' || type === 'Playlist') { + await loadBadgeNames(prefix, sectionIndex, type); + } + } + + // Update Include Item Types field to reflect available types based on source type + const includeItemTypesContainer = sectionItem.querySelector(`.${prefix}_section_includeItemTypes_container[data-section-index="${sectionIndex}"]`); + if (includeItemTypesContainer) { + const currentBadges = includeItemTypesContainer.querySelectorAll('.tag-badge'); + const currentTypes = Array.from(currentBadges).map(badge => badge.getAttribute('data-value')).filter(Boolean); + const availableTypes = type === 'Parent' + ? ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum', 'Audio', 'MusicVideo', 'Book', 'BoxSet', 'Playlist'] + : ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum', 'Audio', 'MusicVideo', 'Book']; + + // Remove invalid types (types not available for current source type) + const validTypes = currentTypes.filter(t => availableTypes.includes(t)); + + // Rebuild the Include Item Types field + const sanitizedPrefix = `${prefix}_section_${sectionIndex.toString().replace(/[^a-zA-Z0-9]/g, '_')}`; + const newIncludeItemTypesHtml = buildIncludeItemTypesField(prefix, sectionIndex, sanitizedPrefix, validTypes, type); + includeItemTypesContainer.outerHTML = newIncludeItemTypesHtml; + + // Re-initialize autocomplete for Include Item Types + const newContainer = sectionItem.querySelector(`.includeItemTypes-badge-container[data-section-index="${sectionIndex}"]`); + if (newContainer) { + setupIncludeItemTypesDropdown(prefix, sectionIndex, availableTypes); + } + } + } + + // Generic badge management system + const BadgeSystem = { + /** + * Creates a badge element + * @param {string} value - The value to store (ID for collections/playlists, name for tags/genres) + * @param {string} displayText - The text to display + * @returns {HTMLElement} Badge element + */ + createBadge(value, displayText) { + const badge = document.createElement('span'); + badge.className = 'tag-badge'; + badge.setAttribute('data-value', value); + badge.style.cssText = 'display: inline-flex; align-items: center; gap: 0.25em; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.25em 0.5em; margin: 0.25em; font-size: 0.9em;'; + badge.innerHTML = `${displayText}`; + return badge; + }, + + /** + * Adds a badge to the container + */ + addBadge(prefix, sectionIndex, value, displayText, badgeContainer, input, hiddenInput) { + // Check if badge already exists + const existingBadges = badgeContainer.querySelectorAll('.tag-badge'); + const alreadyExists = Array.from(existingBadges).some(badge => + badge.getAttribute('data-value') === value + ); + if (alreadyExists) return; + + // Find the badges wrapper div (sibling of the input in new layout, or container in old layout) + const badgesWrapper = input.nextElementSibling && input.nextElementSibling.matches('div') + ? input.nextElementSibling + : badgeContainer; + + const badge = this.createBadge(value, displayText); + badgesWrapper.appendChild(badge); + input.value = ''; + this.updateHiddenInput(prefix, sectionIndex, badgeContainer, hiddenInput); + }, + + /** + * Removes a badge from the container + */ + removeBadge(prefix, sectionIndex, badge, badgeContainer, hiddenInput) { + badge.remove(); + this.updateHiddenInput(prefix, sectionIndex, badgeContainer, hiddenInput); + }, + + /** + * Removes all badges from the container + */ + clearAll(prefix, sectionIndex, badgeContainer, hiddenInput) { + const badges = badgeContainer.querySelectorAll('.tag-badge'); + badges.forEach(badge => badge.remove()); + this.updateHiddenInput(prefix, sectionIndex, badgeContainer, hiddenInput); + }, + + /** + * Updates hidden input from badges + */ + updateHiddenInput(prefix, sectionIndex, badgeContainer, hiddenInput) { + const badges = badgeContainer.querySelectorAll('.tag-badge'); + const values = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean); + if (hiddenInput) { + hiddenInput.value = values.join(', '); + } + }, + + /** + * Gets existing badge values (for deduplication) + */ + getExistingBadges(badgeContainer) { + const badges = badgeContainer.querySelectorAll('.tag-badge'); + return Array.from(badges).map(badge => badge.getAttribute('data-value').toLowerCase()); + } + }; + + // Generic autocomplete setup for badge-based inputs + // Supports: Tag, Genre, Collection, Playlist + async function setupBadgeAutocomplete(prefix, sectionIndex, type) { + const badgeContainer = modalInstance.dialogContent.querySelector( + `.tag-badge-container[data-section-index="${sectionIndex}"][data-type="${type}"]` + ); + const input = modalInstance.dialogContent.querySelector( + `.${prefix}_section_source.autocomplete-input[data-section-index="${sectionIndex}"]` + ); + const suggestionsDiv = modalInstance.dialogContent.querySelector( + `.autocomplete-suggestions[data-section-index="${sectionIndex}"]` + ); + const hiddenInput = modalInstance.dialogContent.querySelector( + `.${prefix}_section_source_hidden[data-section-index="${sectionIndex}"]` + ); + const clearAllBtn = modalInstance.dialogContent.querySelector( + `.badge-clear-all[data-section-index="${sectionIndex}"]` + ); + + if (!badgeContainer || !input || !suggestionsDiv) return; + + // Setup badge remove handlers + badgeContainer.addEventListener('click', (e) => { + if (e.target.classList.contains('tag-badge-remove') || e.target.closest('.tag-badge-remove')) { + const badge = e.target.closest('.tag-badge'); + if (badge) { + BadgeSystem.removeBadge(prefix, sectionIndex, badge, badgeContainer, hiddenInput); + } + } + }); + + // Setup clear all button + if (clearAllBtn) { + clearAllBtn.addEventListener('click', () => { + BadgeSystem.clearAll(prefix, sectionIndex, badgeContainer, hiddenInput); + }); + } + + // Fetch available options based on type + let options = []; + try { + const userId = window.ApiClient.getCurrentUserId(); + const serverAddress = window.ApiClient.serverAddress(); + const token = window.ApiClient.accessToken(); + + if (type === 'Tag' || type === 'Genre') { + // Fetch Tags/Genres using Filters endpoint with caching + const librariesData = await window.apiHelper.getItems({}, true, 300000); + const libraries = librariesData.Items || []; + const movieLibrary = libraries.find(lib => lib.CollectionType === 'movies'); + const parentId = movieLibrary ? movieLibrary.Id : null; + const url = `${serverAddress}/Items/Filters?UserId=${userId}&IncludeItemTypes=Movie${parentId ? `&ParentId=${parentId}` : ''}`; + const data = await window.apiHelper.getData(url, true, 300000); + options = type === 'Tag' ? (data.Tags || []) : (data.Genres || []); + } else if (type === 'Collection' || type === 'Playlist') { + // Fetch Collections/Playlists using getItems with caching + const itemType = type === 'Playlist' ? 'Playlist' : 'BoxSet,CollectionFolder'; + const data = await window.apiHelper.getItems( + { + IncludeItemTypes: itemType, + Recursive: true, + Fields: 'ItemCounts' + }, + true, + 300000 + ); + options = (data.Items || []).map(item => ({ + id: item.Id, + name: item.Name + })); + } + } catch (err) { + console.error('[KefinTweaks Configuration] Error fetching options:', err); + } + + let currentSuggestions = []; + let selectedIndex = -1; + + const getExistingBadges = () => BadgeSystem.getExistingBadges(badgeContainer); + + input.addEventListener('input', (e) => { + const value = e.target.value.trim(); + if (value.length === 0) { + suggestionsDiv.style.display = 'none'; + return; + } + + const existing = getExistingBadges(); + + // Filter options based on type + if (type === 'Tag' || type === 'Genre') { + currentSuggestions = options.filter(opt => + opt.toLowerCase().includes(value.toLowerCase()) && + !existing.includes(opt.toLowerCase()) + ).slice(0, 10); + } else { + // Collections/Playlists: search by name, exclude by ID + currentSuggestions = options.filter(opt => + opt.name.toLowerCase().includes(value.toLowerCase()) && + !existing.includes(opt.id.toLowerCase()) + ).slice(0, 10); + } + + if (currentSuggestions.length > 0) { + suggestionsDiv.innerHTML = currentSuggestions.map((opt, idx) => { + const display = type === 'Tag' || type === 'Genre' ? opt : opt.name; + const dataValue = type === 'Tag' || type === 'Genre' ? opt : opt.id; + return `
${display}
`; + }).join(''); + suggestionsDiv.style.display = 'block'; + selectedIndex = -1; + // Set up mouse event handlers for each suggestion + const suggestionItems = suggestionsDiv.querySelectorAll('.autocomplete-suggestion'); + suggestionItems.forEach((item, idx) => { + item.addEventListener('mouseenter', () => { + // Clear keyboard selection + selectedIndex = -1; + updateSuggestionHighlight(); + // Highlight hovered item + item.style.background = 'rgba(255,255,255,0.1)'; + }); + item.addEventListener('mouseleave', () => { + item.style.background = ''; + }); + }); + } else { + suggestionsDiv.style.display = 'none'; + } + }); + + // Handle suggestion clicks + suggestionsDiv.addEventListener('click', (e) => { + const suggestion = e.target.closest('.autocomplete-suggestion'); + if (!suggestion) return; + + const selectedValue = suggestion.getAttribute('data-value'); + const displayText = suggestion.getAttribute('data-display') || selectedValue; + + BadgeSystem.addBadge(prefix, sectionIndex, selectedValue, displayText, badgeContainer, input, hiddenInput); + suggestionsDiv.style.display = 'none'; + selectedIndex = -1; + input.focus(); + }); + + // Function to show all available suggestions (excluding already selected) + function showAllSuggestions() { + const existing = getExistingBadges(); + + // Filter options based on type, excluding already selected + if (type === 'Tag' || type === 'Genre') { + currentSuggestions = options.filter(opt => + !existing.includes(opt.toLowerCase()) + ).slice(0, 30); + } else { + // Collections/Playlists: exclude by ID + currentSuggestions = options.filter(opt => + !existing.includes(opt.id.toLowerCase()) + ).slice(0, 30); + } + + if (currentSuggestions.length > 0) { + suggestionsDiv.innerHTML = currentSuggestions.map((opt, idx) => { + const display = type === 'Tag' || type === 'Genre' ? opt : opt.name; + const dataValue = type === 'Tag' || type === 'Genre' ? opt : opt.id; + return `
${display}
`; + }).join(''); + suggestionsDiv.style.display = 'block'; + selectedIndex = 0; + updateSuggestionHighlight(); + // Set up mouse event handlers for each suggestion + const suggestionItems = suggestionsDiv.querySelectorAll('.autocomplete-suggestion'); + suggestionItems.forEach((item, idx) => { + item.addEventListener('mouseenter', () => { + // Clear keyboard selection + selectedIndex = -1; + updateSuggestionHighlight(); + // Highlight hovered item + item.style.background = 'rgba(255,255,255,0.1)'; + }); + item.addEventListener('mouseleave', () => { + item.style.background = ''; + }); + }); + } else { + suggestionsDiv.style.display = 'none'; + } + } + + // Handle keyboard navigation + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + // If input is empty and no suggestions shown, show all suggestions + if (input.value.trim().length === 0 && suggestionsDiv.style.display === 'none') { + showAllSuggestions(); + return; + } + // If suggestions are shown, navigate + if (currentSuggestions.length > 0) { + selectedIndex = Math.min(selectedIndex + 1, currentSuggestions.length - 1); + updateSuggestionHighlight(); + } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (currentSuggestions.length > 0) { + selectedIndex = Math.max(selectedIndex - 1, -1); + updateSuggestionHighlight(); + // If we go above the first item, hide suggestions + if (selectedIndex < 0) { + suggestionsDiv.style.display = 'none'; + } + } + } else if (e.key === 'Enter') { + e.preventDefault(); + if (selectedIndex >= 0 && currentSuggestions[selectedIndex]) { + const selected = currentSuggestions[selectedIndex]; + const value = type === 'Tag' || type === 'Genre' ? selected : selected.id; + const display = type === 'Tag' || type === 'Genre' ? selected : selected.name; + BadgeSystem.addBadge(prefix, sectionIndex, value, display, badgeContainer, input, hiddenInput); + suggestionsDiv.style.display = 'none'; + selectedIndex = -1; + } + // No free-form entry - require autocomplete selection + } else if (e.key === 'Escape') { + suggestionsDiv.style.display = 'none'; + selectedIndex = -1; + } else if (e.key === 'Backspace' && input.value === '') { + const badges = badgeContainer.querySelectorAll('.tag-badge'); + if (badges.length > 0) { + const lastBadge = badges[badges.length - 1]; + BadgeSystem.removeBadge(prefix, sectionIndex, lastBadge, badgeContainer, hiddenInput); + } + } + }); + + function updateSuggestionHighlight() { + const items = suggestionsDiv.querySelectorAll('.autocomplete-suggestion'); + items.forEach((item, idx) => { + if (idx === selectedIndex) { + item.style.background = 'rgba(0, 122, 255, 0.3)'; + item.style.borderLeft = '3px solid rgba(0, 122, 255, 0.8)'; + item.style.fontWeight = '500'; + // Scroll into view if needed + item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } else { + item.style.background = ''; + item.style.borderLeft = '3px solid transparent'; + item.style.fontWeight = ''; + } + }); + } + + // Hide suggestions when clicking outside + document.addEventListener('click', (e) => { + if (!badgeContainer.contains(e.target) && !suggestionsDiv.contains(e.target)) { + suggestionsDiv.style.display = 'none'; + } + }); + } + + // Loads and updates badge display text for Collections/Playlists + // Replaces IDs with names after fetching from API + async function loadBadgeNames(prefix, sectionIndex, type) { + if (type !== 'Collection' && type !== 'Playlist') return; + + const badgeContainer = modalInstance.dialogContent.querySelector( + `.tag-badge-container[data-section-index="${sectionIndex}"][data-type="${type}"]` + ); + if (!badgeContainer) return; + + const badges = badgeContainer.querySelectorAll('.tag-badge[data-loading="true"]'); + if (badges.length === 0) return; + + try { + const itemType = type === 'Playlist' ? 'Playlist' : 'BoxSet,CollectionFolder'; + const data = await window.apiHelper.getItems( + { + IncludeItemTypes: itemType, + Recursive: true, + Fields: 'ItemCounts' + }, + true, + 300000 + ); + + const items = data.Items || []; + const itemMap = new Map(items.map(item => [item.Id, item])); + + badges.forEach(badge => { + const id = badge.getAttribute('data-value'); + const item = itemMap.get(id); + if (item) { + const displayText = item.Name; + badge.innerHTML = `${displayText}`; + badge.removeAttribute('data-loading'); + } else { + // Item not found, keep ID display + badge.removeAttribute('data-loading'); + } + }); + } catch (err) { + console.error('[KefinTweaks Configuration] Error loading badge names:', err); + badges.forEach(badge => badge.removeAttribute('data-loading')); + } + } + + // Setup dropdown for Include Item Types field + function setupIncludeItemTypesDropdown(prefix, sectionIndex, availableTypes) { + const badgeContainer = modalInstance.dialogContent.querySelector( + `.includeItemTypes-badge-container[data-section-index="${sectionIndex}"]` + ); + const toggleButton = badgeContainer?.querySelector('.includeItemTypes-dropdown-toggle'); + const dropdown = badgeContainer?.querySelector('.includeItemTypes-dropdown'); + const checkboxNodes = badgeContainer ? Array.from(badgeContainer.querySelectorAll('.includeItemTypes-option')) : []; + const proxyInput = modalInstance.dialogContent.querySelector( + `.${prefix}_section_includeItemTypes[data-section-index="${sectionIndex}"]` + ); + const hiddenInput = modalInstance.dialogContent.querySelector( + `.${prefix}_section_includeItemTypes_hidden[data-section-index="${sectionIndex}"]` + ); + const badgesWrapper = badgeContainer?.querySelector('.includeItemTypes-badges'); + const emptyState = badgeContainer?.querySelector('.includeItemTypes-empty'); + const clearAllBtn = modalInstance.dialogContent.querySelector( + `.badge-clear-all[data-section-index="${sectionIndex}"][data-field="includeItemTypes"]` + ); + const allowedTypeSet = new Set((availableTypes || []).map(type => type.toLowerCase())); + + if (!badgeContainer || !dropdown || !proxyInput || !hiddenInput || !badgesWrapper) return; + + const closeDropdown = () => { + dropdown.style.display = 'none'; + toggleButton?.setAttribute('aria-expanded', 'false'); + }; + + const openDropdown = () => { + dropdown.style.display = 'block'; + toggleButton?.setAttribute('aria-expanded', 'true'); + }; + + toggleButton?.addEventListener('click', (e) => { + e.stopPropagation(); + const isOpen = dropdown.style.display === 'block'; + if (isOpen) { + closeDropdown(); + } else { + openDropdown(); + } + }); + + document.addEventListener('click', (e) => { + if (!badgeContainer.contains(e.target)) { + closeDropdown(); + } + }); + + const updateEmptyState = () => { + const hasBadges = badgesWrapper.querySelector('.tag-badge'); + if (hasBadges) { + emptyState?.setAttribute('hidden', 'true'); + } else { + emptyState?.removeAttribute('hidden'); } }; - enableDiscoveryCheckbox.addEventListener('change', toggleInfiniteScrollVisibility); + // Setup badge remove handlers + badgeContainer.addEventListener('click', (e) => { + if (e.target.classList.contains('tag-badge-remove') || e.target.closest('.tag-badge-remove')) { + const badge = e.target.closest('.tag-badge'); + if (badge) { + const value = badge.getAttribute('data-value'); + const checkbox = checkboxNodes.find(cb => cb.value === value); + if (checkbox) { + checkbox.checked = false; + } + BadgeSystem.removeBadge(prefix, sectionIndex, badge, badgeContainer, hiddenInput); + updateEmptyState(); + syncToggleLabel(); + } + } + }); + + const syncToggleLabel = () => { + const count = badgesWrapper.querySelectorAll('.tag-badge').length; + if (toggleButton) { + const textSpan = toggleButton.querySelector('.includeItemTypes-toggle-label'); + if (textSpan) { + textSpan.textContent = count > 0 ? `${count} selected` : 'Select item types'; + } + } + }; + + const handleCheckboxChange = (checkbox) => { + const value = checkbox.value; + if (!value || (allowedTypeSet.size && !allowedTypeSet.has(value.toLowerCase()))) return; + + if (checkbox.checked) { + BadgeSystem.addBadge(prefix, sectionIndex, value, value, badgeContainer, proxyInput, hiddenInput); + } else { + const badge = badgesWrapper.querySelector(`.tag-badge[data-value="${value}"]`); + if (badge) { + BadgeSystem.removeBadge(prefix, sectionIndex, badge, badgeContainer, hiddenInput); + } else { + BadgeSystem.updateHiddenInput(prefix, sectionIndex, badgeContainer, hiddenInput); + } + } + updateEmptyState(); + syncToggleLabel(); + }; + + checkboxNodes.forEach(checkbox => { + checkbox.addEventListener('change', () => handleCheckboxChange(checkbox)); + }); + + // Setup clear all button + if (clearAllBtn) { + clearAllBtn.addEventListener('click', () => { + checkboxNodes.forEach(cb => { + cb.checked = false; + }); + BadgeSystem.clearAll(prefix, sectionIndex, badgeContainer, hiddenInput); + updateEmptyState(); + syncToggleLabel(); + }); + } + + // Ensure badges match checked state on init + checkboxNodes.forEach(cb => { + if (cb.checked && !badgesWrapper.querySelector(`.tag-badge[data-value="${cb.value}"]`)) { + BadgeSystem.addBadge(prefix, sectionIndex, cb.value, cb.value, badgeContainer, proxyInput, hiddenInput); + } + }); + updateEmptyState(); + syncToggleLabel(); } + + // Seasonal season management functions (exposed globally for onclick handlers) + window.addNewSeasonalSeason = function() { + const seasonsList = modalInstance.dialogContent.querySelector('#homeScreen_seasonal_seasons_list'); + if (!seasonsList) return; + + const newIndex = seasonsList.querySelectorAll('.seasonal-season-item').length; + const newSeason = { + id: `season-${newIndex}`, + enabled: true, + name: 'New Season', + startDate: '', + endDate: '', + order: 100, + sections: [] + }; + + const newSeasonHtml = ` +
+ +
+ chevron_right + New Season + (Enabled, Order: 100) +
+
+ +
+
+
+
+
+
Enabled
+ ${buildJellyfinCheckbox(`seasonal-season-enabled-${newIndex}`, true, 'Enabled', { 'data-index': newIndex, 'class': 'seasonal-season-enabled' })} +
+
+
Name
+ +
+
+
Start Date (MM-DD)
+ +
+
+
End Date (MM-DD)
+ +
+
+
Order
+ +
+
+
+
Sections
+
+
+ +
+
+
+ `; + seasonsList.insertAdjacentHTML('beforeend', newSeasonHtml); + + // Setup listeners for the new season + const newSeasonItem = seasonsList.querySelector(`.seasonal-season-item[data-season-index="${newIndex}"]`); + if (newSeasonItem) { + const nameInput = newSeasonItem.querySelector(`.seasonal-season-name[data-index="${newIndex}"]`); + const nameSpan = newSeasonItem.querySelector(`.seasonal-season-name-display[data-season-index="${newIndex}"]`); + const orderInput = newSeasonItem.querySelector(`.seasonal-season-order-input[data-index="${newIndex}"]`); + const orderSpan = newSeasonItem.querySelector(`.seasonal-season-order[data-season-index="${newIndex}"]`); + const enabledCheckbox = newSeasonItem.querySelector(`#seasonal-season-enabled-${newIndex}`) || newSeasonItem.querySelector(`.seasonal-season-enabled[data-index="${newIndex}"]`); + const enabledSpan = newSeasonItem.querySelector(`.seasonal-season-enabled-display[data-season-index="${newIndex}"]`); + + if (nameInput && nameSpan) { + nameInput.addEventListener('input', () => { + nameSpan.textContent = nameInput.value || 'Unnamed Season'; + }); + } + + if (orderInput && orderSpan) { + orderInput.addEventListener('input', () => { + orderSpan.textContent = orderInput.value || '100'; + }); + } + + if (enabledCheckbox && enabledSpan) { + enabledCheckbox.addEventListener('change', () => { + enabledSpan.textContent = enabledCheckbox.checked ? 'Enabled' : 'Disabled'; + }); + } + } + }; + + // Collection and Playlist picker functions + window.openCollectionPicker = async function(seasonIndex) { + if (!window.ModalSystem) { + alert('Modal system not available'); + return; + } + + try { + // Fetch collections from server + const userId = window.ApiClient.getCurrentUserId(); + const serverAddress = window.ApiClient.serverAddress(); + const token = window.ApiClient.accessToken(); + + const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=BoxSet,CollectionFolder&Recursive=true&Fields=ItemCounts`, { + headers: { 'X-Emby-Token': token } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const collections = data.Items || []; + + // Get currently selected collections + const hiddenInput = modalInstance.dialogContent.querySelector(`.seasonal-season-collections[data-index="${seasonIndex}"]`); + const selectedIds = hiddenInput ? JSON.parse(hiddenInput.value || '[]') : []; + + // Create picker modal + let pickerModalInstanceRef = null; + const pickerModal = window.ModalSystem.create({ + id: 'collectionPickerModal', + title: 'Select Collections', + content: ` +
+
+ +
+
+ ${collections.map(collection => { + const isSelected = selectedIds.includes(collection.Id); + return ` + + `; + }).join('')} +
+
+ `, + footer: ` + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (pickerModalInstance) => { + pickerModalInstanceRef = pickerModalInstance; + + // Set up search filter + const searchInput = pickerModalInstance.dialogContent.querySelector('#collectionPickerSearch'); + if (searchInput) { + searchInput.addEventListener('input', (e) => { + const list = pickerModalInstance.dialogContent.querySelector('#collectionPickerList'); + const items = list.querySelectorAll('label'); + const term = e.target.value.toLowerCase(); + + items.forEach(item => { + const name = item.querySelector('span').textContent.toLowerCase(); + item.style.display = name.includes(term) ? '' : 'none'; + }); + }); + } + + const confirmBtn = pickerModalInstance.dialogFooter?.querySelector('#collectionPickerConfirm'); + const cancelBtn = pickerModalInstance.dialogFooter?.querySelector('#collectionPickerCancel'); + + if (confirmBtn) { + confirmBtn.addEventListener('click', () => { + const checkboxes = pickerModalInstance.dialogContent.querySelectorAll('#collectionPickerList input[type="checkbox"]:checked'); + const selectedIds = Array.from(checkboxes).map(cb => cb.value); + + // Update hidden input + if (hiddenInput) { + hiddenInput.value = JSON.stringify(selectedIds); + + // Update button text and selected count + const button = modalInstance.dialogContent.querySelector(`button[onclick="openCollectionPicker(${seasonIndex})"]`); + const selectedDiv = modalInstance.dialogContent.querySelector(`.seasonal-season-collections-selected[data-index="${seasonIndex}"]`); + + if (button) { + button.querySelector('span').textContent = `Select Collections (${selectedIds.length})`; + } + if (selectedDiv) { + selectedDiv.textContent = selectedIds.length > 0 ? `Selected: ${selectedIds.length} collection(s)` : 'No collections selected'; + } + } + + pickerModalInstance.close(); + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + pickerModalInstance.close(); + }); + } + } + }); + + } catch (error) { + console.error('[KefinTweaks Configuration] Error opening collection picker:', error); + alert('Error loading collections. Please try again.'); + } + }; + + window.openPlaylistPicker = async function(seasonIndex) { + if (!window.ModalSystem) { + alert('Modal system not available'); + return; + } + + try { + // Fetch playlists from server + const userId = window.ApiClient.getCurrentUserId(); + const serverAddress = window.ApiClient.serverAddress(); + const token = window.ApiClient.accessToken(); + + const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ItemCounts`, { + headers: { 'X-Emby-Token': token } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const playlists = data.Items || []; + + // Get currently selected playlists + const hiddenInput = modalInstance.dialogContent.querySelector(`.seasonal-season-playlists[data-index="${seasonIndex}"]`); + const selectedIds = hiddenInput ? JSON.parse(hiddenInput.value || '[]') : []; + + // Create picker modal + let pickerModalInstanceRef = null; + const pickerModal = window.ModalSystem.create({ + id: 'playlistPickerModal', + title: 'Select Playlists', + content: ` +
+
+ +
+
+ ${playlists.map(playlist => { + const isSelected = selectedIds.includes(playlist.Id); + return ` + + `; + }).join('')} +
+
+ `, + footer: ` + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (pickerModalInstance) => { + pickerModalInstanceRef = pickerModalInstance; + + // Set up search filter + const searchInput = pickerModalInstance.dialogContent.querySelector('#playlistPickerSearch'); + if (searchInput) { + searchInput.addEventListener('input', (e) => { + const list = pickerModalInstance.dialogContent.querySelector('#playlistPickerList'); + const items = list.querySelectorAll('label'); + const term = e.target.value.toLowerCase(); + + items.forEach(item => { + const name = item.querySelector('span').textContent.toLowerCase(); + item.style.display = name.includes(term) ? '' : 'none'; + }); + }); + } + + const confirmBtn = pickerModalInstance.dialogFooter?.querySelector('#playlistPickerConfirm'); + const cancelBtn = pickerModalInstance.dialogFooter?.querySelector('#playlistPickerCancel'); + + if (confirmBtn) { + confirmBtn.addEventListener('click', () => { + const checkboxes = pickerModalInstance.dialogContent.querySelectorAll('#playlistPickerList input[type="checkbox"]:checked'); + const selectedIds = Array.from(checkboxes).map(cb => cb.value); + + // Update hidden input + if (hiddenInput) { + hiddenInput.value = JSON.stringify(selectedIds); + + // Update button text and selected count + const button = modalInstance.dialogContent.querySelector(`button[onclick="openPlaylistPicker(${seasonIndex})"]`); + const selectedDiv = modalInstance.dialogContent.querySelector(`.seasonal-season-playlists-selected[data-index="${seasonIndex}"]`); + + if (button) { + button.querySelector('span').textContent = `Select Playlists (${selectedIds.length})`; + } + if (selectedDiv) { + selectedDiv.textContent = selectedIds.length > 0 ? `Selected: ${selectedIds.length} playlist(s)` : 'No playlists selected'; + } + } + + pickerModalInstance.close(); + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + pickerModalInstance.close(); + }); + } + } + }); + + } catch (error) { + console.error('[KefinTweaks Configuration] Error opening playlist picker:', error); + alert('Error loading playlists. Please try again.'); + } + }; + + // Picker functions for sections (used in nested sections) + async function openCollectionPickerForSection(prefix, sectionIndex, isSeasonalNested) { + if (!window.ModalSystem) { + alert('Modal system not available'); + return; + } + + try { + const userId = window.ApiClient.getCurrentUserId(); + const serverAddress = window.ApiClient.serverAddress(); + const token = window.ApiClient.accessToken(); + + const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=BoxSet,CollectionFolder&Recursive=true&Fields=ItemCounts`, { + headers: { 'X-Emby-Token': token } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const collections = data.Items || []; + + const sectionItem = modalInstance.dialogContent.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`); + const sourceInput = sectionItem?.querySelector(`.${prefix}_section_source[data-section-index="${sectionIndex}"]`); + const currentSource = sourceInput ? sourceInput.value : ''; + const selectedId = currentSource || null; + + const pickerModal = window.ModalSystem.create({ + id: 'collectionPickerForSectionModal', + title: 'Select Collection', + content: ` +
+
+ +
+
+ ${collections.map(collection => { + const isSelected = collection.Id === selectedId; + return ` + + `; + }).join('')} +
+
+ `, + footer: ` + + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (pickerModalInstance) => { + const searchInput = pickerModalInstance.dialogContent.querySelector('#collectionPickerForSectionSearch'); + if (searchInput) { + searchInput.addEventListener('input', (e) => { + const list = pickerModalInstance.dialogContent.querySelector('#collectionPickerForSectionList'); + const items = list.querySelectorAll('label'); + const term = e.target.value.toLowerCase(); + items.forEach(item => { + const name = item.querySelector('span').textContent.toLowerCase(); + item.style.display = name.includes(term) ? '' : 'none'; + }); + }); + } + + const confirmBtn = pickerModalInstance.dialogFooter?.querySelector('#collectionPickerForSectionConfirm'); + const clearBtn = pickerModalInstance.dialogFooter?.querySelector('#collectionPickerForSectionClear'); + const cancelBtn = pickerModalInstance.dialogFooter?.querySelector('#collectionPickerForSectionCancel'); + + if (confirmBtn) { + confirmBtn.addEventListener('click', () => { + const selected = pickerModalInstance.dialogContent.querySelector('#collectionPickerForSectionList input[type="radio"]:checked'); + const selectedId = selected ? selected.value : ''; + + if (sourceInput) { + sourceInput.value = selectedId; + + const button = sectionItem?.querySelector(`button[onclick*="openCollectionPickerForSection"]`); + const selectedDiv = sectionItem?.querySelector(`.${prefix}_section_collection_selected[data-section-index="${sectionIndex}"]`); + + if (button) { + button.querySelector('span').textContent = `Select Collection ${selectedId ? '(1)' : ''}`; + } + if (selectedDiv) { + selectedDiv.textContent = selectedId ? `Selected: 1 collection` : 'No collection selected'; + } + } + + pickerModalInstance.close(); + }); + } + + if (clearBtn) { + clearBtn.addEventListener('click', () => { + const radios = pickerModalInstance.dialogContent.querySelectorAll('#collectionPickerForSectionList input[type="radio"]'); + radios.forEach(radio => radio.checked = false); + + if (sourceInput) { + sourceInput.value = ''; + + const button = sectionItem?.querySelector(`button[onclick*="openCollectionPickerForSection"]`); + const selectedDiv = sectionItem?.querySelector(`.${prefix}_section_collection_selected[data-section-index="${sectionIndex}"]`); + + if (button) { + button.querySelector('span').textContent = 'Select Collection'; + } + if (selectedDiv) { + selectedDiv.textContent = 'No collection selected'; + } + } + + pickerModalInstance.close(); + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + pickerModalInstance.close(); + }); + } + } + }); + } catch (error) { + console.error('[KefinTweaks Configuration] Error opening collection picker for section:', error); + alert('Error loading collections. Please try again.'); + } + }; + + async function openPlaylistPickerForSection(prefix, sectionIndex, isSeasonalNested) { + if (!window.ModalSystem) { + alert('Modal system not available'); + return; + } + + try { + const userId = window.ApiClient.getCurrentUserId(); + const serverAddress = window.ApiClient.serverAddress(); + const token = window.ApiClient.accessToken(); + + const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ItemCounts`, { + headers: { 'X-Emby-Token': token } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const playlists = data.Items || []; + + const sectionItem = modalInstance.dialogContent.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`); + const sourceInput = sectionItem?.querySelector(`.${prefix}_section_source[data-section-index="${sectionIndex}"]`); + const currentSource = sourceInput ? sourceInput.value : ''; + const selectedId = currentSource || null; + + const pickerModal = window.ModalSystem.create({ + id: 'playlistPickerForSectionModal', + title: 'Select Playlist', + content: ` +
+
+ +
+
+ ${playlists.map(playlist => { + const isSelected = playlist.Id === selectedId; + return ` + + `; + }).join('')} +
+
+ `, + footer: ` + + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (pickerModalInstance) => { + const searchInput = pickerModalInstance.dialogContent.querySelector('#playlistPickerForSectionSearch'); + if (searchInput) { + searchInput.addEventListener('input', (e) => { + const list = pickerModalInstance.dialogContent.querySelector('#playlistPickerForSectionList'); + const items = list.querySelectorAll('label'); + const term = e.target.value.toLowerCase(); + items.forEach(item => { + const name = item.querySelector('span').textContent.toLowerCase(); + item.style.display = name.includes(term) ? '' : 'none'; + }); + }); + } + + const confirmBtn = pickerModalInstance.dialogFooter?.querySelector('#playlistPickerForSectionConfirm'); + const clearBtn = pickerModalInstance.dialogFooter?.querySelector('#playlistPickerForSectionClear'); + const cancelBtn = pickerModalInstance.dialogFooter?.querySelector('#playlistPickerForSectionCancel'); + + if (confirmBtn) { + confirmBtn.addEventListener('click', () => { + const selected = pickerModalInstance.dialogContent.querySelector('#playlistPickerForSectionList input[type="radio"]:checked'); + const selectedId = selected ? selected.value : ''; + + if (sourceInput) { + sourceInput.value = selectedId; + + const button = sectionItem?.querySelector(`button[onclick*="openPlaylistPickerForSection"]`); + const selectedDiv = sectionItem?.querySelector(`.${prefix}_section_playlist_selected[data-section-index="${sectionIndex}"]`); + + if (button) { + button.querySelector('span').textContent = `Select Playlist ${selectedId ? '(1)' : ''}`; + } + if (selectedDiv) { + selectedDiv.textContent = selectedId ? `Selected: 1 playlist` : 'No playlist selected'; + } + } + + pickerModalInstance.close(); + }); + } + + if (clearBtn) { + clearBtn.addEventListener('click', () => { + const radios = pickerModalInstance.dialogContent.querySelectorAll('#playlistPickerForSectionList input[type="radio"]'); + radios.forEach(radio => radio.checked = false); + + if (sourceInput) { + sourceInput.value = ''; + + const button = sectionItem?.querySelector(`button[onclick*="openPlaylistPickerForSection"]`); + const selectedDiv = sectionItem?.querySelector(`.${prefix}_section_playlist_selected[data-section-index="${sectionIndex}"]`); + + if (button) { + button.querySelector('span').textContent = 'Select Playlist'; + } + if (selectedDiv) { + selectedDiv.textContent = 'No playlist selected'; + } + } + + pickerModalInstance.close(); + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + pickerModalInstance.close(); + }); + } + } + }); + } catch (error) { + console.error('[KefinTweaks Configuration] Error opening playlist picker for section:', error); + alert('Error loading playlists. Please try again.'); + } + }; + + window.editSeasonalSeason = function(index) { + // For now, editing is done inline - this could open a modal in the future + console.log('Edit seasonal season', index); + }; + + window.deleteSeasonalSeason = function(index) { + const seasonsList = modalInstance.dialogContent.querySelector('#homeScreen_seasonal_seasons_list'); + if (!seasonsList) return; + + const seasonItem = seasonsList.querySelector(`.seasonal-season-item[data-season-index="${index}"]`); + if (seasonItem && confirm('Are you sure you want to delete this season?')) { + seasonItem.remove(); + // Re-index remaining items + const remainingItems = seasonsList.querySelectorAll('.seasonal-season-item'); + remainingItems.forEach((item, newIndex) => { + item.setAttribute('data-season-index', newIndex); + item.querySelectorAll('[data-index]').forEach(el => { + el.setAttribute('data-index', newIndex); + }); + const editBtn = item.querySelector('button[onclick*="editSeasonalSeason"]'); + const deleteBtn = item.querySelector('button[onclick*="deleteSeasonalSeason"]'); + if (editBtn) editBtn.setAttribute('onclick', `editSeasonalSeason(${newIndex})`); + if (deleteBtn) deleteBtn.setAttribute('onclick', `deleteSeasonalSeason(${newIndex})`); + }); + } + }; // Toggle visibility of configuration sections based on script enable state const scriptCheckboxes = { @@ -921,6 +3598,11 @@ saveBtn.addEventListener('click', () => handleSaveConfig(modalInstance)); } + const resetBtn = modalInstance.dialogFooter?.querySelector('#resetConfigBtn'); + if (resetBtn) { + resetBtn.addEventListener('click', () => handleResetConfig(modalInstance)); + } + // Export button handler const exportBtn = modalInstance.dialogFooter?.querySelector('#exportConfigBtn'); if (exportBtn) { @@ -1268,6 +3950,186 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; } } + function getSectionConfigFromUI(rootElement, prefix, sectionIndex) { + const scope = rootElement || document; + const sectionItem = scope.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`); + if (!sectionItem) return null; + + const sanitizedIndex = sectionIndex.toString().replace(/[^a-zA-Z0-9]/g, '_'); + const sectionIdAttr = sectionItem.getAttribute('data-section-id') || `${prefix}-section-${sanitizedIndex}`; + + const enabledCheckbox = sectionItem.querySelector(`#${prefix}_section_enabled_${sanitizedIndex}`) || sectionItem.querySelector(`.${prefix}_section_enabled[data-section-index="${sectionIndex}"]`); + const enabled = enabledCheckbox?.checked !== false; + const name = sectionItem.querySelector(`.${prefix}_section_name[data-section-index="${sectionIndex}"]`)?.value || ''; + const type = sectionItem.querySelector(`.${prefix}_section_type[data-section-index="${sectionIndex}"]`)?.value || 'Genre'; + + let source = ''; + const sourceContainer = sectionItem.querySelector(`.${prefix}_section_source_container[data-section-index="${sectionIndex}"]`); + const badgeContainer = sourceContainer?.querySelector(`.tag-badge-container[data-section-index="${sectionIndex}"][data-type]`) || null; + if (badgeContainer) { + const badges = badgeContainer.querySelectorAll('.tag-badge'); + source = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean).join(', '); + } else { + const sourceInput = sectionItem.querySelector(`.${prefix}_section_source[data-section-index="${sectionIndex}"]`); + source = sourceInput ? sourceInput.value : ''; + } + + const itemLimit = parseInt(sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_itemLimit`)?.value || '16', 10); + const sortOrder = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_sortOrder`)?.value || 'Random'; + const sortOrderDirection = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_sortOrderDirection`)?.value || 'Ascending'; + const cardFormat = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_cardFormat`)?.value || 'Poster'; + const order = parseInt(sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_order`)?.value || '100', 10); + const spotlightCheckbox = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_spotlight`) || sectionItem.querySelector(`.${prefix}_section_spotlight[data-section-index="${sectionIndex}"]`); + const discoveryCheckbox = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_discovery`) || sectionItem.querySelector(`.${prefix}_section_discovery[data-section-index="${sectionIndex}"]`); + const spotlight = spotlightCheckbox?.checked === true; + const discoveryEnabled = discoveryCheckbox?.checked === true; + const searchTerm = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_searchTerm`)?.value?.trim() || ''; + + let includeItemTypes = []; + const includeItemTypesContainer = sectionItem.querySelector(`.includeItemTypes-badge-container[data-section-index="${sectionIndex}"]`); + if (includeItemTypesContainer) { + const badges = includeItemTypesContainer.querySelectorAll('.tag-badge'); + includeItemTypes = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean); + } + if (includeItemTypes.length === 0 && type !== 'Parent') { + includeItemTypes = ['Movie']; + } + + return { + id: sectionIdAttr, + enabled: enabled, + name: name, + type: type, + source: source, + itemLimit: itemLimit, + sortOrder: sortOrder, + sortOrderDirection: sortOrderDirection, + cardFormat: cardFormat, + order: order, + spotlight: spotlight, + discoveryEnabled: discoveryEnabled, + searchTerm: searchTerm, + includeItemTypes: includeItemTypes + }; + } + + async function fetchItemsForSectionPreview(sectionConfig) { + const apiClient = window.ApiClient; + if (!apiClient) { + throw new Error('ApiClient is not available'); + } + + const userId = apiClient.getCurrentUserId(); + const serverAddress = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const type = sectionConfig.type || 'Genre'; + const searchTerm = sectionConfig.searchTerm?.trim() || ''; + const includeItemTypes = Array.isArray(sectionConfig.includeItemTypes) && sectionConfig.includeItemTypes.length > 0 + ? sectionConfig.includeItemTypes.join(',') + : (sectionConfig.includeItemTypes || 'Movie'); + const sortOrder = sectionConfig.sortOrder || 'Random'; + const sortOrderDirection = sectionConfig.sortOrderDirection || 'Ascending'; + const itemLimit = parseInt(sectionConfig.itemLimit, 10) || 16; + const sources = (sectionConfig.source || '').split(',').map(s => s.trim()).filter(Boolean); + const fields = 'PrimaryImageAspectRatio,DateCreated,Overview,ProductionYear,ImageTags,BackdropImageTags,ParentBackdropImageTags,ParentThumbImageTag,SeriesPrimaryImageTag'; + const headers = { 'Authorization': `MediaBrowser Token="${token}"` }; + + const dedupeAndLimit = (items = []) => { + let working = Array.isArray(items) ? [...items] : []; + if (sortOrder === 'Random') { + working.sort(() => Math.random() - 0.5); + } else if (window.cardBuilder?.sortItems) { + working = window.cardBuilder.sortItems(working, sortOrder, sortOrderDirection); + } + const seen = new Set(); + const limited = []; + for (const item of working) { + if (!item || !item.Id || seen.has(item.Id)) continue; + seen.add(item.Id); + limited.push(item); + if (limited.length >= itemLimit) break; + } + return limited; + }; + + const fetchJson = async (url) => { + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); + }; + + try { + if (type === 'Genre' || type === 'Tag') { + if (sources.length === 0) { + return []; + } + const paramName = type === 'Genre' ? 'Genres' : 'Tags'; + const valueParam = sources.map(s => encodeURIComponent(s)).join('|'); + const url = `${serverAddress}/Items?userId=${userId}&${paramName}=${valueParam}` + + `&IncludeItemTypes=${encodeURIComponent(includeItemTypes)}` + + `&Recursive=true&Fields=${encodeURIComponent(fields)}` + + `&Limit=${itemLimit}&SortBy=${encodeURIComponent(sortOrder)}&SortOrder=${encodeURIComponent(sortOrderDirection)}` + + (searchTerm ? `&SearchTerm=${encodeURIComponent(searchTerm)}` : ''); + const data = await fetchJson(url); + return dedupeAndLimit(data.Items || []); + } + + if (type === 'Collection' || type === 'Playlist' || type === 'Parent') { + const existingIds = new Set(); + let collected = []; + + const queryBase = { + IncludeItemTypes: includeItemTypes, + Recursive: true, + Fields: fields, + SortBy: sortOrder, + SortOrder: sortOrderDirection + }; + + const runQuery = async (params) => { + const response = await apiClient.getItems(userId, params); + if (response?.Items?.length) { + response.Items.forEach(item => { + if (item && item.Id && !existingIds.has(item.Id)) { + existingIds.add(item.Id); + collected.push(item); + } + }); + } + }; + + if (type === 'Parent' && sources.length === 0) { + const params = { + ...queryBase, + Limit: itemLimit * 2 + }; + if (searchTerm) params.SearchTerm = searchTerm; + await runQuery(params); + } else { + const multiplier = Math.max(1, sources.length); + for (const parentId of sources) { + const params = { + ...queryBase, + ParentId: parentId, + Limit: itemLimit * multiplier + }; + if (searchTerm) params.SearchTerm = searchTerm; + await runQuery(params); + } + } + + return dedupeAndLimit(collected); + } + + return []; + } catch (error) { + console.warn('[KefinTweaks Configuration] Preview fetch failed:', error); + throw error; + } + } + // Handle save configuration async function handleSaveConfig(modalInstance) { // Verify admin status before saving @@ -1299,21 +4161,183 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; config.scripts[key] = checkbox.checked; }); + // Helper function to get section config + function getSectionConfig(prefix, includeSortOrderOrOptions = true, includeOrderFlag = true) { + let includeSortOrder = true; + let includeOrder = true; + let includeName = false; + let defaultName = ''; + + if (typeof includeSortOrderOrOptions === 'object') { + const opts = includeSortOrderOrOptions || {}; + includeSortOrder = opts.includeSortOrder !== undefined ? opts.includeSortOrder : true; + includeOrder = opts.includeOrder !== undefined ? opts.includeOrder : true; + includeName = opts.includeName === true; + defaultName = opts.defaultName || ''; + } else { + includeSortOrder = includeSortOrderOrOptions !== false; + includeOrder = includeOrderFlag !== false; + } + + const config = { + itemLimit: parseInt(document.getElementById(`${prefix}_itemLimit`)?.value || '16', 10) + }; + + if (includeName) { + config.name = document.getElementById(`${prefix}_name`)?.value || defaultName || ''; + } + + if (includeSortOrder) { + config.sortOrder = document.getElementById(`${prefix}_sortOrder`)?.value || 'Random'; + config.sortOrderDirection = document.getElementById(`${prefix}_sortOrderDirection`)?.value || 'Ascending'; + } + + config.cardFormat = document.getElementById(`${prefix}_cardFormat`)?.value || 'Poster'; + + if (includeOrder) { + config.order = parseInt(document.getElementById(`${prefix}_order`)?.value || '100', 10); + } + + return config; + } + + // Collect seasonal seasons + function getSeasonalSeasons() { + const seasons = []; + const seasonItems = modalInstance.dialogContent.querySelectorAll('.seasonal-season-item'); + seasonItems.forEach((item, seasonIndex) => { + // Try to find checkbox by ID first, then fall back to class selector + const enabledCheckbox = item.querySelector(`#seasonal-season-enabled-${seasonIndex}`) || item.querySelector('.seasonal-season-enabled[data-index="${seasonIndex}"]'); + const enabled = enabledCheckbox?.checked !== false; + const name = item.querySelector('.seasonal-season-name')?.value || ''; + const startDate = item.querySelector('.seasonal-season-startDate')?.value || ''; + const endDate = item.querySelector('.seasonal-season-endDate')?.value || ''; + const orderInput = item.querySelector(`.seasonal-season-order-input[data-index="${seasonIndex}"]`); + const order = orderInput ? parseInt(orderInput.value || '100', 10) : 100; + + // Collect nested sections + const sectionsList = item.querySelector(`.seasonal_season_sections_list[data-season-index="${seasonIndex}"]`); + const sections = []; + if (sectionsList) { + const sectionItems = sectionsList.querySelectorAll('.seasonal_season_section_item'); + sectionItems.forEach((sectionItem, sectionItemIndex) => { + const fullIndex = `${seasonIndex}_${sectionItemIndex}`; + const sectionConfig = getSectionConfigFromUI(modalInstance.dialogContent, 'seasonal_season', fullIndex); + if (sectionConfig) { + sections.push(sectionConfig); + } + }); + } + + const seasonConfig = { + id: name.toLowerCase().replace(/\s+/g, '-') || `season-${seasonIndex}`, + name: name, + enabled: enabled, + startDate: startDate, + endDate: endDate, + order: order, + sections: sections + }; + seasons.push(seasonConfig); + }); + return seasons; + } + + // Collect custom sections + function getCustomSections() { + const sectionsList = modalInstance.dialogContent.querySelector('#customSections_list'); + const sections = []; + + if (!sectionsList) { + return sections; + } + + const sectionItems = sectionsList.querySelectorAll('.customSection_section_item'); + sectionItems.forEach((sectionItem, index) => { + const sectionIndex = sectionItem.getAttribute('data-section-index') ?? index; + const sectionConfig = getSectionConfigFromUI(modalInstance.dialogContent, 'customSection', sectionIndex); + if (sectionConfig) { + sections.push(sectionConfig); + } + }); + + return sections; + } + // Collect home screen config config.homeScreen = { - enableNewAndTrending: document.getElementById('homeScreen_enableNewAndTrending')?.checked !== false, - enableNewMovies: document.getElementById('homeScreen_enableNewMovies')?.checked !== false, - enableNewEpisodes: document.getElementById('homeScreen_enableNewEpisodes')?.checked !== false, - enableTrending: document.getElementById('homeScreen_enableTrending')?.checked === true, - enableDiscovery: document.getElementById('homeScreen_enableDiscovery')?.checked !== false, - enableInfiniteScroll: document.getElementById('homeScreen_enableInfiniteScroll')?.checked !== false, - minPeopleAppearances: parseInt(document.getElementById('homeScreen_minPeopleAppearances')?.value || '10'), - minGenreMovieCount: parseInt(document.getElementById('homeScreen_minGenreMovieCount')?.value || '50'), - minimumShowsForNetwork: parseInt(document.getElementById('homeScreen_minimumShowsForNetwork')?.value || '5'), - enableWatchlist: document.getElementById('homeScreen_enableWatchlist')?.checked !== false, - enableSeasonal: document.getElementById('homeScreen_enableSeasonal')?.checked !== false, - seasonalItemLimit: parseInt(document.getElementById('homeScreen_seasonalItemLimit')?.value || '16'), - customSections: parseJSONField('homeScreen_customSections', []) + defaultItemLimit: 16, + defaultSortOrder: 'Random', + defaultCardFormat: 'Poster', + recentlyReleased: { + enabled: document.getElementById('homeScreen_recentlyReleased_enabled')?.checked !== false, + movies: { + enabled: document.getElementById('homeScreen_recentlyReleased_movies_enabled')?.checked !== false, + ...getSectionConfig('homeScreen_recentlyReleased_movies', { includeName: true, defaultName: 'Recently Released Movies', includeSortOrder: false, includeOrder: true }) + }, + episodes: { + enabled: document.getElementById('homeScreen_recentlyReleased_episodes_enabled')?.checked !== false, + ...getSectionConfig('homeScreen_recentlyReleased_episodes', { includeName: true, defaultName: 'Recently Aired Episodes', includeSortOrder: false, includeOrder: true }) + } + }, + trending: { + enabled: document.getElementById('homeScreen_trending_enabled')?.checked === true, + ...getSectionConfig('homeScreen_trending', { includeName: true, defaultName: 'Trending' }) + }, + popularTVNetworks: { + enabled: document.getElementById('homeScreen_popularTVNetworks_enabled')?.checked === true, + minimumShowsForNetwork: parseInt(document.getElementById('homeScreen_popularTVNetworks_minimumShowsForNetwork')?.value || '5'), + ...getSectionConfig('homeScreen_popularTVNetworks', { includeName: true, defaultName: 'Popular TV Networks' }) + }, + watchlist: { + enabled: document.getElementById('homeScreen_watchlist_enabled')?.checked === true, + ...getSectionConfig('homeScreen_watchlist', { includeName: true, defaultName: 'Watchlist' }) + }, + watchAgain: { + enabled: document.getElementById('homeScreen_watchAgain_enabled')?.checked === true, + ...getSectionConfig('homeScreen_watchAgain', { includeName: true, defaultName: 'Watch Again' }) + }, + upcoming: { + enabled: document.getElementById('homeScreen_upcoming_enabled')?.checked !== false, + ...getSectionConfig('homeScreen_upcoming', { includeSortOrder: false, includeName: true, defaultName: 'Upcoming' }) + }, + imdbTop250: { + enabled: document.getElementById('homeScreen_imdbTop250_enabled')?.checked !== false, + ...getSectionConfig('homeScreen_imdbTop250', { includeName: true, defaultName: 'IMDb Top 250' }) + }, + seasonal: { + enabled: document.getElementById('homeScreen_seasonal_enabled')?.checked !== false, + defaultItemLimit: 16, + defaultSortOrder: 'Random', + defaultCardFormat: 'Poster', + seasons: getSeasonalSeasons() + }, + discovery: { + enabled: document.getElementById('homeScreen_discovery_enabled')?.checked !== false, + infiniteScroll: document.getElementById('homeScreen_discovery_infiniteScroll')?.checked !== false, + randomizeOrder: document.getElementById('homeScreen_discovery_randomizeOrder')?.checked === true, + minPeopleAppearances: parseInt(document.getElementById('homeScreen_discovery_minPeopleAppearances')?.value || '10'), + minGenreMovieCount: parseInt(document.getElementById('homeScreen_discovery_minGenreMovieCount')?.value || '50'), + spotlightDiscoveryChance: parseFloat(document.getElementById('homeScreen_discovery_spotlightDiscoveryChance')?.value || '0.5'), + defaultItemLimit: parseInt(document.getElementById('homeScreen_discovery_itemLimit')?.value || '16'), + defaultSortOrder: document.getElementById('homeScreen_discovery_sortOrder')?.value || 'Random', + defaultCardFormat: document.getElementById('homeScreen_discovery_cardFormat')?.value || 'Poster', + sectionTypes: (() => { + const sectionConfigs = {}; + DISCOVERY_SECTION_DEFINITIONS.forEach(section => { + const prefix = `homeScreen_discovery_sectionTypes_${section.key}`; + const collectedConfig = getSectionConfig(prefix, { includeName: true, includeOrder: true, includeSortOrder: false, defaultName: section.defaultName }); + collectedConfig.enabled = document.getElementById(`${prefix}_enabled`)?.checked !== false; + if (section.extras?.minimumItems !== undefined) { + const minimumValue = parseInt(document.getElementById(`${prefix}_minimumItems`)?.value || `${section.extras.minimumItems}`, 10); + collectedConfig.minimumItems = Number.isFinite(minimumValue) ? minimumValue : section.extras.minimumItems; + } + sectionConfigs[section.key] = collectedConfig; + }); + return sectionConfigs; + })() + }, + customSections: getCustomSections() }; // Collect exclusive elsewhere config @@ -1402,6 +4426,48 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; } } + async function handleResetConfig(modalInstance) { + const userIsAdmin = await isAdmin(); + if (!userIsAdmin) { + alert('You must be an administrator to reset configuration.'); + return; + } + + const confirmed = confirm('This will restore the KefinTweaks configuration to its defaults. It cannot be undone. Are you sure you want to continue?'); + if (!confirmed) { + return; + } + + try { + const defaultConfig = await loadDefaultConfig(); + + // Retain script URLs from existing config + const existingConfig = await getKefinTweaksConfig(); + defaultConfig.kefinTweaksRoot = existingConfig.kefinTweaksRoot; + defaultConfig.scriptRoot = existingConfig.scriptRoot; + + await saveConfigToJavaScriptInjector(defaultConfig); + + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Configuration reset to defaults.'); + } else { + alert('Configuration reset to defaults.'); + } + + if (modalInstance && typeof modalInstance.close === 'function') { + modalInstance.close(); + } + openConfigurationModal(); + } catch (error) { + console.error('[KefinTweaks Configuration] Error resetting config:', error); + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast(`Error resetting configuration: ${error.message}`, '5'); + } else { + alert(`Error resetting configuration: ${error.message}`); + } + } + } + // Helper to parse JSON fields safely function parseJSONField(fieldId, defaultValue) { try { @@ -1419,14 +4485,12 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; // Create configuration button for Administration section function createConfigButton() { - const button = document.createElement('a'); - button.setAttribute('is', 'emby-linkbutton'); + const button = document.createElement('div'); button.style.display = 'block'; button.style.padding = '0'; button.style.margin = '0'; - button.className = 'listItem-border emby-button'; + button.className = 'listItem-border emby-button kefin-config-button'; button.setAttribute('data-kefintweaks-config-button', 'true'); - button.href = '#'; button.onclick = (e) => { e.preventDefault(); openConfigurationModal(); diff --git a/scripts/homeScreen.css b/scripts/homeScreen.css index cc00180..68c4cbf 100644 --- a/scripts/homeScreen.css +++ b/scripts/homeScreen.css @@ -124,4 +124,7 @@ div#discovery-loading-indicator:not(.show)::after { /* Ensure Editor's Choice Banner appears at top of home screen if enabled */ .editorsChoiceContainer { order: 1 !important; +} +.spotlight-section { + width: calc(100% - (2 * max(env(safe-area-inset-left),3.3%))); } \ No newline at end of file diff --git a/scripts/homeScreen.js b/scripts/homeScreen.js index 614a59e..8ee1c9d 100644 --- a/scripts/homeScreen.js +++ b/scripts/homeScreen.js @@ -81,7 +81,7 @@ // Create loading indicator element function createDiscoveryLoadingIndicator() { - let loadingDiv = document.getElementById('discovery-loading-indicator'); + let loadingDiv = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); if (loadingDiv) { return loadingDiv; } @@ -105,7 +105,7 @@ // Show loading indicator function showDiscoveryLoadingIndicator() { - let loadingIndicator = document.getElementById('discovery-loading-indicator'); + let loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); if (!loadingIndicator) { createDiscoveryLoadingIndicator(); @@ -119,7 +119,7 @@ // Hide loading indicator function hideDiscoveryLoadingIndicator() { - const loadingIndicator = document.getElementById('discovery-loading-indicator'); + const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); if (loadingIndicator) { loadingIndicator.classList.remove('show'); loadingIndicator.style.visibility = 'hidden'; @@ -184,39 +184,634 @@ WARN('KefinTweaksUtils not available, body class management may not work correctly'); } + /** + * Migrates old homeScreen config format to new format + * Small, transparent function that runs automatically + */ + function migrateHomeScreenConfig() { + if (!window.KefinTweaksConfig || !window.KefinTweaksConfig.homeScreen) { + return; + } + + const old = window.KefinTweaksConfig.homeScreen; + const migrated = {}; + let needsMigration = false; + + // Check if old format exists (has enableNewAndTrending, etc.) + if (old.enableNewAndTrending !== undefined || old.enableNewMovies !== undefined) { + needsMigration = true; + + // Migrate recently released + migrated.recentlyReleased = { + enabled: old.enableNewAndTrending !== false, + order: 30, + movies: { + enabled: old.enableNewMovies !== false, + itemLimit: 16, + sortOrder: "ReleaseDate", + sortOrderDirection: "Descending", + cardFormat: "Poster", + order: 30 + }, + episodes: { + enabled: old.enableNewEpisodes !== false, + itemLimit: 16, + sortOrder: "PremiereDate", + sortOrderDirection: "Descending", + cardFormat: "Backdrop", + order: 31 + } + }; + + // Migrate trending + if (old.enableTrending !== undefined) { + migrated.trending = { + enabled: old.enableTrending || false, + itemLimit: 16, + sortOrder: "Random", + sortOrderDirection: "Ascending", + cardFormat: "Poster", + order: 32 + }; + } + + // Migrate popular TV networks + if (old.minimumShowsForNetwork !== undefined) { + migrated.popularTVNetworks = { + enabled: false, + minimumShowsForNetwork: old.minimumShowsForNetwork || 5, + itemLimit: 16, + sortOrder: "Random", + sortOrderDirection: "Ascending", + cardFormat: "Poster", + order: 61 + }; + } + + // Migrate watchlist + if (old.enableWatchlist !== undefined) { + migrated.watchlist = { + enabled: old.enableWatchlist || false, + itemLimit: 16, + sortOrder: "DateAdded", + sortOrderDirection: "Descending", + cardFormat: "Poster", + order: 60 + }; + } + + // Migrate seasonal + if (old.enableSeasonal !== undefined) { + const seasons = []; + + // Check if old format has seasons array + if (old.seasonal && old.seasonal.seasons && Array.isArray(old.seasonal.seasons)) { + // Migrate each season from old format to new format + for (const oldSeason of old.seasonal.seasons) { + const sections = []; + + // Migrate genres to Genre sections + if (oldSeason.genres && Array.isArray(oldSeason.genres)) { + for (const genre of oldSeason.genres) { + sections.push({ + id: `${oldSeason.id || 'season'}-genre-${genre.toLowerCase().replace(/\s+/g, '-')}`, + enabled: true, + name: `${genre} Movies`, + type: "Genre", + source: genre, + itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16, + sortOrder: oldSeason.sortOrder || "Random", + sortOrderDirection: oldSeason.sortOrderDirection || "Ascending", + cardFormat: oldSeason.cardFormat || "Poster", + order: sections.length + 50 + }); + } + } + + // Migrate tags to Tag sections + if (oldSeason.tags && Array.isArray(oldSeason.tags)) { + for (const tag of oldSeason.tags) { + sections.push({ + id: `${oldSeason.id || 'season'}-tag-${tag.toLowerCase().replace(/\s+/g, '-')}`, + enabled: true, + name: `${tag} Movies`, + type: "Tag", + source: tag, + itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16, + sortOrder: oldSeason.sortOrder || "Random", + sortOrderDirection: oldSeason.sortOrderDirection || "Ascending", + cardFormat: oldSeason.cardFormat || "Poster", + order: sections.length + 50 + }); + } + } + + // Migrate collections to Collection sections + if (oldSeason.collections && Array.isArray(oldSeason.collections)) { + for (const collectionId of oldSeason.collections) { + sections.push({ + id: `${oldSeason.id || 'season'}-collection-${collectionId}`, + enabled: true, + name: "Collection", + type: "Collection", + source: collectionId, + itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16, + sortOrder: oldSeason.sortOrder || "Random", + sortOrderDirection: oldSeason.sortOrderDirection || "Ascending", + cardFormat: oldSeason.cardFormat || "Poster", + order: sections.length + 50 + }); + } + } + + // Migrate playlists to Playlist sections + if (oldSeason.playlists && Array.isArray(oldSeason.playlists)) { + for (const playlistId of oldSeason.playlists) { + sections.push({ + id: `${oldSeason.id || 'season'}-playlist-${playlistId}`, + enabled: true, + name: "Playlist", + type: "Playlist", + source: playlistId, + itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16, + sortOrder: oldSeason.sortOrder || "Random", + sortOrderDirection: oldSeason.sortOrderDirection || "Ascending", + cardFormat: oldSeason.cardFormat || "Poster", + order: sections.length + 50 + }); + } + } + + // If no sections were created, create a default one from genres if available + if (sections.length === 0 && oldSeason.genres && oldSeason.genres.length > 0) { + sections.push({ + id: `${oldSeason.id || 'season'}-default`, + enabled: true, + name: oldSeason.name || "Seasonal Movies", + type: "Genre", + source: oldSeason.genres.join(', '), + itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16, + sortOrder: oldSeason.sortOrder || "Random", + sortOrderDirection: oldSeason.sortOrderDirection || "Ascending", + cardFormat: oldSeason.cardFormat || "Poster", + order: 50 + }); + } + + seasons.push({ + id: oldSeason.id || "halloween", + name: oldSeason.name || "Halloween", + enabled: oldSeason.enabled !== false, + startDate: oldSeason.startDate || "10-01", + endDate: oldSeason.endDate || "10-31", + sections: sections + }); + } + } else { + // Default migration: create Halloween and Christmas seasons + seasons.push({ + id: "halloween", + name: "Halloween", + enabled: true, + startDate: "10-01", + endDate: "10-31", + sections: [ + { + id: "halloween-horror", + enabled: true, + name: "Horror Movies", + type: "Genre", + source: "Horror", + itemLimit: old.seasonalItemLimit || 16, + sortOrder: "Random", + sortOrderDirection: "Ascending", + cardFormat: "Poster", + order: 50 + }, + { + id: "halloween-thriller", + enabled: true, + name: "Thriller Movies", + type: "Genre", + source: "Thriller", + itemLimit: old.seasonalItemLimit || 16, + sortOrder: "Random", + sortOrderDirection: "Ascending", + cardFormat: "Poster", + order: 51 + } + ] + }); + seasons.push({ + id: "christmas", + name: "Christmas", + enabled: true, + startDate: "12-01", + endDate: "12-31", + sections: [ + { + id: "christmas-genre", + enabled: true, + name: "Christmas Movies", + type: "Genre", + source: "Christmas", + itemLimit: old.seasonalItemLimit || 16, + sortOrder: "Random", + sortOrderDirection: "Ascending", + cardFormat: "Poster", + order: 60 + }, + { + id: "christmas-family", + enabled: true, + name: "Family Movies", + type: "Genre", + source: "Family", + itemLimit: old.seasonalItemLimit || 16, + sortOrder: "Random", + sortOrderDirection: "Ascending", + cardFormat: "Poster", + order: 61 + } + ] + }); + } + + migrated.seasonal = { + enabled: old.enableSeasonal !== false, + defaultItemLimit: old.seasonalItemLimit || 16, + defaultSortOrder: "Random", + defaultCardFormat: "Poster", + seasons: seasons + }; + } + + // Migrate existing seasonal config if it's in new format but old structure + if (old.seasonal && old.seasonal.seasons && Array.isArray(old.seasonal.seasons)) { + const needsRestructure = old.seasonal.seasons.some(season => + season.genres || season.tags || season.collections || season.playlists + ); + + if (needsRestructure && !migrated.seasonal) { + // Already handled above, but ensure we don't lose it + migrated.seasonal = migrated.seasonal || { + enabled: old.seasonal.enabled !== false, + defaultItemLimit: old.seasonal.defaultItemLimit || 16, + defaultSortOrder: old.seasonal.defaultSortOrder || "Random", + defaultCardFormat: old.seasonal.defaultCardFormat || "Poster", + seasons: [] + }; + } + } + + // Migrate discovery + if (old.enableDiscovery !== undefined || old.minPeopleAppearances !== undefined) { + migrated.discovery = { + enabled: old.enableDiscovery !== false, + infiniteScroll: old.enableInfiniteScroll !== false, + minPeopleAppearances: old.minPeopleAppearances || 10, + minGenreMovieCount: old.minGenreMovieCount || 50, + defaultItemLimit: 16, + defaultSortOrder: "Random", + defaultCardFormat: "Poster", + sectionTypes: { + spotlightGenre: true, + spotlightNetwork: true, + genreMovies: true, + studioShows: true, + collections: { + enabled: true, + minimumItems: 10, + itemLimit: null, + sortOrder: null, + sortOrderDirection: null, + cardFormat: null + }, + becauseYouWatched: true, + becauseYouLiked: true, + starringTopActor: true, + directedByTopDirector: true, + writtenByTopWriter: true, + becauseYouRecentlyWatched: true, + starringActorRecentlyWatched: true, + directedByDirectorRecentlyWatched: true, + writtenByWriterRecentlyWatched: true + } + }; + } + + // Migrate custom sections + if (old.customSections && Array.isArray(old.customSections)) { + migrated.customSections = old.customSections.map(section => { + // If already in new format (has type and source), keep as is + if (section.type && section.source !== undefined) { + return section; + } + + // Migrate from old format (sourceId) to new format + const sourceId = section.sourceId || section.id; + if (!sourceId) { + return section; // Can't migrate without sourceId + } + + // Try to determine type from existing data or default to Collection + let type = section.type || 'Collection'; + let source = section.source || sourceId; + + // If no type specified, we'll auto-detect in renderCustomSection + // But for migration, default to Collection + return { + id: section.id || `custom-${Math.random().toString(36).substr(2, 9)}`, + enabled: section.enabled !== false, + name: section.name || 'Custom Section', + type: type, + source: source, + itemLimit: section.itemLimit || 16, + sortOrder: section.sortOrder || 'Random', + sortOrderDirection: section.sortOrderDirection || 'Ascending', + cardFormat: section.cardFormat || 'Poster', + order: section.order || 100, + spotlight: section.spotlight === true, + discoveryEnabled: section.discoveryEnabled === true + }; + }); + } + + // Add new sections that didn't exist in old format (use defaults if not already present) + // These sections are new and won't be in old configs, so we add them with default values + if (!window.KefinTweaksConfig.homeScreen.upcoming) { + migrated.upcoming = { + enabled: true, + itemLimit: 48, + cardFormat: "Thumb", + order: 20 + }; + } + + if (!window.KefinTweaksConfig.homeScreen.imdbTop250) { + migrated.imdbTop250 = { + enabled: true, + itemLimit: 16, + sortOrder: "Random", + sortOrderDirection: "Ascending", + cardFormat: "Poster", + order: 21 + }; + } + + if (!window.KefinTweaksConfig.homeScreen.watchAgain) { + migrated.watchAgain = { + enabled: false, + itemLimit: 16, + sortOrder: "Random", + sortOrderDirection: "Ascending", + cardFormat: "Poster", + order: 62 + }; + } + + // Merge migrated config with existing (new format takes precedence) + window.KefinTweaksConfig.homeScreen = { + ...migrated, + ...window.KefinTweaksConfig.homeScreen + }; + + LOG('Migrated homeScreen config from old format to new format'); + + // Save migrated config back to JS Injector plugin (async, don't block) + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + window.KefinTweaksUtils.saveConfigToJavaScriptInjector().catch(err => { + ERR('Failed to save migrated config to JS Injector:', err); + }); + } else { + WARN('KefinTweaksUtils.saveConfigToJavaScriptInjector not available, config migration not persisted'); + } + } + } + + // Run migration before reading config + migrateHomeScreenConfig(); + + // Read new config structure + const homeScreenConfig = window.KefinTweaksConfig?.homeScreen || {}; + const defaultItemLimit = homeScreenConfig.defaultItemLimit || 16; + const defaultSortOrder = homeScreenConfig.defaultSortOrder || 'Random'; + const defaultCardFormat = homeScreenConfig.defaultCardFormat || 'Poster'; + + // Spotlight configuration + const spotlightConfig = homeScreenConfig.spotlight || {}; + const spotlightAutoPlay = spotlightConfig.autoPlay || true; + const spotlightInterval = spotlightConfig.interval || 5000; + const spotlightShowDots = spotlightConfig.showDots || true; + const spotlightShowNavButtons = spotlightConfig.showNavButtons || true; + const spotlightShowClearArt = spotlightConfig.showClearArt || true; + // Custom home sections configuration - // Read from centralized config or use defaults - // name: The name of the section that will appear on the home screen - // id: The ID of the playlist that will be used to fetch the items for the section - // maxItems: The maximum number of items to display in the section - // order: The flex order of the section on the home screen (requires css to take advantage of this) - const customHomeSections = window.KefinTweaksConfig?.homeScreen?.customSections || []; + const customHomeSections = homeScreenConfig.customSections || []; - // New and Trending sections configuration - const enableNewAndTrending = window.KefinTweaksConfig?.homeScreen?.enableNewAndTrending !== false; // Default to true - const enableNewMovies = window.KefinTweaksConfig?.homeScreen?.enableNewMovies !== false; // Default to true - const enableNewEpisodes = window.KefinTweaksConfig?.homeScreen?.enableNewEpisodes !== false; // Default to true - const enableTrending = window.KefinTweaksConfig?.homeScreen?.enableTrending || false; // Default to false (stubs for now) + // Recently Released sections configuration + const recentlyReleasedConfig = homeScreenConfig.recentlyReleased || {}; + const enableNewAndTrending = recentlyReleasedConfig.enabled !== false; + const newMoviesConfig = recentlyReleasedConfig.movies || {}; + const enableNewMovies = newMoviesConfig.enabled !== false; + const newEpisodesConfig = recentlyReleasedConfig.episodes || {}; + const enableNewEpisodes = newEpisodesConfig.enabled !== false; - // Discovery sections configuration - const enableWatchlist = window.KefinTweaksConfig?.homeScreen?.enableWatchlist || false; - const enableDiscovery = window.KefinTweaksConfig?.homeScreen?.enableDiscovery || false; - const enableSeasonal = window.KefinTweaksConfig?.homeScreen?.enableSeasonal || false; + // Trending sections configuration + const trendingConfig = homeScreenConfig.trending || {}; + const enableTrending = trendingConfig.enabled || false; - // People and genre configuration - const minPeopleAppearances = window.KefinTweaksConfig?.homeScreen?.minPeopleAppearances || 10; - const minGenreMovieCount = window.KefinTweaksConfig?.homeScreen?.minGenreMovieCount || 50; - const minimumShowsForNetwork = window.KefinTweaksConfig?.homeScreen?.minimumShowsForNetwork || 5; - const enableInfiniteScroll = window.KefinTweaksConfig?.homeScreen?.enableInfiniteScroll !== false; // Default to true + // Popular TV Networks configuration + const popularTVNetworksConfig = homeScreenConfig.popularTVNetworks || {}; + const minimumShowsForNetwork = popularTVNetworksConfig.minimumShowsForNetwork || 5; + + // Watchlist configuration + const watchlistConfig = homeScreenConfig.watchlist || {}; + const enableWatchlist = watchlistConfig.enabled || false; + + // Watch Again configuration + const watchAgainConfig = homeScreenConfig.watchAgain || {}; + const enableWatchAgain = watchAgainConfig.enabled || false; + + // Upcoming configuration + const upcomingConfig = homeScreenConfig.upcoming || {}; + const enableUpcoming = upcomingConfig.enabled !== false; + + // IMDb Top 250 configuration + const imdbTop250Config = homeScreenConfig.imdbTop250 || {}; + const enableImdbTop250 = imdbTop250Config.enabled !== false; // Seasonal configuration - const SEASONAL_ITEM_LIMIT = window.KefinTweaksConfig?.homeScreen?.seasonalItemLimit || 16; - const SEASONAL_ORDER = window.KefinTweaksConfig?.homeScreen?.seasonalOrder || 50; - const DISCOVERY_ORDER = window.KefinTweaksConfig?.homeScreen?.discoveryOrder || 1000; - const WATCHLIST_ORDER = window.KefinTweaksConfig?.homeScreen?.watchlistOrder || 60; - const POPULAR_TV_NETWORKS_ORDER = window.KefinTweaksConfig?.homeScreen?.popularTVNetworksOrder || 61; - const NEW_MOVIES_ORDER = window.KefinTweaksConfig?.homeScreen?.newMoviesOrder || 30; - const NEW_EPISODES_ORDER = window.KefinTweaksConfig?.homeScreen?.newEpisodesOrder || 31; + const seasonalConfig = homeScreenConfig.seasonal || {}; + const enableSeasonal = seasonalConfig.enabled !== false; + const SEASONAL_ITEM_LIMIT = seasonalConfig.defaultItemLimit || 16; + + // Discovery sections configuration + const discoveryConfig = homeScreenConfig.discovery || {}; + const enableDiscovery = discoveryConfig.enabled !== false; + const enableInfiniteScroll = discoveryConfig.infiniteScroll !== false; + const minPeopleAppearances = discoveryConfig.minPeopleAppearances || 10; + const minGenreMovieCount = discoveryConfig.minGenreMovieCount || 50; + const DISCOVERY_ORDER = 1000; // Discovery sections use dynamic ordering + + const DISCOVERY_SECTION_DEFINITIONS = [ + { key: 'spotlightGenre', defaultName: 'Spotlight' }, + { key: 'spotlightNetwork', defaultName: 'Spotlight' }, + { key: 'genreMovies', defaultName: '[Genre] Movies' }, + { key: 'studioShows', defaultName: 'Shows from [Studio]' }, + { key: 'collections', defaultName: '[Collection Name]', minimumItems: 10 }, + { key: 'becauseYouWatched', defaultName: 'Because you watched [Movie]' }, + { key: 'becauseYouLiked', defaultName: 'Because you liked [Movie]' }, + { key: 'starringTopActor', defaultName: 'Starring [Actor]' }, + { key: 'directedByTopDirector', defaultName: 'Directed by [Director]' }, + { key: 'writtenByTopWriter', defaultName: 'Written by [Writer]' }, + { key: 'becauseYouRecentlyWatched', defaultName: 'Because you recently watched [Movie]' }, + { key: 'starringActorRecentlyWatched', defaultName: 'Starring [Actor] because you recently watched [Movie]' }, + { key: 'directedByDirectorRecentlyWatched', defaultName: 'Directed by [Director] because you recently watched [Movie]' }, + { key: 'writtenByWriterRecentlyWatched', defaultName: 'Written by [Writer] because you recently watched [Movie]' } + ]; + + const discoverySectionDefinitionMap = DISCOVERY_SECTION_DEFINITIONS.reduce((map, definition) => { + map[definition.key] = definition; + return map; + }, {}); + + const discoverySectionTypeMap = { + 'genre': 'genreMovies', + 'director': 'directedByTopDirector', + 'writer': 'writtenByTopWriter', + 'actor': 'starringTopActor', + 'watched': 'becauseYouWatched', + 'liked': 'becauseYouLiked', + 'studio': 'studioShows', + 'watched-recent': 'becauseYouRecentlyWatched', + 'actor-recent': 'starringActorRecentlyWatched', + 'director-recent': 'directedByDirectorRecentlyWatched', + 'writer-recent': 'writtenByWriterRecentlyWatched', + 'spotlight-genre': 'spotlightGenre', + 'spotlight-network': 'spotlightNetwork', + 'collection': 'collections' + }; + + const discoverySectionSettingsCache = {}; + + let collectionsData = null; + async function getCollectionsData() { + if (collectionsData !== null) { + return collectionsData; + } + + collectionsData = await ApiClient.getItems(ApiClient.getCurrentUserId(), { + IncludeItemTypes: 'BoxSet', + Recursive: true, + Fields: 'RecursiveItemCount,ChildCount' + }); + return collectionsData; + } + + if (!discoveryConfig.sectionTypes) { + discoveryConfig.sectionTypes = {}; + } + + if (discoveryConfig.collectionSection) { + const legacySection = discoveryConfig.collectionSection; + const existingCollectionEntry = discoveryConfig.sectionTypes.collections; + if (!existingCollectionEntry || typeof existingCollectionEntry !== 'object') { + discoveryConfig.sectionTypes.collections = { + enabled: legacySection.enabled !== false, + name: legacySection.name || discoveryConfig.sectionNames?.collections || '[Collection Name]', + minimumItems: legacySection.minimumItems ?? 10, + itemLimit: legacySection.itemLimit ?? null, + sortOrder: legacySection.sortOrder ?? null, + sortOrderDirection: legacySection.sortOrderDirection ?? null, + cardFormat: legacySection.cardFormat ?? null, + order: legacySection.order ?? 50 + }; + } else if (legacySection.minimumItems !== undefined && existingCollectionEntry.minimumItems === undefined) { + existingCollectionEntry.minimumItems = legacySection.minimumItems; + } + delete discoveryConfig.collectionSection; + } + + function parseDiscoveryNumber(value, fallback) { + if (value === null || value === undefined || value === '') { + return fallback; + } + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : fallback; + } + + function getDiscoverySectionKeyFromType(sectionType) { + return discoverySectionTypeMap[sectionType] || null; + } + + function getDiscoverySectionSettings(sectionKey) { + if (!sectionKey) return null; + if (discoverySectionSettingsCache[sectionKey]) { + return discoverySectionSettingsCache[sectionKey]; + } + + const definition = discoverySectionDefinitionMap[sectionKey] || {}; + const rawConfig = discoveryConfig.sectionTypes?.[sectionKey]; + const legacyNames = discoveryConfig.sectionNames || {}; + + const defaultItemLimitForDiscovery = discoveryConfig.defaultItemLimit ?? defaultItemLimit; + const defaultSortOrderForDiscovery = discoveryConfig.defaultSortOrder ?? defaultSortOrder; + const defaultCardFormatForDiscovery = discoveryConfig.defaultCardFormat ?? defaultCardFormat; + + let normalized = { + enabled: true, + name: legacyNames[sectionKey] || definition.defaultName || '', + itemLimit: defaultItemLimitForDiscovery, + sortOrder: defaultSortOrderForDiscovery, + sortOrderDirection: 'Ascending', + cardFormat: defaultCardFormatForDiscovery + }; + + if (definition.minimumItems !== undefined) { + normalized.minimumItems = definition.minimumItems; + } + + if (rawConfig === false) { + normalized.enabled = false; + } else if (rawConfig && typeof rawConfig === 'object') { + normalized = { ...normalized, ...rawConfig }; + } else if (rawConfig === true) { + normalized.enabled = true; + } + + normalized.name = normalized.name || definition.defaultName || ''; + normalized.itemLimit = parseDiscoveryNumber(normalized.itemLimit, defaultItemLimitForDiscovery); + normalized.sortOrder = normalized.sortOrder || defaultSortOrderForDiscovery; + normalized.sortOrderDirection = normalized.sortOrderDirection || 'Ascending'; + normalized.cardFormat = normalized.cardFormat || defaultCardFormatForDiscovery; + if (normalized.minimumItems !== undefined) { + normalized.minimumItems = parseDiscoveryNumber(normalized.minimumItems, definition.minimumItems ?? 10); + } + + discoverySectionSettingsCache[sectionKey] = normalized; + return normalized; + } + + function applyDiscoverySectionOrdering(items, sectionConfig) { + if (!Array.isArray(items)) return []; + const sortOrder = sectionConfig?.sortOrder || discoveryConfig.defaultSortOrder || defaultSortOrder; + const sortDirection = sectionConfig?.sortOrderDirection || 'Ascending'; + let sortedItems = items; + + if (sortOrder === 'Random') { + sortedItems = [...items].sort(() => Math.random() - 0.5); + } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { + sortedItems = window.cardBuilder.sortItems(items, sortOrder, sortDirection); + } + + const limit = parseDiscoveryNumber( + sectionConfig?.itemLimit, + discoveryConfig.defaultItemLimit ?? defaultItemLimit + ); + + return sortedItems.slice(0, limit); + } // Processing flag to prevent parallel execution let isProcessing = false; @@ -246,6 +841,12 @@ let renderedWriters = new Set(); // Track rendered writer IDs let renderedStudios = new Set(); // Track rendered studio IDs let renderedWatchedMovies = new Set(); // Track rendered watched movie IDs for "Because you watched" sections + let renderedGenres = new Set(); // Track rendered genre IDs for spotlight sections + let renderedNetworks = new Set(); // Track rendered network IDs for spotlight sections + let renderedCollections = new Set(); // Track rendered collection IDs + let renderedCustomDiscoverySections = new Set(); // Track custom sections used in discovery + let cachedQualifyingGenres = null; // Cache qualifying genres to avoid refetching + let isCachingQualifyingGenres = false; // Prevent parallel caching let renderedFavoriteMovies = new Set(); // Track rendered favorite movie IDs let renderedStarringWatchedMovies = new Set(); // Track movies used for "Starring X since you watched Y" sections let renderedDirectedWatchedMovies = new Set(); // Track movies used for "Directed by X since you watched Y" sections @@ -297,17 +898,23 @@ } } + function buildParams(params) { + return Object.entries(params) + .map(([key, value]) => `${key}=${value}`) + .join('&'); + } + /** * Fetches playlist data from Jellyfin API * @param {string} playlistId - The playlist ID to fetch * @returns {Promise} - Playlist data with ItemIds array */ - async function fetchPlaylistData(playlistId) { + async function fetchPlaylistData(playlistId, params = {}) { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); - const url = `${serverUrl}/Playlists/${playlistId}`; + const url = `${serverUrl}/Playlists/${playlistId}/Items?${buildParams(params)}`; try { const response = await fetch(url, { @@ -339,7 +946,7 @@ const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); - const url = `${serverUrl}/Users/${userId}/Items?ParentId=${collectionId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CCanDelete%2CMediaSourceCount`; + const url = `${serverUrl}/Users/${userId}/Items?ParentId=${collectionId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CCanDelete%2CMediaSourceCount%2COverview%2CTaglines`; try { const response = await fetch(url, { @@ -379,6 +986,287 @@ } } + /** + * Fetches genre items from Jellyfin API + * @param {string} genreId - The genre ID + * @returns {Promise} - Array of item IDs + */ + async function fetchGenreItems(genreId) { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + + // First get genre name + const genreData = await fetchItemData(genreId); + if (!genreData) return []; + + const genreName = genreData.Name; + + const url = `${serverUrl}/Items?userId=${userId}&Genres=${encodeURIComponent(genreName)}&Recursive=true&Fields=ItemCounts`; + + try { + const response = await fetch(url, { + headers: { "Authorization": `MediaBrowser Token="${token}"` } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return { Items: data.Items || [] }; + } catch (err) { + ERR(`Failed to fetch genre items for ${genreName}:`, err); + return { Items: [] }; + } + } + + /** + * Fetches studio items from Jellyfin API + * @param {string} studioId - The studio ID + * @returns {Promise} - Array of item IDs + */ + async function fetchStudioItems(studioId) { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + + const url = `${serverUrl}/Items?userId=${userId}&StudioIds=${studioId}&IncludeItemTypes=Series&Recursive=true&Fields=ItemCounts`; + + try { + const response = await fetch(url, { + headers: { "Authorization": `MediaBrowser Token="${token}"` } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return { Items: data.Items || [] }; + } catch (err) { + ERR(`Failed to fetch studio items for ${studioId}:`, err); + return { Items: [] }; + } + } + + async function loadItemsForSectionConfig(sectionConfig) { + const apiClient = window.ApiClient; + const serverAddress = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + const type = sectionConfig.type || 'Genre'; + const source = sectionConfig.source || ''; + const searchTerm = sectionConfig.searchTerm || ''; + const includeItemTypes = Array.isArray(sectionConfig.includeItemTypes) && sectionConfig.includeItemTypes.length > 0 + ? sectionConfig.includeItemTypes.join(',') + : (sectionConfig.includeItemTypes || 'Movie'); + const sortOrder = sectionConfig.sortOrder || defaultSortOrder; + const sortOrderDirection = sectionConfig.sortOrderDirection || 'Ascending'; + const itemLimit = sectionConfig.itemLimit || defaultItemLimit; + let allItems = []; + + const sources = source.split(',').map(s => s.trim()).filter(Boolean); + // For Parent type, empty sources is valid (means "Any") + if (sources.length === 0 && type !== 'Parent') { + return allItems; + } + + try { + // Build common query parameters + const commonParams = { + userId: userId, + IncludeItemTypes: includeItemTypes, + Recursive: true, + Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,ProductionYear', + Limit: itemLimit, + SortBy: sortOrder, + SortOrder: sortOrderDirection + }; + + // Add search term if provided + if (searchTerm) { + commonParams.SearchTerm = searchTerm; + } + + if (type === 'Genre') { + // Combine all genres into a single query + const genresParam = sources.map(s => encodeURIComponent(s)).join('|'); + + const options = { + Genres: genresParam, + ...commonParams + } + + const data = await window.apiHelper.getItems(options, true); + allItems = data.Items || []; + } else if (type === 'Tag') { + // Combine all tags into a single query + const tagsParam = sources.map(s => encodeURIComponent(s)).join(','); + + const options = { + Tags: tagsParam, + ...commonParams + } + + const data = await window.apiHelper.getItems(options, true); + allItems = data.Items || []; + } else if (type === 'Collection') { + // For collections, we need to query each collection separately as they use ParentId + // But we can still use a single approach by collecting all items first + const existingIds = new Set(); + for (const collectionId of sources) { + try { + const queryParams = { + ParentId: collectionId, + IncludeItemTypes: includeItemTypes, + Fields: 'ItemCounts,Overview,Taglines', + Limit: itemLimit * sources.length, // Get more items to account for filtering + SortBy: sortOrder, + SortOrder: sortOrderDirection + }; + + if (searchTerm) { + queryParams.SearchTerm = searchTerm; + } + + const collectionData = await window.apiHelper.getItems(queryParams, true); + if (collectionData.Items && collectionData.Items.length > 0) { + collectionData.Items.forEach(item => { + if (item && item.Id && !existingIds.has(item.Id)) { + allItems.push(item); + existingIds.add(item.Id); + } + }); + } + } catch (err) { + WARN(`Error fetching collection ${collectionId}:`, err); + } + } + + // Apply sorting and limit after collecting all items + if (sortOrder === 'Random') { + allItems.sort(() => Math.random() - 0.5); + } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { + allItems = window.cardBuilder.sortItems(allItems, sortOrder, sortOrderDirection); + } + allItems = allItems.slice(0, itemLimit); + } else if (type === 'Playlist') { + // For playlists, similar to collections - query each separately + const existingIds = new Set(); + for (const playlistId of sources) { + try { + const queryParams = { + ParentId: playlistId, + IncludeItemTypes: includeItemTypes, + Fields: 'Overview,Taglines', + Limit: itemLimit * sources.length, // Get more items to account for filtering + SortBy: sortOrder, + SortOrder: sortOrderDirection + }; + + if (searchTerm) { + queryParams.SearchTerm = searchTerm; + } + + const playlistData = await window.apiHelper.getItems(queryParams, true); + if (playlistData.Items && playlistData.Items.length > 0) { + playlistData.Items.forEach(item => { + if (item && item.Id && !existingIds.has(item.Id)) { + allItems.push(item); + existingIds.add(item.Id); + } + }); + } + } catch (err) { + WARN(`Error fetching playlist ${playlistId}:`, err); + } + } + + // Apply sorting and limit after collecting all items + if (sortOrder === 'Random') { + allItems.sort(() => Math.random() - 0.5); + } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { + allItems = window.cardBuilder.sortItems(allItems, sortOrder, sortOrderDirection); + } + allItems = allItems.slice(0, itemLimit); + } else if (type === 'Parent') { + // Parent type: query with ParentId(s) or without if source is empty ("Any") + const existingIds = new Set(); + + if (sources.length === 0) { + // No parent IDs specified - query all items (no ParentId filter) + try { + const queryParams = { + IncludeItemTypes: includeItemTypes, + Recursive: true, + Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,ProductionYear', + Limit: itemLimit, + SortBy: sortOrder, + SortOrder: sortOrderDirection + }; + + if (searchTerm) { + queryParams.SearchTerm = searchTerm; + } + + const response = await window.apiHelper.getItems(queryParams, true); + if (response.Items && response.Items.length > 0) { + allItems = response.Items; + } + } catch (err) { + WARN(`Error fetching items (Parent type, no ParentId):`, err); + } + } else { + // Query each parent ID separately + for (const parentId of sources) { + try { + const queryParams = { + ParentId: parentId, + IncludeItemTypes: includeItemTypes, + Recursive: true, + Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,ProductionYear', + Limit: itemLimit * sources.length, + SortBy: sortOrder, + SortOrder: sortOrderDirection + }; + + if (searchTerm) { + queryParams.SearchTerm = searchTerm; + } + + const parentData = await window.apiHelper.getItems(queryParams, true); + if (parentData.Items && parentData.Items.length > 0) { + parentData.Items.forEach(item => { + if (item && item.Id && !existingIds.has(item.Id)) { + allItems.push(item); + existingIds.add(item.Id); + } + }); + } + } catch (err) { + WARN(`Error fetching items for parent ${parentId}:`, err); + } + } + + // Apply sorting and limit after collecting all items + if (sortOrder === 'Random') { + allItems.sort(() => Math.random() - 0.5); + } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { + allItems = window.cardBuilder.sortItems(allItems, sortOrder, sortOrderDirection); + } + allItems = allItems.slice(0, itemLimit); + } + } + } catch (err) { + WARN('Error loading items for section config:', err); + } + + return allItems; + } + /** * Renders a custom home section * @param {Object} section - Section configuration object @@ -387,68 +1275,68 @@ */ async function renderCustomSection(section, container) { try { - // First, fetch item data to determine type - const itemData = await fetchItemData(section.id); - if (!itemData) { - ERR(`Failed to fetch item data for section ${section.name}`); + // Support new structure (type + source) and legacy structure (sourceId) + const type = section.type; + const source = section.source || section.sourceId || section.id; + + if (!source) { + ERR(`No source provided for section ${section.name}`); return false; } - let itemIds = []; - let itemType = itemData.Type; - - // Handle different item types - if (itemType === 'Playlist') { - // Fetch playlist data - const playlistData = await fetchPlaylistData(section.id); - itemIds = playlistData.ItemIds || []; - } else if (itemType === 'BoxSet') { - // Fetch collection data - const collectionData = await fetchCollectionData(section.id); - itemIds = (collectionData.Items || []).map(item => item.Id); - } else { - ERR(`Unsupported item type ${itemType} for section ${section.name}`); - return false; - } - - // Limit to maxItems - const limitedItemIds = itemIds.slice(0, section.maxItems); - - if (limitedItemIds.length === 0) { - return false; - } - - // Generate view more URL - const viewMoreUrl = generateViewMoreUrl(section.id, itemType); - - // Check if cardBuilder is available - if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCardsFromIds) { - WARN("cardBuilder not available, skipping section:", section.name); - return false; - } - - // Render the scrollable container - const scrollableContainer = await window.cardBuilder.renderCardsFromIds( - limitedItemIds, - section.name ?? itemData.Name, - viewMoreUrl, - true - ); - - // Add data attribute to track rendered sections - scrollableContainer.setAttribute('data-custom-section-id', section.id); - scrollableContainer.setAttribute('data-custom-section-name', section.name); - scrollableContainer.setAttribute('data-custom-section-type', itemType); + const sortOrder = section.sortOrder ?? defaultSortOrder; + const sortOrderDirection = section.sortOrderDirection ?? 'Ascending'; + const cardFormat = section.cardFormat ?? defaultCardFormat; + const order = section.order ?? 100; + const useSpotlight = section.spotlight === true; - if (section.order) { - scrollableContainer.style.order = section.order; + let viewMoreUrl = null; + switch (type) { + case 'Genre': + viewMoreUrl = `#/list.html?genreId=${source}&serverId=${ApiClient.serverId()}`; + break; + case 'Studio': + viewMoreUrl = `#/list.html?studioId=${source}&serverId=${ApiClient.serverId()}`; + break; + case 'Playlist': + case 'BoxSet': + viewMoreUrl = `#/details?id=${source}&serverId=${ApiClient.serverId()}`; + break; + case 'Parent': + viewMoreUrl = `#/list.html?parentId=${source}&serverId=${ApiClient.serverId()}`; + break; + default: + viewMoreUrl = null; + break; } - // Append to container - container.appendChild(scrollableContainer); - - return true; + // If new structure with type, use renderSection helper + if (useSpotlight && window.cardBuilder.renderSpotlightSection) { + const items = await loadItemsForSectionConfig(section); + + const spotlightContainer = window.cardBuilder.renderSpotlightSection( + items, + section.name ?? 'Spotlight', + { + autoPlay: spotlightAutoPlay, + interval: spotlightInterval, + showDots: spotlightShowDots, + showNavButtons: spotlightShowNavButtons, + showClearArt: spotlightShowClearArt, + viewMoreUrl: viewMoreUrl + } + ); + + spotlightContainer.setAttribute('data-custom-section-id', section.id || source); + spotlightContainer.setAttribute('data-custom-section-name', section.name); + spotlightContainer.setAttribute('data-custom-section-type', type); + spotlightContainer.style.order = order; + + container.appendChild(spotlightContainer); + return true; + } + return await renderSection(section, container, 'custom'); } catch (err) { ERR(`Error rendering section ${section.name}:`, err); return false; @@ -462,6 +1350,10 @@ async function renderAllCustomSections(container) { const results = await Promise.all( customHomeSections.map(section => { + if (section.discoveryEnabled ) { + return false; + } + // Check if section is already on the page const sectionContainer = container.querySelector(`[data-custom-section-id="${section.id}"]`); if (sectionContainer) { @@ -525,15 +1417,6 @@ sectionsToRender.push(renderNewEpisodesSection(container)); } - // Add trending sections if enabled (stubs for now) -/* const trendingMoviesContainer = container.querySelector('[data-custom-section-id="trending-movies"]'); - const trendingSeriesContainer = container.querySelector('[data-custom-section-id="trending-series"]'); - - if (enableTrending && !trendingMoviesContainer && !trendingSeriesContainer) { - sectionsToRender.push(renderTrendingMoviesSection(container)); - sectionsToRender.push(renderTrendingSeriesSection(container)); - } */ - if (sectionsToRender.length === 0) { return; } @@ -1310,22 +2193,13 @@ * Gets watchlist data from localStorageCache * @returns {Array} - Combined watchlist items (movies + series) */ - function getWatchlistData() { - if (typeof window.LocalStorageCache === 'undefined') { - WARN('LocalStorageCache not available'); - return []; - } + async function getWatchlistData() { + const watchlistData = await window.apiHelper.getWatchlistItems({ IncludeItemTypes: 'Movie,Series,Season,Episode' }, true); - const cache = new window.LocalStorageCache(); - const movies = cache.get('watchlist_movies') || []; - const series = cache.get('watchlist_series') || []; - const seasons = cache.get('watchlist_seasons') || []; - const episodes = cache.get('watchlist_episodes') || []; - - return [...movies, ...series, ...seasons, ...episodes].sort((a, b) => { + return watchlistData.Items.sort((a, b) => { const dateA = new Date(a.PremiereDate || 0); const dateB = new Date(b.PremiereDate || 0); - return dateB - dateA; + return dateB - dateA; }); } @@ -1680,32 +2554,23 @@ */ async function fetchNewMovies() { const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - const userId = apiClient.getCurrentUserId(); const today = new Date().toISOString().split('T')[0]; // Format: YYYY-MM-DD const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; // Format: YYYY-MM-DD - const url = `${serverUrl}/Items?IncludeItemTypes=Movie&SortBy=DateCreated&SortOrder=Descending&Limit=16&Fields=PremiereDate&Recursive=true&MinPremiereDate=${monthAgo}&MaxPremiereDate=${today}&userId=${userId}`; - - try { - const response = await fetch(url, { - headers: { - 'X-Emby-Token': token - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - return data.Items || []; - - } catch (err) { - ERR('Failed to fetch new movies:', err); - return []; + + const options = { + IncludeItemTypes: 'Movie', + SortBy: 'DateCreated', + SortOrder: 'Descending', + Limit: 16, + Fields: 'PremiereDate', + Recursive: true, + MinPremiereDate: monthAgo, + MaxPremiereDate: today } + + const data = await window.apiHelper.getItems(options, true); + return data.Items || []; } /** @@ -1716,48 +2581,32 @@ */ async function fetchNewEpisodes() { const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - const userId = apiClient.getCurrentUserId(); // Compute ISO date range for the last 7 days const now = new Date(); - const maxDate = now.toISOString(); - const minDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(); - - const url = `${serverUrl}/Items?IncludeItemTypes=Episode` + - `&LocationTypes=FileSystem` + - `&Recursive=true` + - `&SortBy=PremiereDate&SortOrder=Descending` + - `&MinPremiereDate=${encodeURIComponent(minDate)}` + - `&MaxPremiereDate=${encodeURIComponent(maxDate)}` + - `&Limit=100` + - `&Fields=PremiereDate,SeriesName,ParentIndexNumber,IndexNumber,ProviderIds,Path` + - `&userId=${userId}`; - - try { - const response = await fetch(url, { - headers: { 'X-Emby-Token': token } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - const episodes = data.Items || []; - - // Deduplicate episodes from the same series that aired on the same date - const deduplicatedEpisodes = deduplicateEpisodesBySeriesAndDate(episodes); - - LOG(`Fetched ${episodes.length} episodes, deduplicated to ${deduplicatedEpisodes.length} episodes`); - - return deduplicatedEpisodes; - - } catch (err) { - ERR('Failed to fetch new episodes:', err); - return []; + const maxDate = now.toISOString().split('T')[0]; + const minDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + const options = { + IncludeItemTypes: 'Episode', + LocationTypes: 'FileSystem', + Recursive: true, + SortBy: 'PremiereDate', + SortOrder: 'Descending', + MinPremiereDate: minDate, + MaxPremiereDate: maxDate, + Limit: 100, + Fields: 'PremiereDate,SeriesName,ParentIndexNumber,IndexNumber,ProviderIds,Path' } + + const data = await window.apiHelper.getItems(options, true); + const episodes = data.Items || []; + + // Deduplicate episodes from the same series that aired on the same date + const deduplicatedEpisodes = deduplicateEpisodesBySeriesAndDate(episodes); + + LOG(`Fetched ${episodes.length} episodes, deduplicated to ${deduplicatedEpisodes.length} episodes`); + return deduplicatedEpisodes; } /** @@ -1792,16 +2641,27 @@ // Only one episode for this series/date, keep it deduplicatedEpisodes.push(episodeGroup[0]); } else { - // Multiple episodes for this series/date, find the one with lowest index number + // Multiple episodes for this series/date, find the one with lowest ParentIndexNumber and IndexNumber const episodeWithLowestIndex = episodeGroup.reduce((lowest, current) => { - const currentIndex = current.IndexNumber || 999999; - const lowestIndex = lowest.IndexNumber || 999999; - return currentIndex < lowestIndex ? current : lowest; + const currentParentIndex = current.ParentIndexNumber ?? 999999; + const lowestParentIndex = lowest.ParentIndexNumber ?? 999999; + const currentIndex = current.IndexNumber ?? 999999; + const lowestIndex = lowest.IndexNumber ?? 999999; + + // First compare by ParentIndexNumber (season number) + if (currentParentIndex < lowestParentIndex) { + return current; + } else if (currentParentIndex > lowestParentIndex) { + return lowest; + } else { + // Same season, compare by IndexNumber (episode number) + return currentIndex < lowestIndex ? current : lowest; + } }); deduplicatedEpisodes.push(episodeWithLowestIndex); - LOG(`Deduplicated ${episodeGroup.length} episodes from "${episodeGroup[0].SeriesName}" on ${episodeGroup[0].PremiereDate}, kept episode ${episodeWithLowestIndex.IndexNumber}`); + LOG(`Deduplicated ${episodeGroup.length} episodes from "${episodeGroup[0].SeriesName}" on ${episodeGroup[0].PremiereDate}, kept S${episodeWithLowestIndex.ParentIndexNumber ?? '?'}E${episodeWithLowestIndex.IndexNumber ?? '?'}`); } }); @@ -1818,7 +2678,7 @@ const movies = await fetchNewMovies(); if (movies.length === 0) { - return false; + return false; // Auto-hide empty sections } // Check if cardBuilder is available @@ -1827,18 +2687,32 @@ return false; } + // Get config values + const itemLimit = newMoviesConfig.itemLimit ?? defaultItemLimit; + const sortOrder = newMoviesConfig.sortOrder ?? defaultSortOrder; + const sortOrderDirection = newMoviesConfig.sortOrderDirection ?? 'Descending'; + const cardFormat = newMoviesConfig.cardFormat ?? defaultCardFormat; + const order = newMoviesConfig.order ?? 30; + const sectionName = newMoviesConfig.name || 'Recently Released Movies'; + + // Apply limit + const limitedMovies = movies.slice(0, itemLimit); + // Render the scrollable container const scrollableContainer = window.cardBuilder.renderCards( - movies, - 'Recently Released Movies', + limitedMovies, + sectionName, null, - true + true, + cardFormat, + sortOrder, + sortOrderDirection ); // Add data attributes to track rendered sections scrollableContainer.setAttribute('data-custom-section-id', 'new-movies'); - scrollableContainer.setAttribute('data-custom-section-name', 'New Movies'); - scrollableContainer.style.order = NEW_MOVIES_ORDER; + scrollableContainer.setAttribute('data-custom-section-name', sectionName); + scrollableContainer.style.order = order; // Append to container container.appendChild(scrollableContainer); @@ -1861,7 +2735,7 @@ const episodes = await fetchNewEpisodes(); if (episodes.length === 0) { - return false; + return false; // Auto-hide empty sections } // Check if cardBuilder is available @@ -1870,18 +2744,33 @@ return false; } + // Get config values + const itemLimit = newEpisodesConfig.itemLimit ?? defaultItemLimit; + const sortOrder = newEpisodesConfig.sortOrder ?? defaultSortOrder; + const sortOrderDirection = newEpisodesConfig.sortOrderDirection ?? 'Descending'; + const cardFormat = newEpisodesConfig.cardFormat ?? defaultCardFormat; + const order = newEpisodesConfig.order ?? 31; + const sectionName = newEpisodesConfig.name || 'Recently Aired Episodes'; + + // Apply limit + const limitedEpisodes = episodes.slice(0, itemLimit); + const viewMoreUrl = `#/tv.html?collectionType=tvshows&tab=2`; + // Render the scrollable container const scrollableContainer = window.cardBuilder.renderCards( - episodes, - 'Recently Aired Episodes', - null, - true + limitedEpisodes, + sectionName, + viewMoreUrl, + true, + cardFormat, + sortOrder, + sortOrderDirection ); // Add data attributes to track rendered sections scrollableContainer.setAttribute('data-custom-section-id', 'new-episodes'); - scrollableContainer.setAttribute('data-custom-section-name', 'New Episodes'); - scrollableContainer.style.order = NEW_EPISODES_ORDER; + scrollableContainer.setAttribute('data-custom-section-name', sectionName); + scrollableContainer.style.order = order; // Append to container container.appendChild(scrollableContainer); @@ -1899,7 +2788,7 @@ * @param {HTMLElement} container - Container to append the section to * @returns {boolean} - Success status */ - function renderWatchlistSection(container) { + async function renderWatchlistSection(container) { try { // Check if section is already on the page const sectionContainer = container.querySelector('[data-custom-section-id="watchlist"]'); @@ -1908,18 +2797,34 @@ return false; } - const watchlistItems = getWatchlistData(); + const watchlistItems = await getWatchlistData(); if (watchlistItems.length === 0) { - return false; + return false; // Auto-hide empty sections } - // Get 16 random items from watchlist - const shuffled = [...watchlistItems].sort(() => Math.random() - 0.5); - const limitedItems = shuffled.slice(0, 16); + // Get config values + const itemLimit = watchlistConfig.itemLimit ?? defaultItemLimit; + const sortOrder = watchlistConfig.sortOrder ?? defaultSortOrder; + const sortOrderDirection = watchlistConfig.sortOrderDirection ?? 'Descending'; + const cardFormat = watchlistConfig.cardFormat ?? defaultCardFormat; + const order = watchlistConfig.order ?? 60; + const sectionName = watchlistConfig.name || 'Watchlist'; + + // Apply sorting and limit + let sortedItems = watchlistItems; + if (sortOrder === 'Random') { + sortedItems = [...watchlistItems].sort(() => Math.random() - 0.5); + } else { + // Use cardBuilder sort helper if available + if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { + sortedItems = window.cardBuilder.sortItems(watchlistItems, sortOrder, sortOrderDirection); + } + } + const limitedItems = sortedItems.slice(0, itemLimit); if (limitedItems.length === 0) { - return false; + return false; // Auto-hide empty sections } // Check if cardBuilder is available @@ -1931,16 +2836,18 @@ // Render the scrollable container with function-based navigation const scrollableContainer = window.cardBuilder.renderCards( limitedItems, - 'My Watchlist', + sectionName, getWatchlistUrl(), true, - 'portrait' + cardFormat, + sortOrder, + sortOrderDirection ); // Add data attribute to track rendered sections scrollableContainer.setAttribute('data-custom-section-id', 'watchlist'); - scrollableContainer.setAttribute('data-custom-section-name', 'Your Watchlist'); - scrollableContainer.style.order = WATCHLIST_ORDER; + scrollableContainer.setAttribute('data-custom-section-name', sectionName); + scrollableContainer.style.order = order; // Append to container container.appendChild(scrollableContainer); @@ -1953,6 +2860,351 @@ } } + /** + * Formats air date for display + * @param {string} premiereDate - ISO date string + * @returns {string} - Formatted date string (MM/DD/YYYY) + */ + function formatAirDate(premiereDate) { + if (!premiereDate) return ''; + try { + const date = new Date(premiereDate); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const year = date.getFullYear(); + return `${month}/${day}/${year}`; + } catch (e) { + return ''; + } + } + + /** + * Renders Upcoming section (upcoming TV episodes) + * @param {HTMLElement} container - Container to append the section to + * @returns {Promise} - Success status + */ + async function renderUpcomingSection(container) { + try { + if (!enableUpcoming) { + return false; + } + + // Check if section is already on the page + const sectionContainer = container.querySelector('[data-custom-section-id="upcoming"]'); + if (sectionContainer) { + LOG('Upcoming section already on the page, skipping...'); + return false; + } + + const userId = ApiClient.getCurrentUserId(); + const serverAddress = ApiClient.serverAddress(); + const itemLimit = upcomingConfig.itemLimit || 48; + const cardFormat = upcomingConfig.cardFormat || 'Backdrop'; + const order = upcomingConfig.order || 20; + const sectionName = upcomingConfig.name || 'Upcoming'; + + // Get the parent id from the root libraryies with CollectionType: "tvshows" + const libraries = await ApiClient.getItems(); + const parentIds = libraries.Items.filter(lib => lib.CollectionType === 'tvshows')?.map(lib => lib.Id); + + const url = `${serverAddress}/Shows/Upcoming?Limit=${itemLimit}&Fields=AirTime,SeriesName,ParentIndexNumber,IndexNumber&UserId=${userId}&ImageTypeLimit=1&EnableImageTypes=Primary,Backdrop,Banner,Thumb&EnableTotalRecordCount=false&ParentIds=${parentIds.join(',')}`; + + const data = await window.apiHelper.getData(url, true); + let episodes = data.Items || []; + + // Filter out episodes that have already aired (before today) + const today = new Date(); + today.setHours(0, 0, 0, 0); // Set to start of today + + episodes = episodes.filter(ep => { + if (!ep.PremiereDate) return false; + const airDate = new Date(ep.PremiereDate); + airDate.setHours(0, 0, 0, 0); // Set to start of air date + return airDate >= today; // Only include today and future dates + }); + + // Deduplicate episodes from the same series that air on the same date + // Keeps only the episode with the lowest ParentIndexNumber and IndexNumber + episodes = deduplicateEpisodesBySeriesAndDate(episodes); + + if (episodes.length === 0) { + return false; // Auto-hide empty sections after deduplication + } + + // Format air dates for display + episodes.forEach(ep => { + ep._displayAirDate = formatAirDate(ep.PremiereDate); + }); + + // Check if cardBuilder is available + if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { + WARN("cardBuilder not available, skipping Upcoming section"); + return false; + } + + const viewMoreUrl = `#/tv.html?collectionType=tvshows&tab=2`; + const itemsContainer = window.cardBuilder.renderCards(episodes, sectionName, viewMoreUrl, true, cardFormat); + + itemsContainer.setAttribute('data-custom-section-id', 'upcoming'); + itemsContainer.setAttribute('data-custom-section-name', sectionName); + itemsContainer.style.order = order; + + container.appendChild(itemsContainer); + + return true; + + } catch (err) { + ERR('Error rendering Upcoming section:', err); + return false; + } + } + + /** + * Caches IMDb IDs in localStorage + * @param {Array} imdbIds - Array of IMDb IDs + */ + function cacheImdbIds(imdbIds) { + try { + localStorage.setItem('kefinTweaks_imdbIds', JSON.stringify({ + ids: imdbIds, + timestamp: Date.now() + })); + } catch (err) { + WARN('Failed to cache IMDb IDs:', err); + } + } + + /** + * Gets cached IMDb IDs from localStorage + * @returns {Object|null} - Cached data with ids and timestamp + */ + function getCachedImdbIds() { + try { + const cached = localStorage.getItem('kefinTweaks_imdbIds'); + return cached ? JSON.parse(cached) : null; + } catch (err) { + WARN('Failed to get cached IMDb IDs:', err); + return null; + } + } + + /** + * Checks if cache is expired (older than 24 hours) + * @param {number} timestamp - Cache timestamp + * @param {number} maxAge - Max age in milliseconds (default: 24 hours) + * @returns {boolean} - True if expired + */ + function isCacheExpired(timestamp, maxAge = 24 * 60 * 60 * 1000) { + return !timestamp || (Date.now() - timestamp) > maxAge; + } + + /** + * Renders IMDb Top 250 section + * @param {HTMLElement} container - Container to append the section to + * @returns {Promise} - Success status + */ + async function renderImdbTop250Section(container) { + try { + if (!enableImdbTop250) { + return false; + } + + // Check if section is already on the page + const sectionContainer = container.querySelector('[data-custom-section-id="imdb-top250"]'); + if (sectionContainer) { + LOG('IMDb Top 250 section already on the page, skipping...'); + return false; + } + + const itemLimit = imdbTop250Config.itemLimit ?? defaultItemLimit; + const sortOrder = imdbTop250Config.sortOrder ?? 'Random'; + const sortOrderDirection = imdbTop250Config.sortOrderDirection ?? 'Ascending'; + const cardFormat = imdbTop250Config.cardFormat ?? defaultCardFormat; + const order = imdbTop250Config.order ?? 21; + const sectionName = imdbTop250Config.name || 'IMDb Top 250'; + + // Get cached IMDb IDs (refresh daily) + let cachedData = getCachedImdbIds(); + let imdbIds = null; + + if (!cachedData || isCacheExpired(cachedData.timestamp, 24 * 60 * 60 * 1000)) { + // Fetch Top 250 list + const top250Response = await fetch('https://raw.githubusercontent.com/theapache64/top250/master/top250_min.json'); + if (!top250Response.ok) { + throw new Error(`Failed to fetch IMDb Top 250: ${top250Response.statusText}`); + } + + const top250 = await top250Response.json(); + + // Extract IMDb IDs + imdbIds = top250.map(entry => { + const match = entry.imdb_url?.match(/\/title\/(tt\d+)\//); + return match ? match[1] : null; + }).filter(Boolean); + + // Cache IDs + cacheImdbIds(imdbIds); + } else { + imdbIds = cachedData.ids; + } + + if (!imdbIds || imdbIds.length === 0) { + WARN('No IMDb IDs available'); + return false; + } + + // Fetch all movies and match by IMDb ID + const userId = ApiClient.getCurrentUserId(); + const allMoviesResponse = await ApiClient.getItems(userId, { + IncludeItemTypes: 'Movie', + Recursive: true, + Fields: 'ProviderIds' + }); + + const allMovies = allMoviesResponse.Items || []; + const matchedMovies = []; + + for (const imdbId of imdbIds) { + const movie = allMovies.find(m => m.ProviderIds?.Imdb === imdbId); + if (movie) { + matchedMovies.push(movie); + } + } + + if (matchedMovies.length === 0) { + return false; // Auto-hide empty sections + } + + // Apply sort order (Random only, no rank data available) + let sortedMovies = matchedMovies; + if (sortOrder === 'Random') { + sortedMovies = [...matchedMovies].sort(() => Math.random() - 0.5); + } + + // Apply limit + const limited = sortedMovies.slice(0, itemLimit); + + if (limited.length === 0) { + return false; // Auto-hide empty sections + } + + // Check if cardBuilder is available + if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { + WARN("cardBuilder not available, skipping IMDb Top 250 section"); + return false; + } + + const scrollableContainer = window.cardBuilder.renderCards( + limited, + sectionName, + null, + true, + cardFormat, + sortOrder, + sortOrderDirection + ); + + scrollableContainer.setAttribute('data-custom-section-id', 'imdb-top250'); + scrollableContainer.setAttribute('data-custom-section-name', sectionName); + scrollableContainer.style.order = order; + + container.appendChild(scrollableContainer); + + return true; + + } catch (err) { + ERR('Error rendering IMDb Top 250 section:', err); + return false; + } + } + + /** + * Renders Watch Again section (random watched movies) + * @param {HTMLElement} container - Container to append the section to + * @returns {Promise} - Success status + */ + async function renderWatchAgainSection(container) { + try { + if (!enableWatchAgain) { + return false; + } + + // Check if section is already on the page + const sectionContainer = container.querySelector('[data-custom-section-id="watch-again"]'); + if (sectionContainer) { + LOG('Watch Again section already on the page, skipping...'); + return false; + } + + const userId = ApiClient.getCurrentUserId(); + const itemLimit = watchAgainConfig.itemLimit ?? defaultItemLimit; + const sortOrder = watchAgainConfig.sortOrder ?? 'Random'; + const sortOrderDirection = watchAgainConfig.sortOrderDirection ?? 'Ascending'; + const cardFormat = watchAgainConfig.cardFormat ?? defaultCardFormat; + const order = watchAgainConfig.order ?? 62; + const sectionName = watchAgainConfig.name || 'Watch Again'; + + // Fetch watched movies + const watchedMoviesResponse = await ApiClient.getItems(userId, { + IncludeItemTypes: 'Movie', + Filters: 'IsPlayed', + SortBy: 'Random', + Recursive: true, + Limit: itemLimit + }); + + const watchedMovies = watchedMoviesResponse.Items || []; + + if (watchedMovies.length === 0) { + return false; // Auto-hide empty sections + } + + // Apply sort order + let sortedMovies = watchedMovies; + if (sortOrder !== 'Random') { + // Use cardBuilder sort if available + if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { + sortedMovies = window.cardBuilder.sortItems(watchedMovies, sortOrder, sortOrderDirection); + } + } + + // Apply limit + const limited = sortedMovies.slice(0, itemLimit); + + if (limited.length === 0) { + return false; // Auto-hide empty sections + } + + // Check if cardBuilder is available + if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { + WARN("cardBuilder not available, skipping Watch Again section"); + return false; + } + + const scrollableContainer = window.cardBuilder.renderCards( + limited, + sectionName, + null, + true, + cardFormat, + sortOrder, + sortOrderDirection + ); + + scrollableContainer.setAttribute('data-custom-section-id', 'watch-again'); + scrollableContainer.setAttribute('data-custom-section-name', sectionName); + scrollableContainer.style.order = order; + + container.appendChild(scrollableContainer); + + return true; + + } catch (err) { + ERR('Error rendering Watch Again section:', err); + return false; + } + } + /** * Renders Popular TV Networks section * @param {HTMLElement} container - Container to append the section to @@ -1970,18 +3222,33 @@ let networks = await getPopularTVNetworks(); if (networks.length === 0) { - return false; + return false; // Auto-hide empty sections } // Remove any networks without Thumb images networks = networks.filter(network => network.ImageTags?.Thumb); - // Shuffle and take 16 random networks - const shuffled = [...networks].sort(() => Math.random() - 0.5); - const limitedNetworks = shuffled.slice(0, 16); + // Get config values + const itemLimit = popularTVNetworksConfig.itemLimit ?? defaultItemLimit; + const sortOrder = popularTVNetworksConfig.sortOrder ?? defaultSortOrder; + const sortOrderDirection = popularTVNetworksConfig.sortOrderDirection ?? 'Ascending'; + const cardFormat = popularTVNetworksConfig.cardFormat ?? defaultCardFormat; + const order = popularTVNetworksConfig.order ?? 61; + const sectionName = popularTVNetworksConfig.name || 'Popular TV Networks'; + + // Apply sort order + let sortedNetworks = networks; + if (sortOrder === 'Random') { + sortedNetworks = [...networks].sort(() => Math.random() - 0.5); + } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { + sortedNetworks = window.cardBuilder.sortItems(networks, sortOrder, sortOrderDirection); + } + + // Apply limit + const limitedNetworks = sortedNetworks.slice(0, itemLimit); if (limitedNetworks.length === 0) { - return false; + return false; // Auto-hide empty sections } // Check if cardBuilder is available @@ -1993,16 +3260,18 @@ // Render the scrollable container with the network/studio items const scrollableContainer = window.cardBuilder.renderCards( limitedNetworks, - 'Popular TV Networks', + sectionName, null, true, - 'backdrop' + cardFormat, + sortOrder, + sortOrderDirection ); // Add data attribute to track rendered sections scrollableContainer.setAttribute('data-custom-section-id', 'popular-tv-networks'); - scrollableContainer.setAttribute('data-custom-section-name', 'Popular TV Networks'); - scrollableContainer.style.order = POPULAR_TV_NETWORKS_ORDER; + scrollableContainer.setAttribute('data-custom-section-name', sectionName); + scrollableContainer.style.order = order; // Append to container container.appendChild(scrollableContainer); @@ -2049,17 +3318,39 @@ async function generateDiscoveryGroup() { try { LOG('Generating single discovery group...'); + + const spotlightChance = discoveryConfig.spotlightDiscoveryChance ?? 0.5; + + let renderGenresSpotlight = false; + let renderNetworksSpotlight = false; + let renderCollectionsSpotlight = false; + + if (Math.random() < spotlightChance) { + // Randomly pick one of the spotlight sections to render + const spotlightSections = ['spotlight-genre', 'spotlight-network', 'spotlight-collection']; + const selectedSpotlightSection = spotlightSections[Math.floor(Math.random() * spotlightSections.length)]; + renderGenresSpotlight = selectedSpotlightSection === 'spotlight-genre'; + renderNetworksSpotlight = selectedSpotlightSection === 'spotlight-network'; + renderCollectionsSpotlight = selectedSpotlightSection === 'spotlight-collection'; + } // Run all async operations in parallel for maximum speed const preloadPromises = [ // 1. [Genre] Movies (async () => { + const sectionConfig = getDiscoverySectionSettings('genreMovies'); + if (!sectionConfig || !sectionConfig.enabled) return null; const randomGenre = await getRandomGenre(); if (randomGenre) { - const genreData = await preloadGenreSection(randomGenre); + const genreData = await preloadGenreSection( + randomGenre, + sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit) + ); if (genreData) { return { type: 'genre', + sectionKey: 'genreMovies', + config: sectionConfig, data: randomGenre, items: genreData.items, viewMoreUrl: genreData.viewMoreUrl @@ -2071,15 +3362,19 @@ // 2. Directed by [Director] (use cached items directly) (async () => { + const sectionConfig = getDiscoverySectionSettings('directedByTopDirector'); + if (!sectionConfig || !sectionConfig.enabled) return null; if (moviesTopPeople && moviesTopPeople.directors.length > 0) { const randomDirector = getRandomItem(moviesTopPeople.directors, renderedDirectors); if (randomDirector && randomDirector.items && randomDirector.items.length > 0) { - // Use cached items directly - no need to fetch from API - const shuffledItems = shuffleArray(randomDirector.items).slice(0, 16); + const limit = sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); + const shuffledItems = shuffleArray(randomDirector.items).slice(0, limit); if (shuffledItems && shuffledItems.length > 0) { return { type: 'director', + sectionKey: 'directedByTopDirector', + config: sectionConfig, data: randomDirector, items: shuffledItems, viewMoreUrl: `#/details?id=${randomDirector.id}&serverId=${ApiClient.serverId()}` @@ -2092,15 +3387,19 @@ // 3. Written by [Writer] (use cached items directly) (async () => { + const sectionConfig = getDiscoverySectionSettings('writtenByTopWriter'); + if (!sectionConfig || !sectionConfig.enabled) return null; if (moviesTopPeople && moviesTopPeople.writers.length > 0) { const randomWriter = getRandomItem(moviesTopPeople.writers, renderedWriters); if (randomWriter && randomWriter.items && randomWriter.items.length > 0) { - // Use cached items directly - no need to fetch from API - const shuffledItems = shuffleArray(randomWriter.items).slice(0, 16); + const limit = sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); + const shuffledItems = shuffleArray(randomWriter.items).slice(0, limit); if (shuffledItems && shuffledItems.length > 0) { return { type: 'writer', + sectionKey: 'writtenByTopWriter', + config: sectionConfig, data: randomWriter, items: shuffledItems, viewMoreUrl: `#/details?id=${randomWriter.id}&serverId=${ApiClient.serverId()}` @@ -2113,15 +3412,19 @@ // 4. Starring [Actor] (use cached items directly) (async () => { + const sectionConfig = getDiscoverySectionSettings('starringTopActor'); + if (!sectionConfig || !sectionConfig.enabled) return null; if (moviesTopPeople && moviesTopPeople.actors.length > 0) { const randomActor = getRandomItem(moviesTopPeople.actors, renderedActors); if (randomActor && randomActor.items && randomActor.items.length > 0) { - // Use cached items directly - no need to fetch from API - const shuffledItems = shuffleArray(randomActor.items).slice(0, 16); + const limit = sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); + const shuffledItems = shuffleArray(randomActor.items).slice(0, limit); if (shuffledItems && shuffledItems.length > 0) { return { type: 'actor', + sectionKey: 'starringTopActor', + config: sectionConfig, data: randomActor, items: shuffledItems, viewMoreUrl: `#/details?id=${randomActor.id}&serverId=${ApiClient.serverId()}` @@ -2134,12 +3437,19 @@ // 5. Because you watched [Movie] (async () => { + const sectionConfig = getDiscoverySectionSettings('becauseYouWatched'); + if (!sectionConfig || !sectionConfig.enabled) return null; const randomWatchedMovie = getRandomWatchedMovie(); if (randomWatchedMovie) { - const watchedData = await preloadBecauseYouWatchedSection(randomWatchedMovie); + const watchedData = await preloadBecauseYouWatchedSection( + randomWatchedMovie, + sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit) + ); if (watchedData) { return { type: 'watched', + sectionKey: 'becauseYouWatched', + config: sectionConfig, data: randomWatchedMovie, items: watchedData.items }; @@ -2150,12 +3460,19 @@ // 6. Because you liked [Movie] (async () => { + const sectionConfig = getDiscoverySectionSettings('becauseYouLiked'); + if (!sectionConfig || !sectionConfig.enabled) return null; const randomLikedMovie = await getRandomFavoriteMovie(); if (randomLikedMovie) { - const likedData = await preloadBecauseYouLikedSection(randomLikedMovie); + const likedData = await preloadBecauseYouLikedSection( + randomLikedMovie, + sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit) + ); if (likedData) { return { type: 'liked', + sectionKey: 'becauseYouLiked', + config: sectionConfig, data: randomLikedMovie, items: likedData.items }; @@ -2166,6 +3483,8 @@ // 7. Shows from [Studio Name] (async () => { + const sectionConfig = getDiscoverySectionSettings('studioShows'); + if (!sectionConfig || !sectionConfig.enabled) return null; const networks = await getPopularTVNetworks(); if (networks && networks.length > 0) { // Filter out already rendered studios @@ -2176,19 +3495,23 @@ if (availableNetworks.length > 0) { const randomNetwork = availableNetworks[Math.floor(Math.random() * availableNetworks.length)]; const items = await fetchItemsByStudio(randomNetwork.Id); + const viewMoreUrl = `#/list.html?studioId=${randomNetwork.Id}&serverId=${ApiClient.serverId()}`; if (items && items.length > 0) { - // Track rendered studio renderedStudios.add(randomNetwork.Id); - // Shuffle and limit - const shuffled = [...items].sort(() => Math.random() - 0.5); - const limitedItems = shuffled.slice(0, 16); + const orderedItems = applyDiscoverySectionOrdering(items, sectionConfig); + if (orderedItems.length === 0) { + return null; + } return { type: 'studio', + sectionKey: 'studioShows', + config: sectionConfig, data: randomNetwork, - items: limitedItems + items: orderedItems, + viewMoreUrl: viewMoreUrl }; } } @@ -2198,12 +3521,19 @@ // 8. Because you recently watched [Movie Name] (async () => { + const sectionConfig = getDiscoverySectionSettings('becauseYouRecentlyWatched'); + if (!sectionConfig || !sectionConfig.enabled) return null; const randomRecentMovie = getRandomRecentlyWatchedMovie(); if (randomRecentMovie) { - const watchedData = await preloadBecauseYouWatchedSection(randomRecentMovie); + const watchedData = await preloadBecauseYouWatchedSection( + randomRecentMovie, + sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit) + ); if (watchedData) { return { type: 'watched-recent', + sectionKey: 'becauseYouRecentlyWatched', + config: sectionConfig, data: randomRecentMovie, items: watchedData.items }; @@ -2214,6 +3544,8 @@ // 9. Starring [Actor Name] since you recently watched [Movie Name] (async () => { + const sectionConfig = getDiscoverySectionSettings('starringActorRecentlyWatched'); + if (!sectionConfig || !sectionConfig.enabled) return null; const recentMovie = getRandomRecentlyWatchedMovie(renderedStarringWatchedMovies); if (!recentMovie) return null; @@ -2251,16 +3583,17 @@ .filter(item => item.Id !== recentMovie.Id); if (filteredItems.length > 0) { - // Limit to 16 - const limitedItems = filteredItems.slice(0, 16); - + const orderedItems = applyDiscoverySectionOrdering(filteredItems, sectionConfig); + if (orderedItems.length === 0) return null; return { type: 'actor-recent', + sectionKey: 'starringActorRecentlyWatched', + config: sectionConfig, data: { person: selectedActor, movie: recentMovie }, - items: limitedItems, + items: orderedItems, viewMoreUrl: `#/details?id=${selectedActor.Id}&serverId=${ApiClient.serverId()}` }; } @@ -2273,6 +3606,8 @@ // 10. Directed by [Director Name] since you recently watched [Movie Name] (async () => { + const sectionConfig = getDiscoverySectionSettings('directedByDirectorRecentlyWatched'); + if (!sectionConfig || !sectionConfig.enabled) return null; const recentMovie = getRandomRecentlyWatchedMovie(renderedDirectedWatchedMovies); if (!recentMovie) return null; @@ -2313,19 +3648,20 @@ .filter(item => item.Id !== recentMovie.Id); if (filteredItems.length > 0) { - // Track rendered director renderedDirectors.add(selectedDirector.Name); - // Limit to 16 - const limitedItems = filteredItems.slice(0, 16); + const orderedItems = applyDiscoverySectionOrdering(filteredItems, sectionConfig); + if (orderedItems.length === 0) return null; return { type: 'director-recent', + sectionKey: 'directedByDirectorRecentlyWatched', + config: sectionConfig, data: { person: selectedDirector, movie: recentMovie }, - items: limitedItems, + items: orderedItems, viewMoreUrl: `#/details?id=${selectedDirector.Id}&serverId=${ApiClient.serverId()}` }; } @@ -2338,6 +3674,8 @@ // 11. Written by [Writer Name] since you recently watched [Movie Name] (async () => { + const sectionConfig = getDiscoverySectionSettings('writtenByWriterRecentlyWatched'); + if (!sectionConfig || !sectionConfig.enabled) return null; const recentMovie = getRandomRecentlyWatchedMovie(renderedWrittenWatchedMovies); if (!recentMovie) return null; @@ -2378,19 +3716,20 @@ .filter(item => item.Id !== recentMovie.Id); if (filteredItems.length > 0) { - // Track rendered writer renderedWriters.add(selectedWriter.Name); - // Limit to 16 - const limitedItems = filteredItems.slice(0, 16); + const orderedItems = applyDiscoverySectionOrdering(filteredItems, sectionConfig); + if (orderedItems.length === 0) return null; return { type: 'writer-recent', + sectionKey: 'writtenByWriterRecentlyWatched', + config: sectionConfig, data: { person: selectedWriter, movie: recentMovie }, - items: limitedItems, + items: orderedItems, viewMoreUrl: `#/details?id=${selectedWriter.Id}&serverId=${ApiClient.serverId()}` }; } @@ -2399,6 +3738,206 @@ } return null; + })(), + + // 12. Spotlight: Top Rated from [Genre] + (async () => { + if (!renderGenresSpotlight) return null; + const sectionConfig = getDiscoverySectionSettings('spotlightGenre'); + if (!sectionConfig || !sectionConfig.enabled) return null; + + try { + // Get qualifying genres (cached) + const allQualifyingGenres = await getQualifyingGenres(); + if (allQualifyingGenres.length === 0) return null; + + // Filter out already rendered genres + const availableGenres = allQualifyingGenres.filter(genre => + !renderedGenres.has(genre.Id) + ); + + if (availableGenres.length === 0) return null; + + // Pick random genre + const selectedGenre = availableGenres[Math.floor(Math.random() * availableGenres.length)]; + + const itemLimit = sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); + const moviesResponse = await ApiClient.getItems(ApiClient.getCurrentUserId(), { + Genres: selectedGenre.Name, + IncludeItemTypes: 'Movie', + Recursive: true, + SortBy: 'CommunityRating', + SortOrder: 'Descending', + Limit: itemLimit, + Fields: 'BackdropImageTags,ImageTags,People,Genres,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount' + }); + + const movies = moviesResponse.Items || []; + if (movies.length === 0) return null; + + // Track rendered genre + renderedGenres.add(selectedGenre.Id); + + return { + type: 'spotlight-genre', + sectionKey: 'spotlightGenre', + config: sectionConfig, + data: selectedGenre, + items: movies, + viewMoreUrl: `${ApiClient.serverAddress()}/web/#/list.html?genreId=${selectedGenre.Id}&serverId=${ApiClient.serverId()}` + }; + } catch (err) { + ERR('Error generating spotlight genre section:', err); + return null; + } + })(), + + // 13. Spotlight: Top Rated from [TV Network] + (async () => { + if (!renderNetworksSpotlight) return null; + const sectionConfig = getDiscoverySectionSettings('spotlightNetwork'); + if (!sectionConfig || !sectionConfig.enabled) return null; + + try { + // Get popular TV networks + const networks = await getPopularTVNetworks(); + if (networks.length === 0) return null; + + // Filter out already rendered networks + const availableNetworks = networks.filter(network => + network.Id && !renderedNetworks.has(network.Id) + ); + + if (availableNetworks.length === 0) return null; + + // Pick random network + const selectedNetwork = availableNetworks[Math.floor(Math.random() * availableNetworks.length)]; + + const itemLimit = sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); + const showsResponse = await ApiClient.getItems(ApiClient.getCurrentUserId(), { + StudioIds: selectedNetwork.Id, + IncludeItemTypes: 'Series', + Recursive: true, + SortBy: 'CommunityRating', + SortOrder: 'Descending', + Limit: itemLimit, + Fields: 'BackdropImageTags,ImageTags,People,Genres,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount' + }); + + const shows = showsResponse.Items || []; + if (shows.length === 0) return null; + + // Track rendered network + renderedNetworks.add(selectedNetwork.Id); + + return { + type: 'spotlight-network', + sectionKey: 'spotlightNetwork', + config: sectionConfig, + data: selectedNetwork, + items: shows, + viewMoreUrl: `${ApiClient.serverAddress()}/web/#/list.html?studioId=${selectedNetwork.Id}&serverId=${ApiClient.serverId()}` + }; + } catch (err) { + ERR('Error generating spotlight network section:', err); + return null; + } + })(), + + // 14. Collection section + (async () => { + if (!renderCollectionsSpotlight) return null; + const sectionConfig = getDiscoverySectionSettings('collections'); + if (!sectionConfig || !sectionConfig.enabled) return null; + + try { + const minItems = sectionConfig.minimumItems ?? 10; + const userId = ApiClient.getCurrentUserId(); + + const itemLimit = sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); + const sortOrder = sectionConfig.sortOrder ?? discoveryConfig.defaultSortOrder ?? defaultSortOrder; + const sortOrderDirection = sectionConfig.sortOrderDirection ?? 'Ascending'; + + // Fetch all collections (BoxSet items) with total record count + const collectionsData = await getCollectionsData(); + if (!collectionsData) return null; + + // Filter by minimum item count using TotalRecordCount and exclude already rendered + const qualifyingCollections = collectionsData.Items.filter(collection => { + // Skip if already rendered + if (renderedCollections.has(collection.Id)) return false; + + // Check if collection has enough items using TotalRecordCount + // TotalRecordCount is available when EnableTotalRecordCount=true + const itemCount = collection.TotalRecordCount ?? collection.RecursiveChildCount ?? collection.ChildCount ?? 0; + return itemCount >= minItems; + }); + + if (qualifyingCollections.length === 0) return null; + + // Randomly select one collection + const selectedCollection = qualifyingCollections[Math.floor(Math.random() * qualifyingCollections.length)]; + + // Now fetch the items for the selected collection + const itemsData = await fetchCollectionData(selectedCollection.Id); + const items = itemsData.Items || []; + + if (items.length === 0) return null; + + // Apply sort order + let sortedItems = items; + if (sortOrder === 'Random') { + sortedItems = [...items].sort(() => Math.random() - 0.5); + } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { + sortedItems = window.cardBuilder.sortItems(items, sortOrder, sortOrderDirection); + } + + // Apply limit + const limited = sortedItems.slice(0, itemLimit); + if (limited.length === 0) return null; + + // Track rendered collection + renderedCollections.add(selectedCollection.Id); + + return { + type: 'collection', + sectionKey: 'collections', + config: sectionConfig, + data: selectedCollection, + items: limited, + viewMoreUrl: `/web/#/collection.html?id=${selectedCollection.Id}`, + sortOrder: sortOrder, + sortOrderDirection: sortOrderDirection, + cardFormat: sectionConfig.cardFormat ?? discoveryConfig.defaultCardFormat ?? defaultCardFormat + }; + } catch (err) { + ERR('Error generating collection section:', err); + return null; + } + })(), + + // 15. Custom discovery section + (async () => { + const eligibleCustomSections = (customHomeSections || []).filter(section => section.enabled !== false && section.discoveryEnabled === true); + if (eligibleCustomSections.length === 0) return null; + + let availableSections = eligibleCustomSections.filter(section => section.id && !renderedCustomDiscoverySections.has(section.id)); + if (availableSections.length === 0) { + LOG('No eligible custom sections for discovery group remaining'); + return null; + } + + const selectedSection = availableSections[Math.floor(Math.random() * availableSections.length)]; + if (!selectedSection) return null; + + if (selectedSection.id) { + renderedCustomDiscoverySections.add(selectedSection.id); + } + + return { + type: 'custom-discovery', + data: { ...selectedSection } + }; })() ]; @@ -2406,7 +3945,17 @@ const preloadResults = await Promise.all(preloadPromises); // Filter out null results and collect valid sections - const sections = preloadResults.filter(section => section !== null); + let sections = preloadResults.filter(section => section !== null); + + if (discoveryConfig.randomizeOrder) { + sections = shuffleArray(sections); + } else { + sections.sort((a, b) => { + const orderA = typeof a?.order === 'number' ? a.order : DISCOVERY_ORDER; + const orderB = typeof b?.order === 'number' ? b.order : DISCOVERY_ORDER; + return orderA - orderB; + }); + } LOG(`Generated discovery group with ${sections.length} sections`); return sections; @@ -2480,7 +4029,7 @@ * Manages the UI state (handlers and button visibility) based on buffer content */ async function manageDiscoveryUIState() { - const homeSectionsContainer = document.querySelector('.homeSectionsContainer'); + const homeSectionsContainer = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); if (discoveryBuffer.length > 0) { // Buffer has content - set up handlers @@ -2614,7 +4163,7 @@ const groupToRender = discoveryBuffer.shift(); if (groupToRender && groupToRender.length > 0) { // Remove loading indicator from DOM before rendering sections to prevent scroll jump - const loadingIndicator = document.getElementById('discovery-loading-indicator'); + const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); let loadingIndicatorClone = null; if (loadingIndicator && loadingIndicator.parentNode) { loadingIndicatorClone = loadingIndicator.cloneNode(true); @@ -2624,6 +4173,7 @@ let renderedCount = 0; + // Render sections from the group (including spotlight and collection sections via generateDiscoveryGroup) for (const sectionData of groupToRender) { try { const success = await renderDiscoverySection(sectionData, container); @@ -2689,7 +4239,7 @@ try { LOG('Discover More button clicked'); - const homeSectionsContainer = document.querySelector('.homeSectionsContainer'); + const homeSectionsContainer = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); if (homeSectionsContainer) { await renderNextDiscoveryGroup(homeSectionsContainer, loadMoreButton); } else { @@ -2767,7 +4317,7 @@ // Remove loading indicator from DOM before revealing sections to prevent scroll jump // We'll re-add it at the end after sections are revealed - const loadingIndicator = document.getElementById('discovery-loading-indicator'); + const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); let loadingIndicatorClone = null; if (loadingIndicator && loadingIndicator.parentNode) { loadingIndicatorClone = loadingIndicator.cloneNode(true); @@ -2821,7 +4371,7 @@ * @param {Object} genre - Genre object * @returns {Promise} - Genre data with items and viewMoreUrl */ - async function preloadGenreSection(genre) { + async function preloadGenreSection(genre, limit = 16) { try { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); @@ -2829,7 +4379,7 @@ const userId = apiClient.getCurrentUserId(); const serverId = apiClient.serverId(); - const url = `${serverUrl}/Items?userId=${userId}&Genres=${encodeURIComponent(genre.Name)}&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=16`; + const url = `${serverUrl}/Items?userId=${userId}&Genres=${encodeURIComponent(genre.Name)}&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=${limit}`; const response = await fetch(url, { headers: { @@ -2865,13 +4415,13 @@ * @param {Object} movie - Movie object * @returns {Promise} - Similar content data */ - async function preloadBecauseYouWatchedSection(movie) { + async function preloadBecauseYouWatchedSection(movie, limit = 16) { try { const items = await fetchSimilarContent(movie.Id, 'Movie'); if (items.length === 0) return null; return { - items: items.slice(0, 16), + items: items.slice(0, limit), hasFullItems: true }; @@ -2886,13 +4436,13 @@ * @param {Object} movie - Movie object * @returns {Promise} - Similar content data */ - async function preloadBecauseYouLikedSection(movie) { + async function preloadBecauseYouLikedSection(movie, limit = 16) { try { const items = await fetchSimilarContent(movie.Id, 'Movie'); if (items.length === 0) return null; return { - items: items.slice(0, 16), + items: items.slice(0, limit), hasFullItems: true }; @@ -2915,70 +4465,256 @@ let viewMoreUrl = null; let sectionId = ''; let order = DISCOVERY_ORDER + renderedSections.size; + let sectionConfig = null; + let sectionKey = null; + + if (typeof sectionData === 'object' && sectionData.type === 'custom-discovery') { + const baseSection = sectionData.data || {}; + const clonedSection = JSON.parse(JSON.stringify(baseSection)); + const uniqueSuffix = `${Date.now()}-${Math.floor(Math.random() * 1000)}`; + clonedSection.id = `${clonedSection.id || clonedSection.name || 'custom'}-discovery-${uniqueSuffix}`; + clonedSection.order = DISCOVERY_ORDER + renderedSections.size; + const success = await renderCustomSection(clonedSection, container); + if (success) { + renderedSections.add(clonedSection.id); + } + return success; + } // Handle preloaded section data if (typeof sectionData === 'object' && sectionData.type) { if (!sectionData.items || sectionData.items.length === 0) return false; + sectionKey = sectionData.sectionKey || getDiscoverySectionKeyFromType(sectionData.type); + sectionConfig = sectionData.config || getDiscoverySectionSettings(sectionKey); + if (!sectionConfig || sectionConfig.enabled === false) return false; items = sectionData.items; viewMoreUrl = sectionData.viewMoreUrl || null; + items = applyDiscoverySectionOrdering(items, sectionConfig); + if (!items || items.length === 0) return false; + + // Helper function to format section names with placeholders + function formatSectionName(template, replacements) { + let name = template; + for (const [key, value] of Object.entries(replacements)) { + name = name.replace(new RegExp(`\\[${key}\\]`, 'gi'), value || ''); + } + return name; + } switch (sectionData.type) { case 'genre': - sectionName = `${sectionData.data.Name} Movies`; + const genreTemplate = sectionConfig?.name || '[Genre] Movies'; + sectionName = formatSectionName(genreTemplate, { Genre: sectionData.data.Name }); sectionId = `genre-${sectionData.data.Name.toLowerCase()}`; break; case 'director': - sectionName = `Directed by ${sectionData.data.name}`; + const directorTemplate = sectionConfig?.name || 'Directed by [Director]'; + sectionName = formatSectionName(directorTemplate, { Director: sectionData.data.name }); sectionId = `director-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`; break; case 'writer': - sectionName = `Written by ${sectionData.data.name}`; + const writerTemplate = sectionConfig?.name || 'Written by [Writer]'; + sectionName = formatSectionName(writerTemplate, { Writer: sectionData.data.name }); sectionId = `writer-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`; break; case 'actor': - sectionName = `Starring ${sectionData.data.name}`; + const actorTemplate = sectionConfig?.name || 'Starring [Actor]'; + sectionName = formatSectionName(actorTemplate, { Actor: sectionData.data.name }); sectionId = `actor-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`; break; case 'watched': - sectionName = `Because you watched ${sectionData.data.Name} ${sectionData.data.ProductionYear ? `(${sectionData.data.ProductionYear})` : ''}`; + const watchedTemplate = sectionConfig?.name || 'Because you watched [Movie]'; + const watchedMovieName = `${sectionData.data.Name}${sectionData.data.ProductionYear ? ` (${sectionData.data.ProductionYear})` : ''}`; + sectionName = formatSectionName(watchedTemplate, { Movie: watchedMovieName }); sectionId = `watched-${sectionData.data.Id}`; break; case 'liked': - sectionName = `Because you liked ${sectionData.data.Name} ${sectionData.data.ProductionYear ? `(${sectionData.data.ProductionYear})` : ''}`; + const likedTemplate = sectionConfig?.name || 'Because you liked [Movie]'; + const likedMovieName = `${sectionData.data.Name}${sectionData.data.ProductionYear ? ` (${sectionData.data.ProductionYear})` : ''}`; + sectionName = formatSectionName(likedTemplate, { Movie: likedMovieName }); sectionId = `liked-${sectionData.data.Id}`; break; case 'studio': - sectionName = `Shows from ${sectionData.data.Name}`; + const studioTemplate = sectionConfig?.name || 'Shows from [Studio]'; + sectionName = formatSectionName(studioTemplate, { Studio: sectionData.data.Name }); sectionId = `studio-${sectionData.data.Id}`; break; case 'watched-recent': - sectionName = `Because you recently watched ${sectionData.data.Name} ${sectionData.data.ProductionYear ? `(${sectionData.data.ProductionYear})` : ''}`; + const watchedRecentTemplate = sectionConfig?.name || 'Because you recently watched [Movie]'; + const watchedRecentMovieName = `${sectionData.data.Name}${sectionData.data.ProductionYear ? ` (${sectionData.data.ProductionYear})` : ''}`; + sectionName = formatSectionName(watchedRecentTemplate, { Movie: watchedRecentMovieName }); sectionId = `watched-recent-${sectionData.data.Id}`; break; case 'actor-recent': - sectionName = `Starring ${sectionData.data.person.Name} since you recently watched ${sectionData.data.movie.Name} ${sectionData.data.movie.ProductionYear ? `(${sectionData.data.movie.ProductionYear})` : ''}`; + const actorRecentTemplate = sectionConfig?.name || 'Starring [Actor] because you recently watched [Movie]'; + const actorRecentMovieName = `${sectionData.data.movie.Name}${sectionData.data.movie.ProductionYear ? ` (${sectionData.data.movie.ProductionYear})` : ''}`; + sectionName = formatSectionName(actorRecentTemplate, { + Actor: sectionData.data.person.Name, + Movie: actorRecentMovieName + }); sectionId = `actor-recent-${sectionData.data.person.Id}-${sectionData.data.movie.Id}`; break; case 'director-recent': - sectionName = `Directed by ${sectionData.data.person.Name} since you recently watched ${sectionData.data.movie.Name} ${sectionData.data.movie.ProductionYear ? `(${sectionData.data.movie.ProductionYear})` : ''}`; + const directorRecentTemplate = sectionConfig?.name || 'Directed by [Director] because you recently watched [Movie]'; + const directorRecentMovieName = `${sectionData.data.movie.Name}${sectionData.data.movie.ProductionYear ? ` (${sectionData.data.movie.ProductionYear})` : ''}`; + sectionName = formatSectionName(directorRecentTemplate, { + Director: sectionData.data.person.Name, + Movie: directorRecentMovieName + }); sectionId = `director-recent-${sectionData.data.person.Id}-${sectionData.data.movie.Id}`; break; case 'writer-recent': - sectionName = `Written by ${sectionData.data.person.Name} since you recently watched ${sectionData.data.movie.Name} ${sectionData.data.movie.ProductionYear ? `(${sectionData.data.movie.ProductionYear})` : ''}`; + const writerRecentTemplate = sectionConfig?.name || 'Written by [Writer] because you recently watched [Movie]'; + const writerRecentMovieName = `${sectionData.data.movie.Name}${sectionData.data.movie.ProductionYear ? ` (${sectionData.data.movie.ProductionYear})` : ''}`; + sectionName = formatSectionName(writerRecentTemplate, { + Writer: sectionData.data.person.Name, + Movie: writerRecentMovieName + }); sectionId = `writer-recent-${sectionData.data.person.Id}-${sectionData.data.movie.Id}`; break; + case 'spotlight-genre': + // Handle spotlight genre section - render as spotlight + if (!window.cardBuilder || !window.cardBuilder.renderSpotlightSection) { + WARN("cardBuilder.renderSpotlightSection not available"); + return false; + } + const spotlightGenreTemplate = sectionConfig?.name || 'Spotlight'; + sectionName = formatSectionName(spotlightGenreTemplate, { Genre: sectionData.data.Name }); + sectionId = `spotlight-genre-${sectionData.data.Id}`; + const spotlightGenreContainer = window.cardBuilder.renderSpotlightSection( + items, + sectionName, + { + autoPlay: true, + interval: 5000, + showDots: true, + showNavButtons: true, + showClearArt: true + }, + ); + spotlightGenreContainer.setAttribute('data-custom-section-id', sectionId); + spotlightGenreContainer.setAttribute('data-custom-section-name', sectionName); + spotlightGenreContainer.style.order = order; + container.appendChild(spotlightGenreContainer); + renderedSections.add(sectionId); + return true; + case 'spotlight-network': + // Handle spotlight network section - render as spotlight + if (!window.cardBuilder || !window.cardBuilder.renderSpotlightSection) { + WARN("cardBuilder.renderSpotlightSection not available"); + return false; + } + const spotlightNetworkTemplate = sectionConfig?.name || 'Spotlight'; + sectionName = formatSectionName(spotlightNetworkTemplate, { Studio: sectionData.data.Name }); + sectionId = `spotlight-network-${sectionData.data.Id}`; + const spotlightNetworkContainer = window.cardBuilder.renderSpotlightSection( + items, + sectionName, + { + autoPlay: true, + interval: 5000, + showDots: true, + showNavButtons: true, + showClearArt: true + } + ); + spotlightNetworkContainer.setAttribute('data-custom-section-id', sectionId); + spotlightNetworkContainer.setAttribute('data-custom-section-name', sectionName); + spotlightNetworkContainer.style.order = order; + container.appendChild(spotlightNetworkContainer); + renderedSections.add(sectionId); + return true; + case 'collection': + // Handle collection section - use provided config + const collectionTemplate = sectionConfig?.name || '[Collection Name]'; + sectionName = formatSectionName(collectionTemplate, { 'Collection Name': sectionData.data.Name || 'Collection' }); + sectionId = `collection-${sectionData.data.Id}`; + const collectionItemLimit = sectionConfig?.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); + const collectionSortOrder = sectionConfig?.sortOrder ?? discoveryConfig.defaultSortOrder ?? defaultSortOrder; + const collectionSortOrderDirection = sectionConfig?.sortOrderDirection ?? 'Ascending'; + const collectionCardFormat = sectionConfig?.cardFormat ?? discoveryConfig.defaultCardFormat ?? defaultCardFormat; + + // Apply sort order if needed + let collectionItems = items; + if (collectionSortOrder === 'Random') { + collectionItems = [...items].sort(() => Math.random() - 0.5); + } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { + collectionItems = window.cardBuilder.sortItems(items, collectionSortOrder, collectionSortOrderDirection); + } + + // Apply limit + const limitedCollectionItems = collectionItems.slice(0, collectionItemLimit); + if (limitedCollectionItems.length === 0) { + return false; + } + + let collectionContainer = null; + + if (window.cardBuilder && window.cardBuilder.renderSpotlightSection) { + collectionContainer = window.cardBuilder.renderSpotlightSection( + limitedCollectionItems, + sectionName, + { + autoPlay: true, + interval: 5000, + showDots: true, + showNavButtons: true, + showClearArt: true + } + ); + } + + if (!collectionContainer && window.cardBuilder && window.cardBuilder.renderCards) { + collectionContainer = window.cardBuilder.renderCards( + limitedCollectionItems, + sectionName, + sectionData.viewMoreUrl || `/web/#/collection.html?id=${sectionData.data.Id}`, + true, + collectionCardFormat, + collectionSortOrder, + collectionSortOrderDirection + ); + } + + collectionContainer.setAttribute('data-custom-section-id', sectionId); + collectionContainer.setAttribute('data-custom-section-name', sectionName); + collectionContainer.style.order = order; + container.appendChild(collectionContainer); + renderedSections.add(sectionId); + return true; default: return false; } + } else if (typeof sectionData === 'string') { + sectionKey = getDiscoverySectionKeyFromType(sectionData); + sectionConfig = getDiscoverySectionSettings(sectionKey); + if (!sectionConfig || sectionConfig.enabled === false) { + return false; + } + if (items && items.length > 0) { + items = applyDiscoverySectionOrdering(items, sectionConfig); + } + if (!sectionName) { + sectionName = sectionConfig.name || sectionName; + } } if (items.length === 0) { - return false; + return false; // Auto-hide empty sections } - // Limit to 16 items - const limitedItems = items.slice(0, 16); + const activeSectionConfig = sectionConfig || (sectionKey ? getDiscoverySectionSettings(sectionKey) : null); + const itemLimit = activeSectionConfig?.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); + const sortOrder = activeSectionConfig?.sortOrder ?? discoveryConfig.defaultSortOrder ?? defaultSortOrder; + const sortOrderDirection = activeSectionConfig?.sortOrderDirection ?? 'Ascending'; + const cardFormat = activeSectionConfig?.cardFormat ?? discoveryConfig.defaultCardFormat ?? defaultCardFormat; + + const limitedItems = activeSectionConfig ? items : items.slice(0, itemLimit); + + if (limitedItems.length === 0) { + return false; // Auto-hide empty sections + } // Check if cardBuilder is available if (typeof window.cardBuilder === 'undefined') { @@ -2996,14 +4732,30 @@ WARN("cardBuilder.renderCards not available, skipping discovery section"); return false; } - scrollableContainer = window.cardBuilder.renderCards(limitedItems, sectionName, viewMoreUrl, true); + scrollableContainer = window.cardBuilder.renderCards( + limitedItems, + sectionName, + viewMoreUrl, + true, + cardFormat, + sortOrder, + sortOrderDirection + ); } else { // Use renderCardsFromIds for ID arrays if (!window.cardBuilder.renderCardsFromIds) { WARN("cardBuilder.renderCardsFromIds not available, skipping discovery section"); return false; } - scrollableContainer = await window.cardBuilder.renderCardsFromIds(limitedItems, sectionName, viewMoreUrl, true); + scrollableContainer = await window.cardBuilder.renderCardsFromIds( + limitedItems, + sectionName, + viewMoreUrl, + true, + cardFormat, + sortOrder, + sortOrderDirection + ); } // Add data attributes to track rendered sections @@ -3036,6 +4788,8 @@ const homePage = document.querySelector('.homePage:not(.hide)'); if (!homePage || homePage.dataset.discoveryReady === 'true') { + LOG('Home page not found or already discovery ready, skipping...'); + LOG('homePage:', homePage); return; } @@ -3054,7 +4808,7 @@ */ function removeScrollBasedLoading() { // Fade out message for "loading more" - const loadingIndicator = document.querySelector('#discovery-loading-indicator'); + const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); if (discoveryScrollHandler) { window.removeEventListener('scroll', discoveryScrollHandler); @@ -3284,6 +5038,68 @@ * Gets a random genre that hasn't been rendered yet * @returns {Promise} - Random genre or null */ + /** + * Gets qualifying genres (genres with enough movies) with caching + * @returns {Promise} Array of qualifying genre objects + */ + async function getQualifyingGenres() { + // Return cached if available + if (cachedQualifyingGenres !== null) { + return cachedQualifyingGenres; + } + + // Prevent parallel caching + if (isCachingQualifyingGenres) { + // Wait for the other call to complete + while (isCachingQualifyingGenres) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + return cachedQualifyingGenres || []; + } + + isCachingQualifyingGenres = true; + + try { + LOG('Fetching and caching qualifying genres...'); + + // Get all genres + const serverAddress = ApiClient.serverAddress(); + const token = ApiClient.accessToken(); + const genresResponse = await fetch(`${serverAddress}/Genres?IncludeItemTypes=Movie`, { + headers: { "Authorization": `MediaBrowser Token="${token}"` } + }); + + if (!genresResponse.ok) { + ERR(`Failed to fetch genres: ${genresResponse.statusText}`); + isCachingQualifyingGenres = false; + return []; + } + + const genresData = await genresResponse.json(); + const genres = genresData.Items || []; + const qualifyingGenres = []; + + // Check each genre to see if it has enough movies + for (const genre of genres) { + if (genre.MovieCount < minGenreMovieCount) continue; + qualifyingGenres.push(genre); + } + + // Cache the results + cachedQualifyingGenres = qualifyingGenres; + LOG(`Cached ${qualifyingGenres.length} qualifying genres`); + console.log(qualifyingGenres); + + return qualifyingGenres; + } catch (err) { + ERR('Error fetching qualifying genres:', err); + isCachingQualifyingGenres = false; + return []; + } finally { + isCachingQualifyingGenres = false; + } + } + async function getRandomGenre() { const cache = new window.LocalStorageCache(); let movieGenres = cache.get('movieGenres'); @@ -3528,32 +5344,190 @@ } /** - * Renders all seasonal sections based on current date + * Checks if current date is within a seasonal period + * @param {string} startDate - Start date in MM-DD format + * @param {string} endDate - End date in MM-DD format + * @returns {boolean} - True if within period + */ + function isInSeasonalPeriod(startDate, endDate) { + const now = new Date(); + const currentMonth = now.getMonth() + 1; // 1-12 + const currentDay = now.getDate(); + + const [startMonth, startDay] = startDate.split('-').map(Number); + const [endMonth, endDay] = endDate.split('-').map(Number); + + const currentDateNum = currentMonth * 100 + currentDay; + const startDateNum = startMonth * 100 + startDay; + const endDateNum = endMonth * 100 + endDay; + + if (startDateNum <= endDateNum) { + // Normal range (e.g., 10-01 to 10-31) + return currentDateNum >= startDateNum && currentDateNum <= endDateNum; + } else { + // Wraps around year (e.g., 12-01 to 01-31) + return currentDateNum >= startDateNum || currentDateNum <= endDateNum; + } + } + + /** + * Renders a seasonal section based on config + * @param {Object} seasonConfig - Season configuration + * @param {HTMLElement} container - Container to append section to + * @returns {Promise} - Success status + */ + /** + * Renders a single section (used by both seasonal and custom sections) + * @param {Object} sectionConfig - Section configuration + * @param {HTMLElement} container - Container to append the section to + * @param {string} sectionIdPrefix - Prefix for section ID + * @returns {Promise} - Success status + */ + async function renderSection(sectionConfig, container, sectionIdPrefix) { + try { + if (!sectionConfig.enabled) { + return false; + } + + const sectionId = `${sectionIdPrefix}-${sectionConfig.id}`; + if (container.querySelector(`[data-custom-section-id="${sectionId}"]`)) { + return false; + } + + // Get config values + const itemLimit = sectionConfig.itemLimit ?? seasonalConfig?.defaultItemLimit ?? defaultItemLimit; + const sortOrder = sectionConfig.sortOrder ?? seasonalConfig?.defaultSortOrder ?? defaultSortOrder; + const sortOrderDirection = sectionConfig.sortOrderDirection ?? 'Ascending'; + const cardFormat = sectionConfig.cardFormat ?? seasonalConfig?.defaultCardFormat ?? defaultCardFormat; + const order = sectionConfig.order ?? 100; + + const allItems = await loadItemsForSectionConfig(sectionConfig, itemLimit); + if (!allItems || allItems.length === 0) { + return false; // Auto-hide empty sections + } + + // Apply sort order + let sortedItems = allItems; + if (sortOrder === 'Random') { + sortedItems = [...allItems].sort(() => Math.random() - 0.5); + } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { + sortedItems = window.cardBuilder.sortItems(allItems, sortOrder, sortOrderDirection); + } + + // Apply limit + const limited = sortedItems.slice(0, itemLimit); + + if (limited.length === 0) { + return false; // Auto-hide empty sections + } + + const type = sectionConfig.type; + const source = sectionConfig.source; + let viewMoreUrl = null; + switch (type) { + case 'Genre': + viewMoreUrl = `#/list.html?genreId=${source}&serverId=${ApiClient.serverId()}`; + break; + case 'Studio': + viewMoreUrl = `#/list.html?studioId=${source}&serverId=${ApiClient.serverId()}`; + break; + case 'Playlist': + case 'BoxSet': + viewMoreUrl = `#/details?id=${source}&serverId=${ApiClient.serverId()}`; + break; + case 'Parent': + viewMoreUrl = `#/list.html?parentId=${source}&serverId=${ApiClient.serverId()}`; + break; + default: + viewMoreUrl = null; + break; + } + + if (!window.cardBuilder || !window.cardBuilder.renderCards) { + WARN("cardBuilder.renderCards not available"); + return false; + } + + const section = window.cardBuilder.renderCards( + limited, + sectionConfig.name, + viewMoreUrl, + true, + cardFormat, + sortOrder, + sortOrderDirection + ); + + section.setAttribute('data-custom-section-id', sectionId); + section.setAttribute('data-custom-section-name', sectionConfig.name); + section.style.order = order; + + container.appendChild(section); + + return true; + } catch (err) { + ERR(`Error rendering section ${sectionConfig.name}:`, err); + return false; + } + } + + /** + * Renders a seasonal section (which contains nested sections) + * @param {Object} seasonConfig - Seasonal section configuration + * @param {HTMLElement} container - Container to append sections to + * @returns {Promise} - Success status + */ + async function renderSeasonalSection(seasonConfig, container) { + try { + if (!seasonConfig.enabled) { + return false; + } + + // Check if in seasonal period + if (!isInSeasonalPeriod(seasonConfig.startDate, seasonConfig.endDate)) { + return false; + } + + // Render each nested section + const sections = seasonConfig.sections || []; + let anyRendered = false; + + for (const section of sections) { + if (section.enabled) { + const rendered = await renderSection(section, container, `seasonal-${seasonConfig.id}`); + if (rendered) { + anyRendered = true; + } + } + } + + return anyRendered; + } catch (err) { + ERR(`Error rendering seasonal section ${seasonConfig.name}:`, err); + return false; + } + } + + /** + * Renders all seasonal sections based on current date and config * @param {HTMLElement} container - Container to append sections to */ async function renderAllSeasonalSections(container) { + const seasons = seasonalConfig.seasons || []; const sectionsToRender = []; - // Add Halloween sections if in Halloween period + // Render each configured season if it's in its period + for (const season of seasons) { + if (season.enabled && isInSeasonalPeriod(season.startDate, season.endDate)) { + sectionsToRender.push(renderSeasonalSection(season, container)); + } + } + + // Also render legacy Halloween sections for backward compatibility if (isHalloweenPeriod()) { sectionsToRender.push(renderAllHalloweenSections(container)); } - // Add Christmas sections if in Christmas period - if (isChristmasPeriod()) { - sectionsToRender.push(renderAllChristmasSections(container)); - } - - // Add New Years sections if in New Years period - if (isNewYearsPeriod()) { - sectionsToRender.push(renderAllNewYearsSections(container)); - } - - // Add Valentine's Day sections if in Valentine's Day period - if (isValentinesPeriod()) { - sectionsToRender.push(renderAllValentinesSections(container)); - } - if (sectionsToRender.length === 0) { LOG('No seasonal periods active, skipping seasonal sections'); return; @@ -3561,7 +5535,7 @@ const results = await Promise.all(sectionsToRender); const successCount = results.filter(Boolean).length; - LOG(`Rendered ${successCount}/${sectionsToRender.length} seasonal section groups`); + LOG(`Rendered ${successCount}/${sectionsToRender.length} seasonal sections`); } /************ Home Screen Observer ************/ @@ -3569,12 +5543,7 @@ /** * Checks if custom sections are already rendered and renders them if not */ - async function checkAndRenderCustomSections() { - // Check if already processing to prevent parallel execution - if (isProcessing) { - return; - } - + async function checkAndRenderCustomSections() { // Try to find the home sections container with retry logic // Retry every 100ms for up to 3 seconds let homeSectionsContainer = null; @@ -3594,6 +5563,12 @@ LOG('Home sections container not found after 3 seconds'); return; } + + // Check if already processing to prevent parallel execution + if (isProcessing && homeSectionsContainer.dataset.customSectionsRendered === 'true') { + LOG('Already processing, skipping...'); + return; + } // Check if sections are already rendered if (homeSectionsContainer.dataset.customSectionsRendered === 'true') { @@ -3613,11 +5588,24 @@ let initPromises = []; // Add people cache and preloading if discovery is enabled + LOG('Checking Discovery section'); if (enableDiscovery) { initPromises.push(initializePeopleCache()); initPromises.push(preloadNextSections()); } + // Add Upcoming section (always rendered when enabled) + LOG('Checking Upcoming section'); + if (enableUpcoming) { + LOG('Rendering Upcoming section'); + initPromises.push(renderUpcomingSection(homeSectionsContainer)); + } + + // Add IMDb Top 250 section (always rendered when enabled) + if (enableImdbTop250) { + initPromises.push(renderImdbTop250Section(homeSectionsContainer)); + } + // Add custom sections if enabled if (customHomeSections && customHomeSections.length > 0) { initPromises.push(renderAllCustomSections(homeSectionsContainer)); @@ -3637,8 +5625,16 @@ if (enableWatchlist) { initPromises.push(renderWatchlistSection(homeSectionsContainer)); } + + // Add Watch Again section if enabled + if (enableWatchAgain) { + initPromises.push(renderWatchAgainSection(homeSectionsContainer)); + } - initPromises.push(renderPopularTVNetworksSection(homeSectionsContainer)); + // Add popular TV networks section if enabled + if (popularTVNetworksConfig.enabled) { + initPromises.push(renderPopularTVNetworksSection(homeSectionsContainer)); + } // Wait for all parallel operations to complete await Promise.all(initPromises); diff --git a/scripts/search.js b/scripts/search.js index ebfb02d..f4b354d 100644 --- a/scripts/search.js +++ b/scripts/search.js @@ -126,7 +126,6 @@ .smart-search-input { width:100%; padding:8px; border-radius:4px; box-sizing:border-box; } .smart-search-buttons { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; justify-content: center; } .smart-search-btn { padding:8px 12px; border:none; border-radius:4px; cursor:pointer; background:#444; color:#fff; } - .smart-search-btn.active { background:#673AB7; } .smart-search-results { margin-top:12px; } .smart-search-stats { color:#999; font-size:12px; margin-top:6px; display: none; } #persistent-toggle-btn { display:none; position:relative; padding:8px 12px; background:#00a4dc; color:#fff; border:none; border-radius:4px; cursor:pointer; } @@ -452,7 +451,7 @@ btnAll.textContent = 'All'; const btnCore = document.createElement('button'); btnCore.id = 'smart-search-core'; - btnCore.className = 'smart-search-btn emby-button active'; + btnCore.className = 'smart-search-btn emby-button raised button-submit active'; btnCore.textContent = 'Movies/TV'; const btnMusic = document.createElement('button'); btnMusic.id = 'smart-search-music'; @@ -513,26 +512,26 @@ // Function to update button states function updateButtonStates(activeType) { // Remove active class from all buttons - smartAllBtn.classList.remove('active'); - smartCoreBtn.classList.remove('active'); - smartMusicBtn.classList.remove('active'); - smartBooksBtn.classList.remove('active'); + smartAllBtn.classList.remove('active','button-submit','raised'); + smartCoreBtn.classList.remove('active','button-submit','raised'); + smartMusicBtn.classList.remove('active','button-submit','raised'); + smartBooksBtn.classList.remove('active','button-submit','raised'); if (smartRequestBtn) { - smartRequestBtn.classList.remove('active'); + smartRequestBtn.classList.remove('active','button-submit','raised'); } // Add active class to the selected button if (activeType === 'all') { - smartAllBtn.classList.add('active'); + smartAllBtn.classList.add('active','button-submit','raised'); } else if (activeType === 'videos') { - smartCoreBtn.classList.add('active'); + smartCoreBtn.classList.add('active','button-submit','raised'); } else if (activeType === 'music') { - smartMusicBtn.classList.add('active'); + smartMusicBtn.classList.add('active','button-submit','raised'); } else if (activeType === 'books') { - smartBooksBtn.classList.add('active'); + smartBooksBtn.classList.add('active','button-submit','raised'); } else if (activeType === 'request' && smartRequestBtn) { - smartRequestBtn.classList.add('active'); + smartRequestBtn.classList.add('active','button-submit','raised'); } } diff --git a/scripts/utils.js b/scripts/utils.js index b4d8760..609069f 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -324,6 +324,195 @@ }); } + /** + * Waits for user to be logged in + * @param {number} maxWaitMs - Maximum time to wait in milliseconds (default: 10000 = 10 seconds) + * @param {number} checkInterval - Interval between checks in milliseconds (default: 500) + * @returns {Promise} - True if logged in, false if timeout + */ + async function waitForLogin(maxWaitMs = 10000, checkInterval = 500) { + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitMs) { + if (window.ApiClient && + window.ApiClient._loggedIn && + window.ApiClient.accessToken && + window.ApiClient.serverAddress) { + try { + const token = window.ApiClient.accessToken(); + if (token) { + LOG('User is logged in'); + return true; + } + } catch (e) { + // Token not available yet + } + } + + await new Promise(resolve => setTimeout(resolve, checkInterval)); + } + + WARN('Timeout waiting for user login'); + return false; + } + + /** + * Saves the current KefinTweaksConfig back to JS Injector plugin + * @param {Object} config - Optional config object to save. If not provided, uses window.KefinTweaksConfig + * @param {Object} options - Options for saving + * @param {boolean} options.waitForLogin - Whether to wait for login if not logged in (default: true) + * @param {number} options.loginWaitTime - Maximum time to wait for login in ms (default: 10000) + * @returns {Promise} - Success status + */ + async function saveConfigToJavaScriptInjector(config = null, options = {}) { + try { + const { waitForLogin: shouldWaitForLogin = true, loginWaitTime = 10000 } = options; + + // Check if ApiClient is available + if (!window.ApiClient) { + if (shouldWaitForLogin) { + LOG('ApiClient not available, waiting for initialization...'); + const loggedIn = await waitForLogin(loginWaitTime); + if (!loggedIn) { + WARN('ApiClient not available after waiting, cannot save config'); + return false; + } + } else { + throw new Error('ApiClient not available'); + } + } + + // Check if user is logged in + const isLoggedIn = window.ApiClient._loggedIn && + window.ApiClient.accessToken && + window.ApiClient.serverAddress; + + if (!isLoggedIn) { + if (shouldWaitForLogin) { + LOG('User not logged in, waiting for login...'); + const loggedIn = await waitForLogin(loginWaitTime); + if (!loggedIn) { + WARN('User not logged in after waiting, cannot save config'); + return false; + } + } else { + throw new Error('User not logged in'); + } + } + + // Use provided config or fall back to window.KefinTweaksConfig + const configToSave = config || window.KefinTweaksConfig; + if (!configToSave) { + throw new Error('No config provided and window.KefinTweaksConfig is not available'); + } + + // Find JavaScript Injector plugin + const server = window.ApiClient.serverAddress(); + const token = window.ApiClient.accessToken(); + + if (!server || !token) { + throw new Error('Server address or access token not available'); + } + + let pluginsResponse = await fetch(`${server}/Plugins`, { + headers: { 'X-Emby-Token': token } + }); + + // Handle 401 Unauthorized - retry once after waiting if waitForLogin is enabled + if (pluginsResponse.status === 401 && shouldWaitForLogin) { + WARN('Received 401 Unauthorized, waiting for login and retrying...'); + const loggedIn = await waitForLogin(5000); // Wait up to 5 more seconds + if (loggedIn) { + // Retry with fresh token + const freshToken = window.ApiClient.accessToken(); + pluginsResponse = await fetch(`${server}/Plugins`, { + headers: { 'X-Emby-Token': freshToken } + }); + } + } + + if (!pluginsResponse.ok) { + if (pluginsResponse.status === 401) { + throw new Error('Unauthorized - user may not be logged in or session expired'); + } + throw new Error(`Failed to get plugins: ${pluginsResponse.status} ${pluginsResponse.statusText}`); + } + + const pluginsData = await pluginsResponse.json(); + const pluginsList = Array.isArray(pluginsData) ? pluginsData : (pluginsData.Items || []); + + const plugin = pluginsList.find(p => p.Name === 'JavaScript Injector' || p.Name === 'JS Injector'); + if (!plugin) { + WARN('JavaScript Injector plugin not found, cannot save config'); + return false; + } + + const pluginId = plugin.Id; + + // Get current injector config + const configUrl = `${server}/Plugins/${pluginId}/Configuration`; + const configResponse = await fetch(configUrl, { + headers: { 'X-Emby-Token': token } + }); + + if (!configResponse.ok) { + throw new Error(`Failed to get plugin config: ${configResponse.statusText}`); + } + + const injectorConfig = await configResponse.json(); + + // Ensure CustomJavaScripts array exists + if (!injectorConfig.CustomJavaScripts) { + injectorConfig.CustomJavaScripts = []; + } + + // Create the script content + const scriptContent = `// KefinTweaks Configuration +// This file is automatically generated by KefinTweaks Configuration UI +// Do not edit manually unless you know what you're doing + +window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; + + // Find or create KefinTweaks-Config script + const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex( + script => script.Name === 'KefinTweaks-Config' + ); + + if (existingScriptIndex !== -1) { + // Update existing script + injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent; + } else { + // Add new script + injectorConfig.CustomJavaScripts.push({ + Name: 'KefinTweaks-Config', + Script: scriptContent, + Enabled: true, + RequiresAuthentication: false + }); + } + + // Save the updated configuration + const saveResponse = await fetch(configUrl, { + method: 'POST', + headers: { + 'X-Emby-Token': token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(injectorConfig) + }); + + if (!saveResponse.ok) { + throw new Error(`Failed to save plugin config: ${saveResponse.statusText}`); + } + + LOG('Successfully saved config to JS Injector plugin'); + return true; + } catch (err) { + ERR('Error saving config to JS Injector:', err); + return false; + } + } + /** * Add the link to the existing container * @param {Element} container - The custom menu options container @@ -387,7 +576,8 @@ getCurrentView, getHandlerCount, clearHandlers, - addCustomMenuLink + addCustomMenuLink, + saveConfigToJavaScriptInjector }; LOG('Initialized successfully'); diff --git a/scripts/watchlist.js b/scripts/watchlist.js index 9ea2a94..d821b76 100644 --- a/scripts/watchlist.js +++ b/scripts/watchlist.js @@ -1082,26 +1082,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: document.head.appendChild(style); LOG('Custom watchlist icon CSS added'); - /************ Helpers ************/ - - async function fetchLikedItems(type) { - const apiClient = window.ApiClient; - const userId = apiClient.getCurrentUserId(); - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - - const url = `${serverUrl}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${userId}&Recursive=true&ImageTypeLimit=1&EnableImageTypes=Primary,Backdrop,Thumb`; - - try { - const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } }); - const data = await res.json(); - return data.Items || []; - } catch (err) { - ERR("Failed to fetch liked items for type:", type, err); - return []; - } - } - /************ Progress Tab Functions ************/ // Generic cache factory function @@ -2281,14 +2261,14 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Check localStorage cache first const userId = window.ApiClient.getCurrentUserId(); const cachedData = localStorageCache.get(`watchlist_${section}`); - if (cachedData) { + if (cachedData && false) { LOG(`Using localStorage cache for watchlist ${section}`); watchlistCache[section].data = cachedData; return; } try { - const items = await fetchLikedItems(type); + const items = watchlistCache[section].data; // Sort items by release date descending (newest first) const sortedItems = items.sort((a, b) => { @@ -2298,7 +2278,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: }); // Store in cache - watchlistCache[section].data = sortedItems; + //watchlistCache[section].data = sortedItems; // Store in localStorage for next time (optimized) const optimizedData = optimizeWatchlistDataForStorage(sortedItems); @@ -2334,6 +2314,17 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: renderWatchlistContent(); try { + // Fetch watchlist data + const watchlistData = await window.apiHelper.getWatchlistItems({ IncludeItemTypes: 'Movie,Series,Season,Episode' }); + localStorageCache.set('watchlist_movies', watchlistData.Items.filter(item => item.Type === 'Movie')); + localStorageCache.set('watchlist_series', watchlistData.Items.filter(item => item.Type === 'Series')); + localStorageCache.set('watchlist_seasons', watchlistData.Items.filter(item => item.Type === 'Season')); + localStorageCache.set('watchlist_episodes', watchlistData.Items.filter(item => item.Type === 'Episode')); + watchlistCache.movies.data = watchlistData.Items.filter(item => item.Type === 'Movie'); + watchlistCache.series.data = watchlistData.Items.filter(item => item.Type === 'Series'); + watchlistCache.seasons.data = watchlistData.Items.filter(item => item.Type === 'Season'); + watchlistCache.episodes.data = watchlistData.Items.filter(item => item.Type === 'Episode'); + // Initialize all watchlist sections in parallel await Promise.all([ initWatchlistSection('movies', 'Movie'), @@ -2399,7 +2390,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Wait for progress and history, then init statistics await Promise.all([watchlistPromise, progressPromise, historyPromise]); await initStatisticsTab(); - LOG('All tabs initialized'); } @@ -2626,7 +2616,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Debug logging to verify we're getting the right data LOG(`Series: ${series.Name}, Aired Episodes: ${totalEpisodes}, Watched: ${watchedCount}, Remaining: ${remainingCount}`); - if (totalEpisodes === 0) return null; + if (totalEpisodes === 0 || watchedCount === 0) return null; // Calculate total runtime from available episodes (only those with actual files) const availableEpisodes = episodes.filter(ep => ep.RunTimeTicks && ep.RunTimeTicks > 0); @@ -2708,19 +2698,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: }); } - // Play movie function - function playMovie(movieId) { - LOG(`Playing movie: ${movieId}`); - const apiClient = window.ApiClient; - const userId = apiClient.getCurrentUserId(); - - // Use Jellyfin's built-in play functionality - apiClient.play({ - Ids: [movieId], - UserId: userId - }); - } - async function toggleMovieFavorite(movieId, buttonElement) { LOG(`Toggling favorite for movie: ${movieId}`); const apiClient = window.ApiClient; @@ -3712,62 +3689,58 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: { type: 'Episode', section: 'episodes' } ]; - for (const { type, section } of types) { + + const watchlistData = await window.apiHelper.getWatchlistItems({ IncludeItemTypes: 'Movie,Series,Season,Episode', Fields: 'ProviderIds,SeriesName,ParentId' }, false); + const items = watchlistData.Items; + + for (const item of items) { try { - // Fetch from server with Fields to get SeriesName for episodes/seasons - const url = `${serverUrl}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${userId}&Recursive=true&Fields=ProviderIds,SeriesName,ParentId&ImageTypeLimit=1&EnableImageTypes=Primary,Backdrop,Thumb`; - const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } }); - const data = await res.json(); - const items = data.Items || []; + const exportItem = { + status: true, + Name: item.Name, + Type: item.Type // Add Type field for efficient import + }; - for (const item of items) { - const exportItem = { - status: true, - Name: item.Name, - Type: type // Add Type field for efficient import - }; + // Add provider IDs (only if they exist) + if (item.ProviderIds) { + if (item.ProviderIds.Imdb) exportItem.Imdb = item.ProviderIds.Imdb; + if (item.ProviderIds.Tmdb) exportItem.Tmdb = item.ProviderIds.Tmdb; + if (item.ProviderIds.Tvdb) exportItem.Tvdb = item.ProviderIds.Tvdb; + } - // Add provider IDs (only if they exist) - if (item.ProviderIds) { - if (item.ProviderIds.Imdb) exportItem.Imdb = item.ProviderIds.Imdb; - if (item.ProviderIds.Tmdb) exportItem.Tmdb = item.ProviderIds.Tmdb; - if (item.ProviderIds.Tvdb) exportItem.Tvdb = item.ProviderIds.Tvdb; + // For episodes, add SeriesName and SeasonName + if (item.Type === 'Episode') { + if (item.SeriesName) { + exportItem.SeriesName = item.SeriesName; } - - // For episodes, add SeriesName and SeasonName - if (type === 'Episode') { - if (item.SeriesName) { - exportItem.SeriesName = item.SeriesName; - } - // Get SeasonName from parent season - if (item.ParentId) { - try { - const seasonUrl = `${serverUrl}/Items/${item.ParentId}?UserId=${userId}&Fields=Name`; - const seasonRes = await fetch(seasonUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } }); - const seasonData = await seasonRes.json(); - if (seasonData.Name) { - exportItem.SeasonName = seasonData.Name; - } - } catch (err) { - WARN('Failed to fetch season name for episode:', err); + // Get SeasonName from parent season + if (item.ParentId) { + try { + const seasonUrl = `${serverUrl}/Items/${item.ParentId}?UserId=${userId}&Fields=Name`; + const seasonRes = await fetch(seasonUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } }); + const seasonData = await seasonRes.json(); + if (seasonData.Name) { + exportItem.SeasonName = seasonData.Name; } + } catch (err) { + WARN('Failed to fetch season name for episode:', err); } } + } - // For seasons, add SeriesName - if (type === 'Season') { - if (item.SeriesName) { - exportItem.SeriesName = item.SeriesName; - } + // For seasons, add SeriesName + if (item.Type === 'Season') { + if (item.SeriesName) { + exportItem.SeriesName = item.SeriesName; } + } - // Only add if at least one provider ID exists - if (exportItem.Imdb || exportItem.Tmdb || exportItem.Tvdb) { - exportData.push(exportItem); - } + // Only add if at least one provider ID exists + if (exportItem.Imdb || exportItem.Tmdb || exportItem.Tvdb) { + exportData.push(exportItem); } } catch (err) { - ERR(`Failed to fetch ${type} items:`, err); + ERR(`Failed to export item:`, err); } } @@ -4152,15 +4125,22 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Hook into onmessage socket.onmessage = function(event) { try { + LOG('WebSocket message received:', event); + // Pass it through so Jellyfin still works normally + if (originalHandler) { + originalHandler.call(this, event); + } + // Parse the WebSocket message data // The event might have event.Data or event.data depending on implementation - const messageData = event.Data || event.data; +/* const messageData = event.Data || event.data; if (!messageData) { // If no data, just pass through to original handler if (originalHandler) originalHandler.call(this, event); return; - } + } */ + const messageData = event.Data || event.data; const data = typeof messageData === 'string' ? JSON.parse(messageData) : messageData; // Check if this is a UserDataChanged message @@ -4178,11 +4158,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Log parse errors but don't break the original handler WARN('WebSocket message parse error:', err); } - - // Pass it through so Jellyfin still works normally - if (originalHandler) { - originalHandler.call(this, event); - } }; playbackMonitorInitialized = true; @@ -7505,7 +7480,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Expose functions to global scope for onclick handlers window.toggleMovieFavorite = toggleMovieFavorite; - window.playMovie = playMovie; LOG('Initialized successfully'); })();