diff --git a/README.md b/README.md index 145ec6f..9124bc5 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,28 @@
- Your project logo + KefinTweaks
+KefinTweaks Devices Mockup + +

+Windows IconmacOS IconiOS IconAndroid IconBrowser IconLG WebOS IconXbox Icon +

+

+Jellyfin Latest VersionJellyfin Latest VersionJellyfin Latest Version +JS Delivr hits +Jellyfin Community Discord KefinTweaks Channel +

+
**KefinTweaks is a comprehensive collection of enhancements and customizations for Jellyfin, focused on, but not limited to, filling gaps in functionality based on the community's most desired [Feature Requests](https://features.jellyfin.org/?view=most-wanted). This includes things like a [Watchlist](#watchlist), a [customizable Home Screen](#enhanced-home-screen), an [improved search experience](#enhanced-search), and an [Appearance Manager](#skin-manager)**. -While working on these scripts to meet my personal needs, I noticed that there was a lot of overlap between the functionality I desire and many of the requested features by the community so I thought it would be a good idea to share this for anyone who may be interested. +While working on these scripts to meet my personal needs, I noticed that there was a lot of overlap between the functionality I desire and many of the requested features by the community so I thought it would be a good idea to share this for anyone who may be interested. -Since I began working on this in August 2025, the scope of the project has grown and changed drastically. This began as a small set of tweaks to enhance my own personal server, but has evolved into a robust set of features that aim to compliment and enhance the native Jellyfin experience in a way that feels like it *is* native functionality. +Since I began working on this in August 2025, the scope of the project has grown and changed drastically. This began as a small set of tweaks to enhance my own personal server, but has evolved into a robust set of features that aim to compliment and enhance the native Jellyfin experience in a way that feels like it _is_ native functionality. In the future, we can hope that the majority of this plugin will become obsolete as we will see many of these changes implemented and included in the native Jellyfin experience, as the community has so long desired! @@ -24,45 +35,48 @@ Note: The Slideshow at the top of the Home Screen in the video below is from the ## Table of Contents - [Installation](#installation) - - [Prerequisites](#prerequisites) - - [Setup Instructions](#setup-instructions) + - [Prerequisites](#prerequisites) + - [Setup Instructions](#setup-instructions) - [Configuration](#configuration) - [Features Overview](#features-overview) - - [Data Caching](#data-caching) - - [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](#watchlist-page) - - [Series Progress Page](#series-progress) - - [Movie History Page](#movie-history) - - [User Statistics Page](#statistics) - - [Export/Import](#exportimport) - - [Enhanced Home Screen](#enhanced-home-screen) - - [Enhanced Search](#enhanced-search) - - [UI Enhancements](#ui-enhancements) - - [Watchlist Support](#watchlist-support) - - [Skin Manager](#skin-manager) - - [UX Improvements](#ux-improvements) - - [Subtitle Search](#subtitle-search) - - [Remove from Continue Watching](#remove-from-continue-watching) - - [Collection Sorting](#collection-sorting) - - [Navigation Improvements](#navigation-improvements) - - [Header Tab Enhancements](#header-tab-enhancements) - - [Breadcrumb Navigation](#breadcrumb-navigation) - - [Custom Menu Integration](#custom-menu-integration) - - [Playlist Screen Improvement](#playlist-screen-improvement) - - [Flatten Single Season Shows](#flatten-single-season-shows) - - [Collections on Movie/Series Details page](#collections-on-movieseries-details-page) - - [System Improvements](#system-improvements) - - [Performance & Stability](#performance--stability) - - [Branding & Customization](#branding--customization) -- [Script Details](#script-details) - - [Dependency Scripts](#dependency-scripts) - - [Feature Scripts](#feature-scripts) - - [Auto-Inject Dependencies](#auto-inject-dependencies) - - [Info For Developers](#info-for-developers) + - [Data Caching](#data-caching) + - [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](#watchlist-page) + - [Series Progress Page](#series-progress) + - [Movie History Page](#movie-history) + - [User Statistics Page](#statistics) + - [Export/Import](#exportimport) + - [Enhanced Home Screen](#enhanced-home-screen) + - [Enhanced Search](#enhanced-search) + - [Thumbnail Scrubber](#thumbnail-scrubber) + - [Watch Together](#watch-together) + - [Version Preferences](#version-preferences) + - [User Manager](#user-manager) + - [Library Mini-Games](#games) + - [UI Enhancements](#ui-enhancements) + - [Watchlist Support](#watchlist-support) + - [Skin Manager](#skin-manager) + - [UX Improvements](#ux-improvements) + - [Subtitle Search](#subtitle-search) + - [Remove from Continue Watching](#remove-from-continue-watching) + - [Collection Sorting](#collection-sorting) + - [Navigation Improvements](#navigation-improvements) + - [Header Tab Enhancements](#header-tab-enhancements) + - [Breadcrumb Navigation](#breadcrumb-navigation) + - [Custom Menu Integration](#custom-menu-integration) + - [Playlist Screen Improvement](#playlist-screen-improvement) + - [Flatten Single Season Shows](#flatten-single-season-shows) + - [Collections on Movie/Series Details page](#collections-on-movieseries-details-page) + - [Desktop Hamburger Menu](#desktop-hamburger-menu) + - [Custom Pages](#custom-pages) + - [System Improvements](#system-improvements) + - [Performance & Stability](#performance--stability) + - [Branding & Customization](#branding--customization) +- [Info For Developers](#info-for-developers) - [Client Support](#client-support) - [Changes to default Jellyfin functionality](#changes-to-default-jellyfin-functionality) - [Design Philosophy](#design-philosophy) @@ -71,21 +85,19 @@ Note: The Slideshow at the top of the Home Screen in the video below is from the - [Acknowledgments](#acknowledgments) - [Support](#support) - [FAQ](#faq) -- [Roadmap](#roadmap) - - [Planned Features](#planned-features) - - [Version History](#version-history) +- [Version History](#version-history) ## Installation ### Prerequisites -- Jellyfin 10.10.7 or earlier (10.11.X is untested and "unsupported" but may be mostly functional) + +- Jellyfin 12, 11 or 10 - [JS Injector](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector) plugin installed and configured - [File Transformation Plugin](https://github.com/IAmParadox27/jellyfin-plugin-file-transformation) plugin is recommended to avoid file permission issues when injecting scripts with JS Injector -- Only required for Watchlist: [Custom Tabs](https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs) plugin ### Setup Instructions -0. **Install the Prerequisites listed above** +0. **Install the Prerequisites listed above** 1. **Add a new script to your JS Injector Plugin** 2. **Copy the code below, paste it into the new script, save your changes and refresh the page** @@ -96,17 +108,17 @@ script.async = true; document.head.appendChild(script); ``` -If you are hosting the script yourself, replace the `https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks@latest/` with your hosted location + `/KefinTweaks/`. You should be able to include the scripts in your jelly-web folder and access it from `/web/KefinTweaks` if you like. +If you are hosting the script yourself, replace the `https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks@latest/` with your hosted location + `/KefinTweaks/`. You should be able to include the scripts in your jellyfin-web folder and access it from `/web/KefinTweaks` if you like. 3. **Navigate to the Plugins page and click on KefinTweaks** 4. **Choose your preferred plugin version, or specify a custom source if you are self hosting the scripts** 5. **Press Install! KefinTweaks will automatically load without refreshing the page** -
- injector -
-
-
+| KefinTweaks in 60 seconds | +| :---: | +| ![KefinTweaks Installation](pages/images/plugin-install.gif) | + +  ## Configuration @@ -126,7 +138,7 @@ All configuration options for KefinTweaks can be managed directly from the UI. - **Feature Toggle**: Toggle individual KefinTweaks features on or off - **Home Screen Configuration**: Configure custom sections, discovery features, and seasonal content -- **Search Configuration**: Enable/disable Jellyseerr integration +- **Search Configuration**: Enable/disable Seerr integration (requires JellyfinEnhanced plugin) - **Skin Configuration**: Manage available skins, set default skins, and enable/disable specific skins - **Theme Configuration**: Add custom themes available to all users - **Exclusive Elsewhere Configuration**: Configure branding behavior @@ -138,63 +150,60 @@ All configuration options for KefinTweaks can be managed directly from the UI. - **Export**: Export your entire configuration to clipboard as JSON for backup or sharing - **Import**: Import a previously exported configuration to restore settings or apply configurations from other servers -
- Configuration Interface -
-
- -
- Configuration Interface -
-
+| Configuration Interface | +| :---: | +| ![Configuration Interface](pages/images/config-1.png) | +  ### Automatic Setup On first load, KefinTweaks automatically: -- Creates the `KefinTweaks-Config` script in the JavaScript Injector plugin with default settings -- Ensures the Watchlist tab exists in the CustomTabs plugin with the correct HTML structure -These tasks only run for administrator users and happen automatically in the background. -
+- Creates the `KefinTweaks-Config` script in the JavaScript Injector plugin with default settings +- Creates the `KefinTweaks-Injector` script in the JavaScript Injector plugin with default settings + +These tasks only run for administrator users and happen automatically in the background. +  ## Uninstallation 1. **Navigate to the Plugins page and click on KefinTweaks** -2. **Press Uninstall and click to confirm** - -
- injector -
-
-
+2. **Press Uninstall and click to confirm** +You can also choose to disable to plugin temporarily and re-enable it with a single click at any time from the Plugin Settings. +| Uninstall KefinTweaks | +| :---: | +| ![Uninstall KefinTweaks](pages/images/plugin-uninstall.gif) | +  ## Features Overview KefinTweaks provides a modular system of enhancements that can be individually enabled or disabled based on your needs. Each script is designed to work independently while sharing common utilities and dependencies. -As mentioned above, most of the functionality included in KefinTweaks are features that have been requested by the community. Some of these requests have been pending for 5 years or more. I feel that a lot of the top requests have functionality that is essential to the Jellyfin experience but I have also included less requested features, as well as some things that I just personally needed or wanted. +As mentioned above, most of the functionality included in KefinTweaks are features that have been requested by the community. Some of these requests have been pending for 5 years or more. I feel that a lot of the top requests have functionality that is essential to the Jellyfin experience but I have also included less requested features, as well as some things that I just personally needed or wanted. +  ## Data Caching One thing that I feel is worth pointing out is the way in which some of the data needed by KefinTweaks is handled. For most of the features, data is grabbed on demand as normal, same as any existing Jellyfin functionality. However there are specific features, or subsets of features which do use local device caching in order to either improve performance, or simply because it is not feasible to fetch the data each time. In the future if this ever becomes a plugin, the caching can be more intelligent and done on the server. -The features in KefinTweaks which use local data caching are listed below, along with accompanying explanations for why they are being cached. Everything is cached by default for 24h unless it is specified otherwise. -- Home Screen - - **Genres** [24h]: - Genres are cached simply to reduce API calls to the server. This is not an expensive API call, but under most circumstances the results will be the same even across longer periods of time. - - **Top People** [7d]: - In order to populate the "Top People" in your movie library, we fetch the Person data for every movie in your library. We build a list of all the People who appear in at least X number of items based on their type (Actor/Director/Writer). This is an expensive operation, and is longer as the size of your library grows. +The features in KefinTweaks which use local data caching are listed below, along with accompanying explanations for why they are being cached. Everything is cached by default for 24h unless it is specified otherwise. + +- Home Screen + - **Genres** [24h]: + Genres are cached simply to reduce API calls to the server. This is not an expensive API call, but under most circumstances the results will be the same even across longer periods of time. + - **Top People** [7d]: + In order to populate the "Top People" in your movie library, we fetch the Person data for every movie in your library. We build a list of all the People who appear in at least X number of items based on their type (Actor/Director/Writer). This is an expensive operation, and is longer as the size of your library grows. - Watchlist - - **Watchlist Items** [5m]: - These items are only cached after they are fetched on first page load. This means every time you refresh the page we fetch this data from the server and then in most situations will simply re-use that data until it expires 5 minutes later, or when the user manually refreshes the page. - - **Series Progress and Movie History** [24h]: - These are both more expensive operations, especially if you have watched a very high number of Movies or Shows. It's not practical to fetch this data on demand so we cache it to improve UX. -- Collections on Details Page - - **Collections** [1h]: - These items are only cached after they are fetched on first page load. 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. -
+ - **Watchlist Items** [5m]: + These items are only cached after they are fetched on first page load. This means every time you refresh the page we fetch this data from the server and then in most situations will simply re-use that data until it expires 5 minutes later, or when the user manually refreshes the page. + - **Series Progress and Movie History** [24h]: + These are both more expensive operations, especially if you have watched a very high number of Movies or Shows. It's not practical to fetch this data on demand so we cache it to improve UX. +- Collections on Details Page + - **Collections** [1h]: + These items are only cached after they are fetched on first page load. 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 @@ -219,44 +228,52 @@ The features in KefinTweaks which use local data caching are listed below, along - 🚧 [Keep original title option](https://features.jellyfin.org/posts/32/keep-original-title-option) - 🚧 [Search by tag/genre](https://features.jellyfin.org/posts/276/search-by-tag-genre) -- 🚧 [Add drag and drop to library order](https://features.jellyfin.org/posts/3509/add-drag-and-drop-to-libray-order) -
+- 🚧 [Add drag and drop to library order](https://features.jellyfin.org/posts/3509/add-drag-and-drop-to-libray-order) +  -### Core Features +# Core Features ## **Watchlist** -### **Watchlist Page**: -Tired of forgetting everything you wanted to watch? Add movies, series, seasons, and episodes to your Watchlist! -
- watchlist -
-
+### **Watchlist Page** -### **Series Progress**: -An overview of every series you've ever started watching with functionality to filter, sort and mark items as watched. +Tired of forgetting everything you wanted to watch? Add movies, series, seasons, and episodes to your Watchlist! +- **Auto-Remove**: + Automatically removes watched items from your watchlist when playback completes. -
- progress -
-
+- **Real-time Updates**: +Cached watchlist updates when toggling watchlist status from card overlays -### **Movie History**: -An overview of every movie you've ever watched with functionality to filter, sort and mark items as favorites. +| Your Watchlist Page | +| :---: | +| ![Watchlist](pages/images/watchlist.png) | -
- history -
-
+### **Series Progress** -### **Statistics**: -A summary of your watched items by the numbers. See how many movies, shows or episodes you have watched. +An overview of every series you've ever started watching with functionality to filter, sort and mark items as watched. -
- statistics -
+| Review your all your in-progress and completely watched Series | +| :---: | +| ![Series Progress](pages/images/progress.png) | + +### **Movie History** + +An overview of every movie you've ever watched with functionality to filter, sort and mark items as favorites. + +| Check your full history of Watched Movies | +| :---: | +| ![Movie History](pages/images/history.png) | + +### **Statistics** + +A summary of your watched items by the numbers. See how many movies, shows or episodes you have watched. Check our your Top Shows, Playback History, Watch Streaks and Binge Sessions. + +| Playback History, Watch Streaks, Binge Sessions | +| :---: | +| ![Stats](pages/images/statistics.png) | + +### **Export/Import/Sync** -### **Export/Import/Sync**: Export your Watchlist to JSON which can later be imported using the Watchlist Import feature. You can use this feature to make bulk changes to your Watchlist if you need to. Additionally, you can Sync your Watchlist to any Playlist of your choice. @@ -269,75 +286,275 @@ Export your Watchlist to JSON which can later be imported using the Watchlist Im
-
+  -- **Auto-Remove**: -Automatically removes watched items from your watchlist when playback completes. +## **Enhanced Home Screen** -- **Real-time Updates**: -Cached watchlist updates when toggling watchlist status from card overlays -
+Have you ever wanted to be able to fully customize nearly every aspect of your Home Screen? Be able to set it up for all users in a particular way, with the ability for them to make changes to their liking? That's what the Enhanced Home Screen does. So much has been added here that it won't even be possible to give a clear overview of everything that is possible with the new features. I'll do my best to list out everything, but you can always ask me on Discord if you want to know how to create something specific that you can't quite figure out. It's probably achievable. +| New Home Sections Available in KefinTweaks | +| :---: | +| ![New sections available in KefinTweaks](https://i.imgur.com/uispy2q.png) | + +- **Every native Jellyfin section** + KefinTweaks supports all of the native sections included in Jellyfin by default. The section status (enabled/disabled) in KefinTweaks will sync to your Jellyfin user preferences so these sections remain available when using a client that doesn't support KefinTweaks. -#### **Enhanced Home Screen** - **Custom Sections**: -Add playlist or collection-based sections to your home screen + Limited only by your creativity, you can create almost any section you like -- **New & Trending**: -Creates sections for movies and episodes released in the last 7 days (trending sections incomplete) +- **New Releases**: + Creates sections for movies and episodes released in the last X days - **Infinite Discovery Sections**: -Load discovery sections based on items you've watched and favorited, as well as from genres or top people in your library + Load discovery sections based on items you've watched, favorited and watchlisted, as well as from genres or top people in your library - **HSS Support**: -Full compatibility with [Home Screen Sections](https://github.com/IAmParadox27/jellyfin-plugin-home-sections) plugin by [IAmParadox27](https://github.com/IAmParadox27/) + Full compatibility with [Home Screen Sections](https://github.com/IAmParadox27/jellyfin-plugin-home-sections) plugin by [IAmParadox27](https://github.com/IAmParadox27/) + +- **Thematic Picks**: + Thematic sections based around similar themes built from your library tags + +- **Browse and Resume Playlists and Collections**: + Use these sections to browse your Playlists and Collection, and pick up where you left off in any currently in-progress playlists or collections + +- **Browse By Studio**: + Adds a beautiful home screen row for popular Movie, TV and Streaming studios + +- **More Live TV sections** + Show your TV Channels, or Programs by Category directly on your home screen - **Seasonal Content**: -Seasonally-themed sections (Halloween, Christmas, etc.) + Create your own Seasonal sections or enable them from the ones already provided in KefinTweaks -
- homescreen -
-
+| Seasonally-themed sections with companion Seasonal Backgrounds and Animations | +| :---: | +| ![Seasonal Content](pages/images/seasonalsection.png) | - **Watchlist Integration**: -Dedicated watchlist section on home screen + Add a section for your Watchlist directly on your home screen -
- homescreen -
-
+| Watchlist Section on Home Screen | +| :---: | +| ![Watchlist Section](pages/images/homescreen.png) | + +  + +Home Category Navigation +--- +- Adds navigation controls to navigate between Home / Discovery / Pinned or any custom Category +- Start discovering content without scrolling through the entire home screen + +| ![Home Category Navigation](https://i.imgur.com/CLLuAlp.png) | +| :---: | + +  + +Pinned Content +--- +- Pin specific items to your home screen + - Choose a name for the section you want to pin the item to +- Pin Children items to your home screen + - Pick a Parent item: Studio, Genre, Series, Person, etc + +| ![Pinned Content](https://i.imgur.com/dRiWEHM.png) | +| :---: | + +  + +Personalized Discovery Sections +--- +- Personalized dynamic recommendations based on your watch history, favorites, watchlist, etc +- Suggested content from popular Genres, Studios, People +- Custom user created static discovery sections (dynamic user created sections coming soon! + +| ![Personalized Discovery Sections](https://i.imgur.com/LDVx0r5.png) | +| :---: | + +  + +Section Customization +--- +- Pick the Artwork + - Support for every Jellyfin artwork type +- Pick a Border + - 14 different border styles + - Custom border color +- Customize the footer text + - 12 different fonts + - Font size + - Font color + - Text capitalization options + - Horizontal and vertical positioning (text overlay) +- Switch between Row and Grid view + +| All existing Jellyfin artwork types supported | +| :---: | +| ![Artwork support](https://raw.githubusercontent.com/ranaldsgift/KefinTweaks/refs/heads/experimental/pages/images/releases/v0.5/cardformats.webp) | +| 14 Different Border Styles | +| ![Border styles](https://raw.githubusercontent.com/ranaldsgift/KefinTweaks/refs/heads/experimental/pages/images/releases/v0.5/borders.webp) | + +  + +Spotlight Customization +--- +- Pick the Layout + - Borderless + - Border +- Pick the size + - Normal + - Large + - Full +- Cycle your backdrops + - Decide how many backdrops appear for an item before advancing slides +- Tile your backdrops + - Up to 3 backdrops side by side +- Slide animations + - Enable or disable animations + - Pick your preferred animation for each backdrop tile + +| Spotlight Section Variations | +| :---: | +| ![Spotlight sections](https://raw.githubusercontent.com/ranaldsgift/KefinTweaks/refs/heads/experimental/pages/images/releases/v0.5/spotlights.webp) | + +  + +Section Editor: Wizard +--- +- Use pre-defined templates to easily create new sections +- Sections created by the Wizard can still be fully edited in the Advanced Editor later + +| Section Creation Wizard | +| :---: | +| ![Section wizard](https://raw.githubusercontent.com/ranaldsgift/KefinTweaks/refs/heads/experimental/pages/images/releases/v0.5/wizard.webp) | + +  + +Section Editor: Advanced +--- +- Create any custom section where the limits are only what Jellyfin can handle +- Define multiple queries for a section + - Sort each query individually + - Choose a post-process items strategy: Merge / Separate / Random + - Choose a post-process sorting strategy +- Merged mode results are merged before post processing +- Separate mode adds a "Query Picker" to the section to switch between the defined queries +- Random mode chooses one query from the list at random every time the section loads +- Limited only by your creativity, you can create almost any section you like: + - Top rated Series Premiere episodes from the last 365 days + - Lowest rated Series that aired for more than a decade + - Movies that start with the letter B and have a rating above 7 + - Movies released by Universal Pictures before 1995 starring Keith David + +Just watch the [teaser trailer](https://www.youtube.com/watch?v=URw8q6acdmo) if you want an idea of the level of customization. + +## **Enhanced Search** -#### **Enhanced Search** - **Performance Improvements**: -Defaults to searching in Movies/TV/People as most searches are for these items. Options to search specific categories or all categories like the default Jellyfin search functionality. + Defaults to searching in Movies/TV/People as most searches are for these items. Options to search specific categories or all categories like the default Jellyfin search functionality. - **Jellyseerr Support**: -If you have [JellyfinEnhanced](https://github.com/n00bcodr/Jellyfin-Enhanced) installed, the Jellyseerr search results will appear in the KefinTweaks enhanced search. There is also a button that lets users search specifically for Requests. + If you have [JellyfinEnhanced](https://github.com/n00bcodr/Jellyfin-Enhanced) installed, the Jellyseerr search results will appear in the KefinTweaks enhanced search. There is also a button that lets users search specifically for Requests. - **Meilisearch Support**: -Zero-config support for searching with the [Meilisearch](https://github.com/arnesacnussem/jellyfin-plugin-meilisearch) plugin + Zero-config support for searching with the [Meilisearch](https://github.com/arnesacnussem/jellyfin-plugin-meilisearch) plugin -
- search -
-
-
+| Search by Content Type | +| :-: | +| ![Enhanced Search](pages/images/search.png) | -### UI Enhancements +  + +Thumbnail Scrubber +--- +Tired of Youtube hover previews and the seekbar functionality to quickly seek through a video? Well, wake up! We brought it to Jellyfin. + +- Adds a seekbar and "scrubbing" functionality when hovering a card similar to Youtube +- Optionally enable Video Previews on Hover +- Video Previews fallback to Trickplay when a static stream can't be completed + +FR: [1](https://features.jellyfin.org/posts/139/show-video-preview-on-thumbnail-hover) + +| Thumbnail Scrubber | Video Preview on Hover | +| :-: | :-: | +| ![Scrubber Example](https://i.imgur.com/Lr2bbxy.gif) | ![Hover Preview Example](https://i.imgur.com/CpcOE5A.gif) | + +  + +Watch Together +--- +This feature is designed to be used by people who are watching something together on the same device and want the Watched status and progress to be synced to each of their individual accounts. This could be watching together in your living room, in the sauna, or even while you travel to the moon in your preferred spacecraft. + +Authenticate the user accounts you want to sync progress with. If you have active users in your Watch Together group, you will be reminded at the beginning of playback if you'd like to continue syncing with any of them. + +[Checkout how it works!](https://streamable.com/yfj8xk) The UI has been improved since this video, but the functionality remains. + +FR: [1](https://features.jellyfin.org/posts/389/watch-together-watch-with-multiples-accounts), [2](https://features.jellyfin.org/posts/3950/would-love-to-see-a-watch-together-or-a-syncplay-feature-for-the-jellyfin-tv) + +| Video OSD Access and Reminder | +| :---: | +| ![Watch Together](https://i.imgur.com/m6eiZmS.png) | + +  + +Version Preferences +--- +Have you ever wanted Jellyfin to respect its own naming schemes? Why is that pesky 1080p version listed before the 4k version? Forget about it! + +- Specify priorities across both Versions and Editions +- Add custom Version qualities and custom Edition names +- Multiple Versions from the same Edition are grouped together +- Your choice of format: "Edition - Version" or "Version - Edition" + +FR: [1](https://features.jellyfin.org/posts/3816/smart-default-version-selection-for-grouped-media-by-bitrate-audio-tracks-codec-resolution), [2](https://features.jellyfin.org/posts/1465/multiple-editions-of-a-movie-with-multiple-resolutions), [3](https://features.jellyfin.org/posts/3758/option-to-manually-change-the-order-of-movie-versions-in-group-versions) + +| Prioritize your item Versions and Editions | +| :---: | +| ![Version Preferences](https://i.imgur.com/jnYoB3p.png) | + +  + +User Manager +--- +Every Jellyfin admin cuts their teeth by manually configuring each of their freshly created user accounts with their preferred defaults. It's a time-honored tradition, but what do I know about tradition? + +- Create User Templates +- Pick a default User Template +- New Users default to the chosen Template with the ability to select a different one instead +- Bulk Update existing users with the settings from any of your User Templates + +| User Profile Templates and Bulk User Editing | +| :---: | +| ![User Profile Templates](https://i.imgur.com/3ThokAo.png) | + +  + +Games +--- +Frivolous games where the points don't matter? Test your knowledge of your library with some random mini-games! + +- [Movie LUTs Game](https://game.movieluts.com/) +- 3x3 Game - Guess the movies from the backdrops! + +| Movie LUTs and more! | +| :---: | +| ![Games](https://i.imgur.com/f7aDWkm.png) | + +  + +# UI Enhancements + +## **Watchlist Support** -#### **Watchlist Support** - **Watchlist Toggle**: -Watchlist toggle button added to all item card overlays + Watchlist toggle button added to all item card overlays -
- watchlistoverlay -
-
-
+| Watchlist Items from the Card Overlay | +| :---: | +| ![Watchlist Overlay](pages/images/watchlistoverlay.png) | +  -#### **Skin Manager** +## **Skin Manager** The Skin Manager adds the functionality for users to change the appearance of the Web UI from a button in the top header, or from their Display Preferences in their User Account. @@ -351,155 +568,133 @@ The Skin Manager adds the functionality for users to change the appearance of th -
+
- **Skins** -Select from a list of pre-defined Skins created by other Jellyfin community members. You may specify additional custom skins in the configuration options. + Select from a list of pre-defined Skins created by other Jellyfin community members. You may specify additional custom skins in the configuration options. - **Color Schemes** -Certain Skins either support or require a color scheme. This lets you change the UI colors within an individual Skin. These options are automatically available for the Skins which support them. + Certain Skins either support or require a color scheme. This lets you change the UI colors within an individual Skin. These options are automatically available for the Skins which support them. - **Optional CSS Modules** -KefinTweaks also includes optional CSS modules from supported Skins, and even adds some of these as Global options for users to take advantage of with any Skin. -
+KefinTweaks also includes optional CSS modules from supported Skins, and even adds some of these as Global options for users to take advantage of with any Skin. +  -### UX Improvements +# UX Improvements -#### **Subtitle Search**: -Search and download subtitles directly from the video OSD +## **Subtitle Search**: -
- subtitlesearch -
-
+Search and download subtitles directly from the video OSD -#### **Remove from Continue Watching**: -Adds a card overlay button to remove items from Continue Watching for all resumable items +| Search and Download Subtitles While Watching Content | +| :---: | +| ![Subtitle Search](pages/images/subtitlesearch.png) | -
- continuewatching -
-
+  -#### **Collection Sorting**: -Adds the ability to sort collection items on the Collection page. Supports sorting by Title, Release Date, Date Added, Community Rating, Critic Rating. -
+## **Remove from Continue Watching**: -### Navigation Improvements +Adds a card overlay button to remove items from Continue Watching for all resumable items + +| Remove Progress from any in-progress Items | +| :---: | +| ![Remove from Continue Watching](pages/images/continuewatching.png) | + +  + +## **Collection Sorting**: + +Adds the ability to sort collection items on the Collection page. Supports sorting by Title, Release Date, Date Added, Community Rating, Critic Rating. +  + +# Navigation Improvements + +## **Header Tab Enhancements**: -#### **Header Tab Enhancements**: Improved tab navigation and functionality, supports linking to specific tabs +  + +## **Breadcrumb Navigation**: -#### **Breadcrumb Navigation**: Clear navigation paths for Movies, Series, Seasons, Episodes, Music Artists, Albums and Songs -
- breadcrumbs -
-
+| Navigate using Breadcrumbs at the top of Item pages | +| :---: | +| ![Breadcrumbs](pages/images/breadcrumbs.png) | -#### **Custom Menu Integration**: -Add custom menu links to the side navigation drawer menu +  -
- custommenu -
-
+## **Custom Menu Integration**: + +Add custom menu links to the side navigation drawer menu + +| Navigate to internal or external links from the Top Nav, User Menu, or Side Menu | +| :---: | +| ![Custom Menu Links](pages/images/custommenu.png) | + +  + +## **Playlist Screen Improvement**: -#### **Playlist Screen Improvement**: Updates the default playlist functionality to include a play button to start playback and make clicking an item go to the item detail page +  + +## **Episodes on the Series Page**: -#### **Episodes on the Series Page**: Adds an Episodes section on the Series page which displays the episodes from the season with the Next Up item. Users can quickly switch between seasons to browse other episodes. Includes the option to "Flatten Single Season Shows" which will hide the Seasons container when only one season is present. -
- series-episodes -
-
+| Browse Episode listings directly from the Series page | +| :---: | +| ![Episodes on Series Page](https://i.imgur.com/ttP9cGT.png) | -#### **Collections on Movie/Series Details page**: -This shows an "Included In" section on the Item Details page which displays any Collections that the item is a part of. +  -
- itemdetailscollections -
-
-
+## **Collections on Movie/Series Details page**: +This shows an "Included In" section on the Item Details page which displays any Collections that the item is a part of. -### System Improvements +| See which Collections an Item is Included In | +| :---: | +| ![Collections on Details Page](pages/images/itemdetailscollections.png) | + +  + +## Desktop Hamburger Menu + +Returns the Hamburger Menu to the top left corner on Desktop layouts in the default Modern UI +  + +## Custom Pages + +This is an internal feature that is leveraged by existing KefinTweaks features like the Watchlist and Games, but can be used for more Custom Pages in the future. This removes the CustomTabs requirement for the Watchlist tab. Existing installations with CustomTabs continue to use that. +  + +# System Improvements + +## **Performance & Stability** -#### **Performance & Stability** - **Background Leak Fix**: - Resolves an issue that causes backgrounds to be added to the DOM endlesslly when a tab is not focused and Backdrop images are enabled + Resolves an issue that causes backgrounds to be added to the DOM endlesslly when a tab is not focused and Backdrop images are enabled - **Infinite Scroll**: - Adds infinite scrolling to the Movies and TV library pages. Loads batches of 100 items at a time and supports filtering and sorting. + Adds infinite scrolling to the Movies and TV library pages. Loads batches of 100 items at a time and supports filtering and sorting. - **LocalStorage Caching**: - Local caching for data related to Watchlist, Series Progress, Movie History and Top People + Local caching for data related to Watchlist, Series Progress, Movie History and Top People - **Dashboard Button Fix**: - Fix to handle navigating back from the dashboard page to the homescreen instead of the "new tab" page of a browser + Fix to handle navigating back from the dashboard page to the homescreen instead of the "new tab" page of a browser + +## **Branding & Customization** -#### **Branding & Customization** - **Exclusive Elsewhere**: -Custom branding for items which aren't available on any external streaming providers. Requires [JellyfinEnhanced](https://github.com/n00bcodr/Jellyfin-Enhanced). + Custom branding for items which aren't available on any external streaming providers. Requires [JellyfinEnhanced](https://github.com/n00bcodr/Jellyfin-Enhanced). - **Ratings and Comments**: -Coming soon...requires the [Updoot](https://github.com/BobHasNoSoul/jellyfin-updoot) backend script -
+Coming soon...requires the [Updoot](https://github.com/BobHasNoSoul/jellyfin-updoot) backend script +  - -## Script Details - -### Dependency Scripts - -| Script | Description | -|--------|-------------| -| `utils.js` | Page view management and common utilities | -| `skinConfig.js` | Default skin configuration for KefinTweaks | -| `apiHelper.js` | API helper functions for common Jellyfin operations | -| `cardBuilder.js` | Core card building functionality (required by other scripts) | -| `localStorageCache.js` | localStorage-based caching layer with 24-hour TTL and manual refresh | -| `indexedDBCache.js` | IndexedDB-based caching layer for large datasets with TTL support | -| `modal.js` | Generic modal system for Jellyfin-style dialogs | -| `toaster.js` | Toast notification system using Jellyfin's existing toast functionality | - -### Feature Scripts - -| Script | Description | Dependencies | -|--------|-------------|--------------| -| `skinManager.js` | Skin selection and management (loads with priority) | `utils`, `skinConfigLegacyDefaults`, `skinConfig`, `modal` | -| `watchlist.js` | Adds watchlist functionality throughout Jellyfin interface | `cardBuilder`, `localStorageCache`, `modal`, `utils` | -| `homeScreen.js` | Adds custom home screen sections | `cardBuilder`, `localStorageCache`, `utils` | -| `search.js` | Enhanced search functionality | `cardBuilder`, `utils` | -| `headerTabs.js` | Header tab improvements | None | -| `customMenuLinks.js` | Load and add custom menu links from configuration | `utils` | -| `exclusiveElsewhere.js` | Modifies the behavior of the Jellyfin Enhanced Elsewhere functionality to add custom branding when items are not available on streaming services | None | -| `updoot.js` | Upvote functionality provided by https://github.com/BobHasNoSoul/jellyfin-updoot | None | -| `backdropLeakFix.js` | Fixes issue that causes backdrop images to be continuously added to the page if the tab isn't focused | None | -| `dashboardButtonFix.js` | Fixes the dashboard button to redirect to the home page when the back button is clicked and there is no history to go back to | None | -| `infiniteScroll.js` | Adds infinite scroll functionality to media library pages | `cardBuilder` | -| `removeContinue.js` | Adds remove from continue watching functionality to cards with data-position-ticks | None | -| `subtitleSearch.js` | Adds subtitle search functionality to the video OSD, allowing users to search and download subtitles from remote sources | `toaster` | -| `breadcrumbs.js` | Adds breadcrumb navigation to item detail pages for Movies, Series, Seasons, Episodes, Music Artists, and Music Albums | `utils` | -| `playlist.js` | Modifies playlist view page behavior to navigate to item details instead of playing, adds play button to playlist items, and adds sorting functionality | `cardBuilder`, `utils`, `modal` | -| `itemDetailsCollections.js` | Adds related collections to item details pages showing which collections contain the current item | `indexedDBCache`, `utils`, `cardBuilder` | -| `seriesEpisodes.js` | Displays episodes directly on series page with season selection. Works for both single and multi-season shows when enabled. | `cardBuilder`, `utils` | -| `seriesInfo.js` | Adds series and season information (seasons count, episodes count, end time) to details pages | `utils` | -| `collections.js` | Adds sorting functionality to collection pages | `utils`, `modal` | -
- -### Auto-Inject Dependencies - -KefinTweaks automatically enables required dependencies when you enable a script that needs them. For example: - -- Enabling `watchlist` automatically enables `cardBuilder`, `localStorageCache`, `modal`, and `utils` - -This ensures all scripts have their required dependencies without manual configuration. - -### Info for Developers +## 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: @@ -512,7 +707,7 @@ The KefinTweaks Watchlist leverages the existing Jellyfin Item UserData field: " - 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 @@ -525,8 +720,14 @@ Here is a list of known supported clients: - [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) -
+- [Jellyfin for Tizen](https://github.com/jellyfin/jellyfin-tizen)* (maybe) + +These third party clients support the same Watchlist as KefinTweaks: + +- [AFinity by MakD](https://github.com/MakD/AFinity) +- [Streamyfin](https://github.com/streamyfin/streamyfin) +- [Pelagica](https://github.com/KartoffelChipss/pelagica) +  ## Changes to default Jellyfin functionality @@ -550,29 +751,25 @@ A few things to note about KefinTweaks functionality in general and how it inter ### What functionality is modified? - Infinite Scroll for Library Pages - - Pagination controls are hidden in favor of infinite scrolling + - 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 - - The Playlist Play button now resumes the Playlist by default instead of playing it from the beginning - - A new button is added to play the Playlist from the beginning + - 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 + - The Playlist Play button now resumes the Playlist by default instead of playing it from the beginning + - A new button is added to play the Playlist from the beginning - Episodes of Series Page - - Episodes from the Season of the Next Up episode are displayed in a section with the Next Up item being focused - - The default Jellyfin "Next Up" section is hidden in favor of the above - - With the Flatten Single Season Shows enabled, the normal Seasons section is hidden for shows with only 1 season + - Episodes from the Season of the Next Up episode are displayed in a section with the Next Up item being focused + - The default Jellyfin "Next Up" section is hidden in favor of the above + - With the Flatten Single Season Shows enabled, the normal Seasons section is hidden for shows with only 1 season - 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? - + - 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 - Search - - The default Jellyfin search is hidden in favor of the KefinTweaks search - - KefinTweaks does not block default Jellyfin search requests, but they will only be triggered when manually navigating to the search page with a searchTerm in the url. In this circumstance, the duplicate search results are hidden. + - The default Jellyfin search is hidden in favor of the KefinTweaks search + - KefinTweaks does not block default Jellyfin search requests, but they will only be triggered when manually navigating to the search page with a searchTerm in the url. In this circumstance, the duplicate search results are hidden. - Dashboard Button Fix - - This fix prevents the Back button in the Dashboad from navigating back to the "new tab" page in your browser -
+ - This fix prevents the Back button in the Dashboad from navigating back to the "new tab" page in your browser +  ## Design Philosophy @@ -580,8 +777,8 @@ The general design philosophy here is to utilize as much pre-existing Jellyfin f 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. -
+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 @@ -591,12 +788,13 @@ I have started this project with a polar opposite approach from my past endeavor 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. - +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 @@ -604,48 +802,68 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - **[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 environment 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 -- **[joshmcrty](https://github.com/joshmcrty)**: For the [Flurry](https://github.com/joshmcrty/Flurry) jQuery plugin +- **[freefrontend.com](https://freefrontend.com/css-frames/)**: For the custom card border styles and ideas. +- **[Unsplash](https://unsplash.com/) and [Pexels](https://www.pexels.com/): For the images related to the Thematic Picks home sections** +  ## FAQ -> ### I saved the KefinTweaks Plugin script in JS Injector but I don't see KefinTweaks in the Dashboard menu +> ### I saved the KefinTweaks Plugin script in JS Injector but I don't see KefinTweaks in the Dashboard menu You must complete the installation by clicking the KefinTweaks plugin and selecting the installation source (either GitHub or local) from the KefinTweaks plugin card at the very bottom of your Plugins page: -
- jsinjector publicjs -

+| Check for pending installation | +| :---: | +| ![JS Injector publicjs](pages/images/faq/pending.png) | +> ### KefinTweaks Plugin never appears in the Plugins page even though I saved the KefinTweaks Plugin script in JS Injector -> ### KefinTweaks Plugin never appears in the Plugins page even though I saved the KefinTweaks Plugin script in JS Injector +Ensure `JavaScriptInjector\public.js` is loaded by the client: -1. Ensure `JavaScriptInjector\public.js` is loaded by the client: - - Open DevTools (right-click page > inspect) - - Click Sources tab -
- jsinjector publicjs -
+- Open DevTools (right-click page > inspect) +- Click Sources tab +- Verify that `JavaScriptInjector\public.js` is present: + +| Check for public.js injection | +| :---: | +| ![JS Injector publicjs](pages/images/faq/publicjs.png) | + +If you don't see the public.js from JS Injector, please ensure that you have [File Transformation plugin](https://github.com/IAmParadox27/jellyfin-plugin-file-transformation) installed. + +If you do see the public.js from JS Injector, this may be an issue related to the browser cache. Follow the instructions below to ensure your browser cache is up to date. + +> ### My Watchlist Tab is empty + +If you see the tab button for Watchlist, but clicking it brings you to an empty page, this is almost certainly due to a caching issue. Please follow the instructions below to ensure your browser cache is up to date. + +> ### How do I clear my client cache? + +You may sometimes end up in a situation where the jellyfin-web client is not correctly reflecting your KefinTweaks configuration. To ensure KefinTweaks is delivering its scripts based on your current configuration, you may need to perform a "hard refresh" which will bypass the cache: + +- Use "Ctrl+F5" or "Ctrl+Shift+F5" to refresh the page, or: +- Open Devtools (right-click page > inspect) +- Click the Network tab +- Check the "Disable Cache" checkbox +- Refresh your page while keeping the Devtools window open + +| Disable Browser Cache | +| :---: | +| ![Disable Browser Cache](pages/images/faq/disablecache.png) | ## Support - **Issues**: -KefinTweaks is not maintained by the Jellyfin team, and as a result you are encouraged to seek support from me directly. Sadly, there is no place suitable for discussion of plugins built by community members or fan-made projects in the official Jellyfin Discord, so please visit the [Jellyfin Community Discord](https://discord.gg/v7P9CAvCKZhttps://discord.gg/v7P9CAvCKZ) to find me (username: HighImKevin) and other users who would be happy to help you out. Please also feel free to report bugs and request features from the Issues page. + KefinTweaks is not maintained by the Jellyfin team, and as a result you are encouraged to seek support from me directly. Sadly, there is no place suitable for discussion of plugins built by community members or fan-made projects in the official Jellyfin Discord, so please visit the [Jellyfin Community Discord](https://discord.gg/v7P9CAvCKZhttps://discord.gg/v7P9CAvCKZ) to find me (username: HighImKevin) and other users who would be happy to help you out. Please also feel free to report bugs and request features from the Issues page. - **Documentation**: This README and the inline comments in kefinTweaks.js and the other scripts is the only real documentation available at this time -
+  -## Roadmap +## Version History -### Planned Features -- **Jellyfin 10.11 Support** -- **Additional options for User configuration in the Jellyfin UI** - -
- -### Version History +- **v0.5.0**: New Features: Home Screen Overhaul, Stats Overhaul, Thumbnail Scrubber, Watch Together, User Manager, Version Preferences, Library Mini-games - **v0.4.0**: New Features: Proper Install Flow, Watchlist->Playlist Sync, Series Info+, Series Page Episodes, Christmas Seasonal Home Screen - **v0.3.5**: Critical bug fix - **v0.3.4**: Improved handling of Latest/Development branches for KefinTweaks installation. Improved collection caching. diff --git a/configuration/customMenuLinks-configuration.js b/configuration/customMenuLinks-configuration.js new file mode 100644 index 0000000..d1cb489 --- /dev/null +++ b/configuration/customMenuLinks-configuration.js @@ -0,0 +1,200 @@ +// KefinTweaks Custom Menu Links Configuration UI +// Modular configuration modal for managing custom menu links + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks CustomMenuLinks Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks CustomMenuLinks Config]', ...args); + + const MODAL_ID = 'kefin-custommenulinks-config'; + + /** + * Load configuration from window.KefinTweaksConfig + */ + async function loadConfig() { + const config = window.KefinTweaksConfig || {}; + return { + customMenuLinks: Array.isArray(config.customMenuLinks) ? config.customMenuLinks : [] + }; + } + + /** + * Save configuration to JS Injector + */ + async function saveConfig(customMenuLinks) { + try { + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + + window.KefinTweaksConfig.customMenuLinks = customMenuLinks; + // Placement is per-link; do not keep the legacy global top-nav flag + delete window.KefinTweaksConfig.showCustomMenuLinksInTopNav; + + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } else if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } else { + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + /** + * Build configuration HTML + */ + function buildConfigHTML(config) { + return ` +
+
+
+
Custom Menu Links JSON
+
Add custom menu links with per-link placements. Require name and either url or action (a dotted path on window, e.g. MyPlugin.openDialog). Defaults: sideMenu true, topNavigation "none", userMenu false. Use collapsed with topNavigation: "main" on v12 to place under More. Optional order overrides top-nav position: for main, left-to-right child index (0 = first); for right, right-to-left (0 = last child).
+
These links are not added by modifying your config.json, so if you wish for these to appear on supported third party clients which don't use jellyfin-web, you will want to manually edit your config.json rather than adding the links here. You can find the information about doing that here.
+ +
+ View Example Format +
[
+  {
+    "name": "My Custom Link",
+    "icon": "link",
+    "url": "#/userpluginsettings.html?pageUrl=https://domain.com/custom-page",
+    "openInNewTab": false,
+    "sideMenu": true,
+    "topNavigation": "none",
+    "userMenu": false,
+    "collapsed": false
+  },
+  {
+    "name": "Top Nav First",
+    "icon": "star",
+    "url": "#/home",
+    "sideMenu": false,
+    "topNavigation": "main",
+    "order": 0
+  },
+  {
+    "name": "Top Nav Link",
+    "icon": "star",
+    "url": "#/home",
+    "sideMenu": false,
+    "topNavigation": "main",
+    "collapsed": true
+  },
+  {
+    "name": "Header Right Near End",
+    "icon": "extension",
+    "action": "MyPlugin.openDialog",
+    "sideMenu": false,
+    "topNavigation": "right",
+    "order": 1
+  },
+  {
+    "name": "External Link",
+    "icon": "open_in_new",
+    "url": "https://example.com",
+    "openInNewTab": true,
+    "sideMenu": true,
+    "topNavigation": "none"
+  }
+]
+
+
+
+
+ `; + } + + /** + * Collect configuration from form + */ + function collectConfig() { + try { + const jsonText = document.getElementById('customMenuLinksJson')?.value || '[]'; + const customMenuLinks = JSON.parse(jsonText); + return { customMenuLinks }; + } catch (e) { + ERR('Error parsing custom menu links JSON:', e); + alert('Invalid JSON format. Please check your input.'); + return null; + } + } + + /** + * Open configuration modal + */ + async function openConfigModal() { + try { + const config = await loadConfig(); + + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + window.ModalSystem.create({ + id: MODAL_ID, + title: 'Custom Menu Links Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '1400px', height: '90vh' } + : undefined, + onOpen: (modal) => { + const saveBtn = modal.dialogFooter.querySelector('#save-custommenulinks-config-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const collected = collectConfig(); + if (!collected) return; + const success = await saveConfig(collected.customMenuLinks); + if (success) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Custom Menu Links configuration saved!'); + } + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + } + }); + + LOG('Custom Menu Links configuration modal opened'); + } catch (error) { + ERR('Error opening custom menu links config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.customMenuLinks = { openConfigModal }; + + console.log('[KefinTweaks CustomMenuLinks Config] Script loaded'); + +})(); diff --git a/configuration/homeScreen-configuration.css b/configuration/homeScreen-configuration.css new file mode 100644 index 0000000..8ef1741 --- /dev/null +++ b/configuration/homeScreen-configuration.css @@ -0,0 +1,403 @@ +[data-modal-id="kefin-homescreen-config-main"] .config-tab-content details { + max-height: calc(90vh - 19em); + overflow: auto; +} + +[data-modal-id="kefin-homescreen-config-main"] .config-tab-nav { + display: flex; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 1em; + flex-wrap: wrap; + gap: 0.25em; +} + +[data-modal-id="kefin-homescreen-config-main"] .config-tab-btn { + display: inline-flex; + align-items: center; + gap: 0.4em; + padding: 0.75em 1.25em; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-weight: 500; +} + +[data-modal-id="kefin-homescreen-config-main"] .config-tab-btn.active { + border-bottom-color: var(--theme-primary-color, #00a4dc); + color: var(--theme-primary-color, #00a4dc); +} + +[data-modal-id="kefin-homescreen-config-main"] .config-tab-icon { + font-size: 1.25em; + line-height: 1; +} + +[data-modal-id="kefin-homescreen-config-main"] #communityCollectionsGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1em; + max-height: 600px; + overflow-y: auto; + padding-right: 0.5em; +} + +.layout-mobile [data-modal-id="kefin-homescreen-config-main"] .config-tab-label { + display: none; +} + +.layout-mobile [data-modal-id="kefin-homescreen-config-main"] .config-tab-btn { + padding: 0.75em; +} + +[data-type="Collections"], +[data-type="Playlists"], +[data-type="Generic Parent"] { + display: none; +} +[data-parent-item-type="Collection"] [data-type="Collections"] { + display: block; +} +[data-parent-item-type="Playlist"] [data-type="Playlists"] { + display: block; +} +[data-parent-item-type="Generic Parent"] [data-type="Generic Parent"] { + display: block; +} + +[data-modal-id="kefin-homescreen-config-main"] details[open] summary.listItemBodyText::after { + content: ""; +} + +[data-modal-id="kefin-homescreen-config-main"] #section-content { + padding-right: 1em; +} + +[data-modal-id="kefin-homescreen-config-main"] div#section-content, +[data-modal-id="kefin-homescreen-config-main"] div#global-settings-content, +[data-modal-id="kefin-homescreen-config-main"] #home-screen-benchmark-root { + max-height: calc(90vh - 19em); + overflow: auto; + scrollbar-width: none; +} + +[data-modal-id="kefin-homescreen-config-main"] div#global-settings-content > *:last-child, +[data-modal-id="kefin-homescreen-config-main"] #home-screen-benchmark-root > *:last-child, +[data-modal-id="kefin-homescreen-config-main"] #section-content > div > *:last-child { + margin-bottom: 4em !important; +} + +div#order-sections-container .editable-group-name { + pointer-events: none; +} + +.section-group[data-section-type-seasonal="false"] .section-row[data-section-type="Seasonal"] { + display: none !important; +} +.section-group[data-section-type-default="false"] .section-row[data-section-type="Default"] { + display: none !important; +} +.section-group[data-section-type-custom="false"] .section-row[data-section-type="Custom"] { + display: none !important; +} + +div#discovery-sections-list .section-preview-btn { + display: none; +} + +div#order-sections-container .section-group .group-sections-list { + max-height: calc(90vh - 25em); + overflow: auto; + margin-bottom: -1em; +} + +span.listItemBodyText.secondary.discovery-tag { + background: #3f51b5; + padding: 2px 5px; +} +span.listItemBodyText.secondary.spotlight-tag { + background: #501515; + padding: 2px 5px; +} +span.listItemBodyText.secondary.seasonal-tag { + background: #3b5562; + padding: 2px 5px; +} + +.discovery-tag, +.spotlight-tag, +.seasonal-tag { + color: rgba(255, 255, 255, 0.9) !important; + border-radius: 5px; +} + +.homeScreenSettingsContainer form { + display: flex; + flex-direction: column; +} + +.homeScreenSettingsContainer form > * { + order: 2; +} + +.homeScreenSettingsContainer #kefin-user-home-sections-editor { + order: 1; +} + +.kefin-restore-defaults-btn .restoreBtn::after { + content: "Restore Defaults"; +} + +/* Split panes: side nav on desktop, horizontal nav on mobile */ +[data-modal-id="kefin-homescreen-config-main"] .hsc-split-pane { + display: grid; + grid-template-columns: 200px 1fr; + gap: 1.5em; +} + +.layout-mobile [data-modal-id="kefin-homescreen-config-main"] .hsc-split-pane, +[data-modal-id="kefin-homescreen-config-main"] .hsc-split-pane.hsc-split-pane-narrow { + grid-template-columns: 1fr; + gap: 1em; +} + +.layout-mobile [data-modal-id="kefin-homescreen-config-main"] #global-settings-navigation > div, +.layout-mobile [data-modal-id="kefin-homescreen-config-main"] #section-navigation > div { + display: flex !important; + flex-direction: row !important; + flex-wrap: wrap; + gap: 0.5em; + max-height: none; + overflow-x: auto; +} + +.layout-mobile [data-modal-id="kefin-homescreen-config-main"] #global-settings-navigation .global-settings-nav-btn, +.layout-mobile [data-modal-id="kefin-homescreen-config-main"] #section-navigation .section-type-nav-btn { + flex: 0 0 auto; + white-space: nowrap; +} + +@media (max-width: 899px) { + [data-modal-id="kefin-homescreen-config-main"] .hsc-split-pane { + grid-template-columns: 1fr; + gap: 1em; + } + + [data-modal-id="kefin-homescreen-config-main"] #global-settings-navigation > div, + [data-modal-id="kefin-homescreen-config-main"] #section-navigation > div { + display: flex !important; + flex-direction: row !important; + flex-wrap: wrap; + gap: 0.5em; + } + + [data-modal-id="kefin-homescreen-config-main"] #global-settings-navigation .global-settings-nav-btn, + [data-modal-id="kefin-homescreen-config-main"] #section-navigation .section-type-nav-btn { + flex: 0 0 auto; + white-space: nowrap; + } + + [data-modal-id="kefin-homescreen-config-main"] .config-tab-label { + display: none; + } + + [data-modal-id="kefin-homescreen-config-main"] .config-tab-btn { + padding: 0.75em; + } +} + +/* .homeScreenSettingsContainer:has(#kefin-user-home-sections-editor) form>div:first-child > *:not(.verticalSection-extrabottompadding) { + display: none; +} + +.homeScreenSettingsContainer:has(#kefin-user-home-sections-editor) form>div:first-child { + margin-bottom: 0; +} + +.listItem.section-row:has(.discovery-tag) { + box-shadow: black inset 1px 1px 2px 1px; +} */ + +.homeScreenSettingsContainer:has(#kefin-user-home-sections-editor) .selectContainer { + display: none; +} + +/* Global Settings panel layouts */ +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-panel { + display: flex; + flex-direction: column; + gap: 1em; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-desc { + font-size: 0.9em; + line-height: 1.5; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-desc p { + margin: 0 0 0.75em; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-desc p:last-child { + margin-bottom: 0; +} + +.hsc-settings-grid { + display: grid; + gap: 0.75em; + align-items: stretch; +} + +.hsc-settings-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: 1fr; +} + +.hsc-settings-grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.hsc-settings-span-2 { + grid-column: 1 / -1; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-grid { + display: grid; + gap: 0.75em 1.25em; + margin-top: 0.75em; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-spotlight-inner > .hsae-field-grid:first-child, +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-spotlight-inner > .hsae-toggle-card-grid:first-child { + margin-top: 0; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-grid-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-toggle-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75em; + margin-top: 0.75em; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-wrap { + min-width: 0; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-wrap .fld, +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-wrap select { + width: 100%; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-query-divider { + height: 1px; + margin: 1em 0; + background: rgba(255, 255, 255, 0.1); + border: 0; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-spotlight-backdrops-row { + margin-top: 0.75em; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-spotlight-animations { + display: grid; + gap: 0.75em 1.25em; + grid-template-columns: minmax(0, 1fr); + margin-top: 0.75em; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-spotlight-anim-group { + min-width: 0; +} + +[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-spotlight-row-autoplay { + margin-top: 0.75em; + align-items: end; +} + +@media (max-width: 899px) { + [data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-grid-4 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + [data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-grid-3 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .hsc-config-toolbar button > span:nth-child(2) { + display: none; + } + .hsc-config-toolbar button > span { + margin: 0 !important; + } +} + +@media (max-width: 640px) { + [data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-grid-2, + [data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-grid-3, + [data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-grid-2, + [data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-grid-4, + [data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-toggle-card-grid { + grid-template-columns: 1fr; + } +} + +[data-modal-id="kefin-homescreen-config-main"] .hsc-settings-span-2 { + display: flex; +} + +[data-modal-id="kefin-homescreen-config-main"] .hsc-settings-grid .hsc-settings-span-2 .kefin-toggle-card { + flex: 1; +} + +[data-modal-id="kefin-homescreen-config-main"] .kefin-settings-input-card { + align-items: center; +} + +[data-modal-id="kefin-homescreen-config-main"] .kefin-settings-input-card-control { + flex-shrink: 0; + width: 7.5em; +} + +[data-modal-id="kefin-homescreen-config-main"] .kefin-settings-input-card-control .emby-input { + width: 100%; + box-sizing: border-box; +} + +.section-row-actions .btnSectionConfigure { + display: none; +} + +.dialogContainer[data-modal-id="kefin-homescreen-config-main"] .formDialogHeader + div > div { + padding: 1.25em 1.5em; + height: calc(100% - 2.5em); +} +.dialogContainer[data-modal-id="kefin-homescreen-config-main"] .formDialogHeader + div { + padding: 0 !important; + overflow-y: hidden !important; +} +.dialogContainer[data-modal-id="kefin-homescreen-config-main"] .formDialogHeader + div > div > div #section-content > div { + padding-bottom: 4em; +} +.config-tab-nav button[data-tab="order"] { + display: none; +} + + +.listItem[data-section-type="discovery"]:not([data-section-id^="custom"]) .section-row-title-line, +.listItem[data-section-type="discovery"]:not([data-section-id^="custom"]) .section-row-caption { + display: none !important; +} + +.listItem[data-section-type="discovery"]:not([data-section-id^="custom"]) .section-row-description { + font-size: 1em !important; + margin-top: 0 !important; +} diff --git a/configuration/homeScreen-configuration.js b/configuration/homeScreen-configuration.js new file mode 100644 index 0000000..6096570 --- /dev/null +++ b/configuration/homeScreen-configuration.js @@ -0,0 +1,6235 @@ +// KefinTweaks Home Screen Configuration UI +// Modular configuration modal for managing home screen sections +// Uses the new HomeScreenSection format with queries array + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks HomeScreen Config]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks HomeScreen Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks HomeScreen Config]', ...args); + + // Modal IDs + const MAIN_MODAL_ID = 'kefin-homescreen-config-main'; + const PREVIEW_DEFAULT_LIMIT = window.KefinHomeScreenEditorConstants?.DEFAULT_SECTION_QUERY_LIMIT ?? 16; + + function withPreviewQueryOptions(query) { + const queryOptions = { ...(query?.queryOptions || {}) }; + if (queryOptions.Limit == null || queryOptions.Limit === '') { + queryOptions.Limit = PREVIEW_DEFAULT_LIMIT; + } + return { ...query, queryOptions }; + } + + function se() { return window.KefinHomeScreenSectionEditor || {}; } + function buildCheckbox(id, checked, label) { return se().buildCheckbox?.(id, checked, label) || ''; } + function buildSelect(id, options, selectedValue, label) { return se().buildSelect?.(id, options, selectedValue, label) || ''; } + function buildTextInput(id, value, label, type, placeholder) { return se().buildTextInput?.(id, value, label, type, placeholder) || ''; } + function buildToggleSlider(id, checked, label, options) { return se().buildToggleSlider?.(id, checked, label, options) || ''; } + function buildToggleCard(id, checked, label, description, options) { + return se().buildToggleCard?.(id, checked, label, description, options) || ''; + } + function updateToggleSliderUI(btn, checked) { return se().updateToggleSliderUI?.(btn, checked); } + function updateToggleSwitchUI(btn, checked) { return se().updateToggleSwitchUI?.(btn, checked); } + const SORT_ORDERS = (window.KefinHomeScreenEditorConstants?.SORT_ORDERS) || ['Default', 'Random', 'Name']; + const CARD_FORMATS = (window.KefinHomeScreenEditorConstants?.CARD_FORMATS) || ['Poster']; + + const GENERAL_TOGGLE_DESCRIPTIONS = { + 'userHome-inlineConfigure': { + on: 'Allows Home Screen sections to be configured directly from the Home Screen.', + off: 'Sections can only be configured from the User > Home preferences page.' + }, + 'userHome-pinning': { + on: 'Allows users to create pinned sections on their Home Screen from library items by adding a "Pin To Home" button in the item context menu.', + off: 'Users cannot pin items to their Home Screen.' + }, + 'userHome-pairNativeHomeSections': { + on: 'Sections with a matching Native Jellyfin section are automatically kept in sync with your KefinTweaks Home Screen.', + off: 'KefinTweaks will disable all native Jellyfin home screen sections in the native Jellyfin User Preferences. When pairing is disabled, the official Jellyfin Android TV and Roku clients will have no sections rendered on the home screen. You should only disable this when you do not use either of those clients. Disabling will have a performance benefit when loading the home screen.' + }, + 'home-fadeInSections': { + on: 'Sections will be hidden when they are rendered and will fade up and in as they enter the window when you scroll.', + off: 'Sections will be rendered normally without any fade in animation.' + }, + 'home-showStaleDataBeforeRefresh': { + on: 'Sections will show stale items before being refreshed and existing items will be updated in place when new data is ready.', + off: 'Sections will show loading skeleton cards whenever section data is stale.' + }, + 'seasonal-enableSeasonalAnimations': { + on: 'Animated seasonal overlays will appear in the UI based on the seasonal sections that are enabled.', + off: 'No animated seasonal overlays will appear in the UI regardless of which seasonal sections are enabled.' + }, + 'seasonal-enableSeasonalBackground': { + on: 'The Home Screen background will be replaced by an appropriate Seasonal Background during the corresponding season.', + off: 'Seasonal Backgrounds will not appear at all.' + }, + 'home-ensureThumbsForPopularTVNetworks': { + on: 'Only Studios with a valid image will be rendered in Popular Studios sections.', + off: 'Studios without any image may be rendered in Popular Studios sections. You can enable the Optional CSS Module "Studio Thumbnails" to overlay a background with the Studio Name on items without an image.' + }, + 'home-loadPeopleEpisodeData': { + on: 'This is NOT recommended for larger libraries. Loads all Person appearances from all episodes in your library. This data takes much longer to cache.', + off: 'This is recommended to remain disabled. Person appearance data is limited to only Movies and Series.' + }, + 'home-showCreateSectionButtonOnHome': { + on: 'Adds a button to the bottom left corner of the home screen so that you can quickly create a new section directly from the Home Screen. Only admins will see this button. The button is hidden on mobile.', + off: 'You must use the KefinTweaks Configuration > Home Screen > New Section flow to create new Home Screen sections.' + }, + 'home-showCategoryFilters': { + on: 'Category Filters will appear on the Home Screen to quickly navigate between Discovery, Pinned or any custom Category sections.', + off: 'Category Filters will not appear on the Home Screen.' + }, + 'home-renderCategoriesSeparately': { + on: 'Sections will only appear when their defined category is selected on the Home Screen. Discovery sections will only load when the Discovery category is selected.', + off: 'All sections will be visible when the default Home category is selected. Discovery sections will render at the bottom of the Home and Discovery categories.' + } + }; + + const DISCOVERY_TOGGLE_DESCRIPTIONS = { + 'discovery-enabled': { + on: 'Discovery Sections will be generated per-user and rendered dynamically as the user scrolls down the Home Screen.', + off: 'No Discovery Sections will be generated.' + }, + 'discovery-infiniteScroll': { + on: 'Discovery Sections will render automatically when the user scrolls near the bottom of the page (one group per scroll; scroll again for more).', + off: 'Show “Discover More” at the bottom. Scroll again while at the bottom to load the next discovery group.' + }, + 'discovery-renderSpotlightAboveMatching': { + on: 'Top Rated Genre/Studio and regular Genre/Studio sections will appear next to each other', + off: 'Top Rated Genre/Studio and regular Genre/Studio sections will not necessarily appear next to each other' + }, + 'discovery-randomizeOrder': { + on: 'Discovery Sections will be rendered in a random order.', + off: 'Discovery Sections will always appear in the same order.' + }, + 'discovery-fadeInSections': { + on: 'Sections will be hidden when they are rendered and will fade up and in as they enter the window when you scroll.', + off: 'Sections will be rendered normally without any fade in animation.' + } + }; + + function toggleDesc(map, id, checked) { + const entry = map[id]; + if (!entry) return ''; + return checked ? entry.on : entry.off; + } + + function hscToggleCard(id, checked, label, descMap) { + return buildToggleCard(id, checked, label, toggleDesc(descMap, id, checked), { hintKey: id }); + } + + function updateHscToggleCardHints(root, descMap) { + if (!root || !descMap) return; + Object.keys(descMap).forEach((id) => { + const checkbox = root.querySelector('#' + id); + const descEl = checkbox?.closest('.kefin-toggle-card')?.querySelector('.kefin-toggle-card-desc'); + if (!descEl || !checkbox) return; + descEl.textContent = toggleDesc(descMap, id, checkbox.checked); + }); + } // Current config state + let currentConfig = null; + let mainModalInstance = null; + let currentActiveTab = 'sections'; // Track the currently active tab + let currentGlobalSettingsSubTab = 'general'; // Track active sub-tab in Global Settings (general, spotlight, discovery, cache) + let globalSettingsSaveTimer = null; + const UPDATE_ALL_CONFIRM_MODAL_ID = 'kefin-homescreen-config-update-all-confirm'; + const RESTORE_DEFAULTS_MODAL_ID = 'kefin-homescreen-config-restore-defaults'; + const COMMUNITY_IMPORT_MODAL_ID = 'kefin-homescreen-community-import'; + const MAX_PEOPLE_COUNT_OPTIONS = [100, 250, 500, 1000, 2500, 5000]; + const RESTORE_RETAIN_TOGGLE_DESCRIPTIONS = { + 'restore-retain-status': { + on: 'Current Default Status and User Configuration values will be unchanged.', + off: 'Default Status and User Configuration will be restored to their defaults.' + }, + 'restore-retain-name-caption': { + on: 'Current Section Name and Caption values will be unchanged.', + off: 'Section Name and Caption will be restored to their defaults.' + }, + 'restore-retain-appearance': { + on: 'Current Section Appearance settings will be unchanged.', + off: 'Section Appearance settings will be restored to their defaults.' + }, + 'restore-retain-advanced': { + on: 'Current Section Advanced Options will be unchanged.', + off: 'Section Advanced Options will be restored to their defaults.' + }, + 'restore-retain-customs': { + on: 'Current Custom Sections will be unchanged.', + off: 'All custom sections will be removed.' + } + }; + const RESTORE_APPEARANCE_KEYS = [ + 'renderMode', 'cardFormat', 'spotlight', 'spotlightConfig', + 'borderStyle', 'borderColor', + 'hideCardTitles', 'cardTitleVisibility', + 'cardTitlePosition', 'cardTitleCapitalization', 'cardTitleFontFamily', + 'cardTitleFontSize', 'cardTitleColor', + 'itemsLayout', 'useGaplessCards', 'sectionCssClass', 'hideName' + ]; + const RESTORE_ADVANCED_KEYS = [ + 'ttl', 'sortBy', 'sortOrder', 'sortOrderDirection', + 'minimumItems', 'itemLimit', 'flattenSeries', 'limitBeforeSort', 'hideWatched' + ]; + const TTL_UNIT_OPTIONS = [ + { value: 'seconds', ms: 1000 }, + { value: 'minutes', ms: 60000 }, + { value: 'hours', ms: 3600000 }, + { value: 'days', ms: 86400000 } + ]; + + function normalizeActiveTab(tab) { + if (tab === 'community') return 'import-export'; + if (tab === 'benchmark') return 'troubleshoot'; + return tab || 'sections'; + } + + /** + * Show toast notification + */ + function showToast(message, duration = '3') { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast(message, duration); + } else { + // Fallback to alert if toaster not available + alert(message); + } + } + + /** + * Flatten section groups into a flat array of sections + * @param {Array} groups - Array of HomeScreenSectionGroup objects + * @returns {Array} Flat array of all sections + */ + function flattenSectionGroups(groups) { + if (!Array.isArray(groups)) return []; + const flattened = []; + groups.forEach(group => { + if (group && Array.isArray(group.sections)) { + flattened.push(...group.sections); + } + }); + return flattened; + } + + const POPULAR_STUDIO_SECTION_IDS = new Set(['popularTVStudios', 'popularMovieStudios', 'popularStudios']); + + /** + * Sync HOME_SETTINGS.ensureThumbsForPopularTVNetworks onto popular studio section queryOptions. + * When on: ImageTypes ['Thumb'] (API + postProcessItems filter). When off: remove ImageTypes. + */ + function syncEnsureThumbsOnPopularStudios(config) { + if (!config) return; + const ensureThumbs = config.HOME_SETTINGS?.ensureThumbsForPopularTVNetworks === true; + const groupKeys = ['HOME_SECTION_GROUPS', 'SEASONAL_SECTION_GROUPS', 'CUSTOM_SECTION_GROUPS', 'DISCOVERY_SECTION_GROUPS']; + for (const key of groupKeys) { + const sections = flattenSectionGroups(config[key] || []); + for (const section of sections) { + if (!section || !POPULAR_STUDIO_SECTION_IDS.has(section.id) || !Array.isArray(section.queries)) continue; + for (const query of section.queries) { + if (!query) continue; + if (ensureThumbs) { + query.queryOptions = { ...(query.queryOptions || {}), ImageTypes: ['Thumb'] }; + } else if (query.queryOptions && Object.prototype.hasOwnProperty.call(query.queryOptions, 'ImageTypes')) { + const { ImageTypes, ...rest } = query.queryOptions; + query.queryOptions = rest; + } + } + } + } + } + + /** + * Whether today falls in an MM-DD (or M-D) seasonal window. + * Supports year-wrap when end < start (e.g. 12-20 … 01-05). + * @param {string} startDate + * @param {string} endDate + * @returns {boolean} + */ + function isInSeasonalPeriod(startDate, endDate) { + if (startDate == null || endDate == null || startDate === '' || endDate === '') { + return false; + } + const parseMonthDay = (value) => { + const parts = String(value).trim().split(/[-/.]/).map((p) => parseInt(p, 10)); + if (parts.length < 2 || parts.some((n) => !Number.isFinite(n))) return null; + let month; + let day; + // YYYY-MM-DD → take month/day + if (parts.length >= 3 && parts[0] > 31) { + month = parts[1]; + day = parts[2]; + } else { + month = parts[0]; + day = parts[1]; + } + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + return { month, day }; + }; + const startMd = parseMonthDay(startDate); + const endMd = parseMonthDay(endDate); + if (!startMd || !endMd) return false; + + const now = new Date(); + const year = now.getFullYear(); + const start = new Date(year, startMd.month - 1, startMd.day, 0, 0, 0, 0); + let end = new Date(year, endMd.month - 1, endMd.day, 23, 59, 59, 999); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return false; + + if (end < start) { + if (now >= start) { + end.setFullYear(year + 1); + } else { + start.setFullYear(year - 1); + } + } + return now >= start && now <= end; + } + + /** + * Collect discovery-enabled sections from custom groups, stamping pageNumber + * per group (1..N among discovery sections only, in group order). + * @param {Array} groups - CUSTOM_SECTION_GROUPS + * @param {{ isSectionActive: Function, isInSeasonalPeriod: Function }} options + * @returns {Array} + */ + function collectCustomDiscoverySections(groups, { isSectionActive, isInSeasonalPeriod }) { + const out = []; + (groups || []).forEach((group) => { + let pageNumber = 0; + (group.sections || []).forEach((section) => { + if (!isSectionActive(section)) return; + if (section.discoveryEnabled !== true) return; + if (section.startDate && section.endDate && !isInSeasonalPeriod(section.startDate, section.endDate)) return; + pageNumber += 1; + out.push({ ...section, pageNumber, isCustom: true }); + }); + }); + return out; + } + + /** + * Get all sections from groups with metadata about which group they belong to + * @param {Array} groups - Array of HomeScreenSectionGroup objects + * @returns {Array} Array of objects with section and groupName + */ + function getAllSectionsFromGroups(groups) { + if (!Array.isArray(groups)) return []; + const result = []; + groups.forEach(group => { + if (group && Array.isArray(group.sections)) { + group.sections.forEach(section => { + result.push({ + section: section, + groupName: group.name || 'Unnamed Group', + groupAuthor: group.author, + groupDescription: group.description + }); + }); + } + }); + return result; + } + + /** + * Render section groups with group-level and section-level checkboxes + * Used by import and export modals to display sections within their groups + * @param {HTMLElement} container - Container element to render into + * @param {Array} sectionGroups - Array of section group objects { name, author?, description?, sections: [] } + * @param {Object} options - Options object + * @param {string} options.checkboxClass - CSS class for section checkboxes (e.g., 'export-custom-check', 'import-custom-check') + * @param {string} options.groupCheckboxClass - CSS class for group checkboxes (optional, defaults to checkboxClass + '-group') + */ + function renderSectionGroupsCheckboxList(container, sectionGroups, options = {}) { + if (!Array.isArray(sectionGroups) || sectionGroups.length === 0) { + container.innerHTML = '
No section groups found.
'; + return; + } + + const checkboxClass = options.checkboxClass || 'section-check'; + const groupCheckboxClass = options.groupCheckboxClass || checkboxClass + '-group'; + + let html = '
'; + + sectionGroups.forEach((group, groupIndex) => { + if (!group || !Array.isArray(group.sections) || group.sections.length === 0) { + return; + } + + const groupName = group.name || 'Unnamed Group'; + + // Group checkbox row + html += ` +
+ +
+ `; + + // Section checkboxes (indented) + group.sections.forEach((section, sectionIndex) => { + html += ` + + `; + }); + + html += ` +
+
+ `; + }); + + html += '
'; + container.innerHTML = html; + + // Wire up group checkboxes to toggle all section checkboxes in that group + container.querySelectorAll(`.${groupCheckboxClass}`).forEach(groupCheckbox => { + groupCheckbox.addEventListener('change', (e) => { + const groupIndex = parseInt(e.target.dataset.groupIndex); + const isChecked = e.target.checked; + container.querySelectorAll(`.${checkboxClass}[data-group-index="${groupIndex}"]`).forEach(sectionCheckbox => { + sectionCheckbox.checked = isChecked; + }); + }); + }); + } + + /** + * Find a section by ID in groups + * @param {Array} groups - Array of HomeScreenSectionGroup objects + * @param {string} sectionId - Section ID to find + * @returns {Object|null} Object with section, group, and groupIndex, or null if not found + */ + function findSectionInGroups(groups, sectionId) { + if (!Array.isArray(groups)) return null; + for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { + const group = groups[groupIndex]; + if (group && Array.isArray(group.sections)) { + const sectionIndex = group.sections.findIndex(s => s.id === sectionId); + if (sectionIndex !== -1) { + return { + section: group.sections[sectionIndex], + group: group, + groupIndex: groupIndex, + sectionIndex: sectionIndex + }; + } + } + } + return null; + } + + /** + * Find a section by ID across all section groups (HOME, SEASONAL, DISCOVERY, CUSTOM) + * @param {Object} config - Configuration object + * @param {string} sectionId - Section ID to find + * @returns {Object|null} Object with section, group, groupIndex, sectionIndex, and groupType, or null if not found + */ + function findSectionInAllGroups(config, sectionId) { + if (!config) return null; + + // Check HOME_SECTION_GROUPS + let found = findSectionInGroups(config.HOME_SECTION_GROUPS || [], sectionId); + if (found) { + found.groupType = 'HOME_SECTION_GROUPS'; + return found; + } + + // Check SEASONAL_SECTION_GROUPS + found = findSectionInGroups(config.SEASONAL_SECTION_GROUPS || [], sectionId); + if (found) { + found.groupType = 'SEASONAL_SECTION_GROUPS'; + return found; + } + + // Check DISCOVERY_SECTION_GROUPS + found = findSectionInGroups(config.DISCOVERY_SECTION_GROUPS || [], sectionId); + if (found) { + found.groupType = 'DISCOVERY_SECTION_GROUPS'; + return found; + } + + // Check CUSTOM_SECTION_GROUPS + found = findSectionInGroups(config.CUSTOM_SECTION_GROUPS || [], sectionId); + if (found) { + found.groupType = 'CUSTOM_SECTION_GROUPS'; + return found; + } + + return null; + } + + /** + * True when a section property should be omitted from JS Injector persistence + * (same meaning as a blank CustomPrefs segment). + * @param {*} value + * @returns {boolean} + */ + function isEmptySectionValue(value) { + return value === '' || value === null || value === undefined; + } + + /** + * Remove empty properties from a section (and nested plain objects) before injector save. + * Empty = '' / null / undefined. Does not strip false, 0, or []. + * @param {Object} section + * @returns {Object} + */ + function omitEmptySectionProps(section) { + if (!section || typeof section !== 'object' || Array.isArray(section)) return section; + Object.keys(section).forEach((key) => { + const value = section[key]; + if (isEmptySectionValue(value)) { + delete section[key]; + return; + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + omitEmptySectionProps(value); + if (Object.keys(value).length === 0) delete section[key]; + } + }); + return section; + } + + /** + * Strip empty props from every section in group arrays (mutates in place). + * @param {Array} groups + */ + function omitEmptyPropsFromGroups(groups) { + if (!Array.isArray(groups)) return; + groups.forEach((group) => { + if (!group || !Array.isArray(group.sections)) return; + group.sections.forEach((section) => omitEmptySectionProps(section)); + }); + } + + /** + * Apply user-editable presentation fields onto a server section (no queries/paths/items). + * Cleared Default/Normal/left/empty values are written as '' so mergeSection overwrites + * stale injector values; omitEmptySectionProps then drops them on save. + * @param {Object} section + * @param {Object} presentation - buildPrefOverrides-shaped object + */ + function applyPresentationDefaultsToSection(section, presentation) { + if (!section || !presentation) return section; + if (presentation.order != null && presentation.order !== '') { + const order = parseInt(presentation.order, 10); + if (!Number.isNaN(order)) section.order = order; + } + if (presentation.ttl != null && presentation.ttl !== '') { + const ttl = parseInt(presentation.ttl, 10); + if (!Number.isNaN(ttl)) section.ttl = ttl; + } + if (presentation.cardFormat) section.cardFormat = presentation.cardFormat; + + if (presentation.hideName === true) section.hideName = true; + else if (presentation.hideName === false) section.hideName = ''; + + if (presentation.hideCardTitles === true) { + section.cardTitleVisibility = 'hidden'; + section.hideCardTitles = true; + } else { + section.hideCardTitles = ''; + if (section.cardTitleVisibility === 'hidden') section.cardTitleVisibility = ''; + } + + if (presentation.cardTitlePosition) { + section.cardTitlePosition = presentation.cardTitlePosition; + } else { + section.cardTitlePosition = ''; + } + + if (presentation.cardTitleCapitalization && presentation.cardTitleCapitalization !== 'normal') { + section.cardTitleCapitalization = presentation.cardTitleCapitalization; + } else { + section.cardTitleCapitalization = ''; + } + + if (presentation.cardTitleFontFamily && presentation.cardTitleFontFamily !== 'default') { + section.cardTitleFontFamily = presentation.cardTitleFontFamily; + } else { + section.cardTitleFontFamily = ''; + } + + if (presentation.cardTitleFontSize && presentation.cardTitleFontSize !== 'normal') { + section.cardTitleFontSize = presentation.cardTitleFontSize; + } else { + section.cardTitleFontSize = ''; + } + + if (presentation.cardTitleColor) section.cardTitleColor = presentation.cardTitleColor; + else section.cardTitleColor = ''; + + if (presentation.borderStyle) section.borderStyle = presentation.borderStyle; + else section.borderStyle = ''; + + if (presentation.borderColor) section.borderColor = presentation.borderColor; + else section.borderColor = ''; + + const isSpotlight = section.renderMode === 'Spotlight' || section.spotlight === true; + if (isSpotlight) { + section.spotlightConfig = section.spotlightConfig || {}; + if (presentation.animationEnabled === false) section.spotlightConfig.panAnimation = false; + else if (presentation.animationEnabled === true) section.spotlightConfig.panAnimation = true; + + if (presentation.spotlightLayout) section.spotlightConfig.spotlightLayout = presentation.spotlightLayout; + if (presentation.spotlightSize) section.spotlightConfig.spotlightSize = presentation.spotlightSize; + if (presentation.spotlightTileCount != null && presentation.spotlightTileCount !== '') { + const tileCount = parseInt(presentation.spotlightTileCount, 10); + if (!Number.isNaN(tileCount)) section.spotlightConfig.tileCount = tileCount; + } + } + + return section; + } + + /** + * Copy presentation defaults onto the server section and persist. + * @param {string} sectionId + * @param {Object} presentation + * @returns {Promise} + */ + async function publishSectionPresentationDefaults(sectionId, presentation) { + getConfig(); + if (!currentConfig || !sectionId) return false; + const found = findSectionInAllGroups(currentConfig, sectionId); + if (!found?.section) { + WARN(`publishSectionPresentationDefaults: section not found: ${sectionId}`); + return false; + } + applyPresentationDefaultsToSection(found.section, presentation || {}); + await saveConfig(currentConfig); + return true; + } + + /** + * Update a section in groups + * @param {Array} groups - Array of HomeScreenSectionGroup objects + * @param {string} sectionId - Section ID to update + * @param {Object} updatedSection - Updated section data + * @returns {boolean} True if section was found and updated + */ + function updateSectionInGroups(groups, sectionId, updatedSection) { + const found = findSectionInGroups(groups, sectionId); + if (found) { + // Full replace: collectData returns a complete section clone; spread-merge + // would resurrect keys that collectData intentionally deleted/cleared. + found.group.sections[found.sectionIndex] = updatedSection; + return true; + } + return false; + } + + /** + * Add a section to a group + * @param {Array} groups - Array of HomeScreenSectionGroup objects + * @param {string} groupName - Name of the group to add to + * @param {Object} section - Section to add + * @param {string} author - Optional author for custom groups + */ + function addSectionToGroup(groups, groupName, section, author) { + if (!Array.isArray(groups)) return; + + // Find existing group + let targetGroup = groups.find(g => { + if (author) { + return g.name === groupName && g.author === author; + } + return g.name === groupName && !g.author; + }); + + // Create group if it doesn't exist + if (!targetGroup) { + targetGroup = { + name: groupName, + sections: [] + }; + if (author) { + targetGroup.author = author; + } + groups.push(targetGroup); + } + + // Add section + if (!targetGroup.sections) { + targetGroup.sections = []; + } + targetGroup.sections.push(section); + + // Sort sections by order + targetGroup.sections.sort((a, b) => (a.order || 0) - (b.order || 0)); + } + + /** + * Remove a section from groups + * @param {Array} groups - Array of HomeScreenSectionGroup objects + * @param {string} sectionId - Section ID to remove + * @returns {boolean} True if section was found and removed + */ + function removeSectionFromGroups(groups, sectionId) { + if (!Array.isArray(groups)) return false; + for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { + const group = groups[groupIndex]; + if (group && Array.isArray(group.sections)) { + const sectionIndex = group.sections.findIndex(s => s.id === sectionId); + if (sectionIndex !== -1) { + group.sections.splice(sectionIndex, 1); + // Remove empty groups (except default groups) + if (group.sections.length === 0 && !group.author) { + groups.splice(groupIndex, 1); + } + return true; + } + } + } + return false; + } + + /** + * Catalog for Browse by Studio custom section. + * `name` must match Jellyfin Studio.Name exactly. Paramount uses "Paramount Pictures" (common JF name). + */ + const BROWSE_BY_STUDIO_CATALOG = [ + { name: 'AMC', file: 'AMC.svg' }, + { name: 'Apple TV', file: 'Apple_TV.svg' }, + { name: 'Columbia Pictures', file: 'Columbia_Pictures.svg' }, + { name: 'Disney+', file: 'Disney+.svg' }, + { name: 'Dreamworks Animation', file: 'DreamWorks_Animation.svg' }, + { name: 'FX', file: 'FX.svg' }, + { name: 'HBO', file: 'HBO.svg' }, + { name: 'Lucasfilm Ltd.', file: 'Lucasfilm.svg' }, + { name: 'Marvel Studios', file: 'Marvel_Studios.svg' }, + { name: 'New Line Cinema', file: 'New_Line_Cinema.svg' }, + { name: 'Paramount Pictures', file: 'Paramount_Pictures.svg' }, + { name: 'Pixar', file: 'Pixar.svg' }, + { name: 'Paramount+ with Showtime', file: 'Paramount+_with_Showtime.svg' }, + { name: 'Universal Pictures', file: 'Universal_Pictures.svg' }, + { name: 'Walt Disney Pictures', file: 'Walt_Disney_Pictures.svg' }, + { name: 'Warner Bros.', file: 'Warner_Bros..svg' }, + { name: 'HBO Max', file: 'HBO_Max.svg' }, + { name: 'Prime Video', file: 'Prime_Video.svg' }, + { name: 'Hulu', file: 'Hulu.svg' }, + { name: 'Peacock', file: 'Peacock.svg' } + ]; + + const BROWSE_BY_STUDIO_SECTION_ID = 'browse-by-studio'; + const BROWSE_BY_STUDIO_GROUP_ID = 'home-popular-studios'; + const BROWSE_BY_STUDIO_GROUP_NAME = 'Popular Studios'; + + let browseStudiosFetchPromise = null; + + function isBrowseByStudioCatalogFullyResolved(existingItems) { + const resolvedNames = new Set( + (existingItems || []) + .filter((item) => item?.Id && item?.Name) + .map((item) => item.Name) + ); + return BROWSE_BY_STUDIO_CATALOG.every((entry) => resolvedNames.has(entry.name)); + } + + function fetchAllStudiosForBrowseSection() { + if (browseStudiosFetchPromise) { + return browseStudiosFetchPromise; + } + + browseStudiosFetchPromise = (async () => { + const userId = window.ApiClient?.getCurrentUserId?.(); + const serverAddress = window.ApiClient?.serverAddress?.(); + if (!userId || !serverAddress || !window.apiHelper?.getData) { + return []; + } + + const url = `${serverAddress}/Studios?UserId=${encodeURIComponent(userId)}`; + const data = await window.apiHelper.getData(url, true, 24 * 60 * 60 * 1000); + if (Array.isArray(data?.Items)) return data.Items; + if (Array.isArray(data)) return data; + return []; + })().finally(() => { + browseStudiosFetchPromise = null; + }); + + return browseStudiosFetchPromise; + } + + function buildBrowseByStudioItem(catalogEntry, studio) { + return { + Name: catalogEntry.name, + Type: 'Folder', + Id: studio.Id, + imageUrl: `\${kefinTweaksRoot}pages/images/studios/${catalogEntry.file}`, + cardUrl: `#/details?id=${studio.Id}&serverId=\${serverId}` + }; + } + + /** + * Build browse-by-studio items from exact Name matches. + * Skips catalog entries already present in existingItems with an Id. + * @param {Array} studios + * @param {Array} [existingItems] + * @returns {Array} + */ + function buildBrowseByStudioItems(studios, existingItems = []) { + const byName = new Map(); + (studios || []).forEach((studio) => { + if (studio?.Name != null && studio?.Id) { + byName.set(studio.Name, studio); + } + }); + + const resolvedNames = new Set( + (existingItems || []) + .filter((item) => item?.Id && item?.Name) + .map((item) => item.Name) + ); + + const items = []; + BROWSE_BY_STUDIO_CATALOG.forEach((entry) => { + if (resolvedNames.has(entry.name)) return; + const studio = byName.get(entry.name); + if (!studio) return; + items.push(buildBrowseByStudioItem(entry, studio)); + }); + return items; + } + + function createBrowseByStudioSection(items) { + return { + id: BROWSE_BY_STUDIO_SECTION_ID, + name: 'Browse by Studio', + enabled: true, + order: 11, + cardFormat: 'Logo', + ttl: 604800000, + queries: [], + renderMode: 'Normal', + items: items || [], + type: 'home', + userConfigurable: true, + sortBy: 'Random', + cardTitleVisibility: 'hidden', + hideName: true, + useGaplessCards: false + }; + } + + /** + * Verify and sync library-based section groups (Recently Added, Popular Genres, etc.) with actual Jellyfin libraries + * @param {Object} config - Current configuration object + * @returns {Object} Updated configuration with synced library-based sections + */ + async function verifyLibrarySectionsConfig(config) { + try { + if (!window.ApiClient || !window.ApiClient._loggedIn) { + WARN('User not logged in, skipping library sections verification'); + return { config, hasChanges: false }; + } + + if (!window.dataHelper || !window.dataHelper.getLibraries) { + WARN('dataHelper.getLibraries not available, skipping library sections verification'); + return { config, hasChanges: false }; + } + + const libraries = await window.dataHelper.getLibraries(); + const filteredLibraries = (libraries || []).filter(l => !l.CollectionType || (l.CollectionType !== 'boxsets' && l.CollectionType !== 'playlists')); + const currentLibraryIds = new Set(filteredLibraries.map(l => l.Id)); + + if (!libraries || libraries.length === 0) { + LOG('No libraries found; syncing Browse by Studio only'); + } + + const groups = config.HOME_SECTION_GROUPS || []; + let updatedGroups = groups.map(g => ({ ...g, sections: Array.isArray(g.sections) ? [...g.sections] : [] })); + let hasChanges = false; + + if (filteredLibraries.length > 0) { + const libraryTemplates = [ + { + idPrefix: 'recently-added-', + groupId: 'home-recently-added', + groupName: 'Recently Added', + sectionTemplate: { + enabled: false, + order: 61, + cardFormat: 'Poster', + buildSection: function (library) { + let viewMoreUrl = null; + if (library.CollectionType === 'movies') { + viewMoreUrl = `#/movies.html?topParentId=${library.Id}&collectionType=movies&tab=1`; + } else if (library.CollectionType === 'tvshows') { + viewMoreUrl = `#/tv.html?topParentId=${library.Id}&collectionType=tvshows&tab=1`; + } + return { + id: `recently-added-${library.Id}`, + name: `Recently Added ${library.Name}`, + viewMoreUrl: viewMoreUrl, + jellyfinId: 'latestmedia', + userConfigurable: true, + ttl: 0, + queries: [{ + path: '/Items/Latest', + queryOptions: { + ParentId: library.Id, + Fields: 'PrimaryImageAspectRatio,Path', + Limit: 16, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', + } + }] + }; + } + } + }, + { + idPrefix: 'popular-genres-', + groupId: 'home-popular-genres', + groupName: 'Popular Genres', + sectionTemplate: { + enabled: false, + order: 81, + cardFormat: 'Thumb', + ttl: 604800000, + buildSection: function (library) { + const ct = (library.CollectionType || '').toLowerCase(); + let includeItemTypes = ['Movie', 'Series', 'Video', 'MusicArtist', 'Book', 'AudioBook']; + if (ct === 'music') includeItemTypes = ['MusicArtist']; + else if (ct === 'tvshows') includeItemTypes = ['Series']; + else if (ct === 'movies') includeItemTypes = ['Movie']; + else if (ct === 'books') includeItemTypes = ['Book', 'AudioBook']; + else if (ct === 'homevideos') includeItemTypes = ['Video']; + return { + id: `popular-genres-${library.Id}`, + name: `Popular ${library.Name} Genres`, + userConfigurable: true, + queries: [{ + path: '/Genres', + ParentId: library.Id, + queryOptions: { + ParentId: library.Id, + IncludeItemTypes: includeItemTypes, + SortBy: 'ChildCount', + SortOrder: 'Descending', + Limit: 20 + } + }] + }; + } + } + } + ]; + + for (const template of libraryTemplates) { + let group = updatedGroups.find(g => g.id === template.groupId || g.name === template.groupName); + if (!group) { + group = { + id: template.groupId, + name: template.groupName, + sections: [] + }; + updatedGroups.push(group); + hasChanges = true; + } else if (!group.id) { + group.id = template.groupId; + hasChanges = true; + } + + const groupSections = group.sections || []; + const templateSectionIds = groupSections.filter(s => s.id && s.id.startsWith(template.idPrefix)).map(s => s.id); + const existingLibraryIdsForTemplate = new Set(templateSectionIds.map(id => id.replace(template.idPrefix, ''))); + + const sectionsToRemove = groupSections.filter(s => { + if (!s.id || !s.id.startsWith(template.idPrefix)) return false; + const libId = s.id.replace(template.idPrefix, ''); + // Drop legacy recently-added-grouped-* shells and libs that no longer exist + return !currentLibraryIds.has(libId); + }); + const sectionsToRemoveIds = new Set(sectionsToRemove.map(s => s.id)); + const librariesToAdd = filteredLibraries.filter(l => !existingLibraryIdsForTemplate.has(l.Id)); + + if (sectionsToRemoveIds.size > 0 || librariesToAdd.length > 0) hasChanges = true; + + group.sections = groupSections.filter(s => !sectionsToRemoveIds.has(s.id)); + + librariesToAdd.forEach(library => { + const base = template.sectionTemplate.buildSection(library); + const newSection = { + ...template.sectionTemplate, + ...base, + id: base.id, + name: base.name, + queries: base.queries + }; + delete newSection.buildSection; + group.sections.push(newSection); + }); + } + + // Remove stale library sections from any HOME group (e.g. misplaced sections) + for (const group of updatedGroups) { + const beforeCount = (group.sections || []).length; + group.sections = (group.sections || []).filter(s => { + if (!s?.id) return true; + for (const template of libraryTemplates) { + if (s.id.startsWith(template.idPrefix)) { + const libId = s.id.replace(template.idPrefix, ''); + return currentLibraryIds.has(libId); + } + } + return true; + }); + if (group.sections.length !== beforeCount) { + hasChanges = true; + } + } + } + + // Browse by Studio: create once, then backfill unresolved catalog entries + try { + let studiosGroup = updatedGroups.find( + (g) => g.id === BROWSE_BY_STUDIO_GROUP_ID || g.name === BROWSE_BY_STUDIO_GROUP_NAME + ); + if (!studiosGroup) { + studiosGroup = { + id: BROWSE_BY_STUDIO_GROUP_ID, + name: BROWSE_BY_STUDIO_GROUP_NAME, + sections: [] + }; + updatedGroups.push(studiosGroup); + hasChanges = true; + } else if (!studiosGroup.id) { + studiosGroup.id = BROWSE_BY_STUDIO_GROUP_ID; + hasChanges = true; + } + if (!Array.isArray(studiosGroup.sections)) { + studiosGroup.sections = []; + } + + const browseSection = studiosGroup.sections.find((s) => s?.id === BROWSE_BY_STUDIO_SECTION_ID); + // Append-only: do not prune user-kept studios that leave the Kefin catalog + const existingItems = Array.isArray(browseSection?.items) ? browseSection.items : []; + + if (browseSection && isBrowseByStudioCatalogFullyResolved(existingItems)) { + LOG('Browse by Studio catalog fully resolved; skipping /Studios fetch'); + } else { + const studiosPromise = fetchAllStudiosForBrowseSection(); + const studios = await studiosPromise; + + if (!browseSection) { + const items = buildBrowseByStudioItems(studios, []); + studiosGroup.sections.push(createBrowseByStudioSection(items)); + hasChanges = true; + LOG(`Created browse-by-studio with ${items.length} matched studios`); + } else { + const toAppend = buildBrowseByStudioItems(studios, existingItems); + if (toAppend.length > 0) { + browseSection.items = existingItems.concat(toAppend); + hasChanges = true; + LOG(`Backfilled ${toAppend.length} browse-by-studio studios`); + } + } + } + } catch (browseErr) { + WARN('Browse by Studio sync skipped:', browseErr); + } + + if (!hasChanges) { + LOG('Library-based sections are in sync with libraries'); + return { config, hasChanges: false }; + } + + config.HOME_SECTION_GROUPS = updatedGroups; + LOG('Library-based sections synced successfully'); + return { config, hasChanges: true }; + } catch (error) { + ERR('Error verifying library sections config:', error); + return { config, hasChanges: false }; + } + } + + const NATIVE_QUERY_SYNC_KEYS = { + queryLevel: ['path'], + queryOptions: ['Fields', 'Filters', 'IncludeItemTypes', 'MediaTypes', 'IsActive', 'IsScheduled', 'IsAiring', 'HasAired', 'IsInProgress'], + }; + + function buildCanonicalSectionMapForGroups(groupKey) { + const defaults = window.KefinHomeConfig2; + const map = new Map(); + flattenSectionGroups(defaults?.[groupKey] || []).forEach((section) => { + if (section?.id) map.set(section.id, section); + }); + return map; + } + + function buildCanonicalHomeSectionMap() { + return buildCanonicalSectionMapForGroups('HOME_SECTION_GROUPS'); + } + + function getAllKefinDefaultSectionIds() { + const ids = new Set(); + ['HOME_SECTION_GROUPS', 'SEASONAL_SECTION_GROUPS', 'DISCOVERY_SECTION_GROUPS'].forEach((key) => { + buildCanonicalSectionMapForGroups(key).forEach((_, id) => ids.add(id)); + }); + return ids; + } + + const KEFIN_DEFAULT_GROUP_KEYS = ['HOME_SECTION_GROUPS', 'SEASONAL_SECTION_GROUPS', 'DISCOVERY_SECTION_GROUPS']; + const LIBRARY_RESOLVED_ID_PREFIXES = ['recently-added-', 'popular-genres-']; + const FIXED_TEMPLATE_SECTION_IDS = new Set([ + BROWSE_BY_STUDIO_SECTION_ID + ]); + + function isLibraryResolvedSectionId(sectionId) { + if (!sectionId) return false; + return LIBRARY_RESOLVED_ID_PREFIXES.some((prefix) => sectionId.startsWith(prefix)); + } + + function shouldKeepDefaultSurfaceSection(section, defaultSectionIds) { + if (!section?.id) return false; + if (section.deleted === true) return true; + if (defaultSectionIds.has(section.id)) return true; + if (FIXED_TEMPLATE_SECTION_IDS.has(section.id)) return true; + if (isLibraryResolvedSectionId(section.id)) return true; + return false; + } + + function addMissingKefinDefaultSections(config) { + const defaults = window.KefinHomeConfig2; + if (!defaults) return false; + let hasChanges = false; + + KEFIN_DEFAULT_GROUP_KEYS.forEach((groupKey) => { + const defaultGroups = defaults[groupKey] || []; + if (!Array.isArray(config[groupKey])) config[groupKey] = []; + + defaultGroups.forEach((defaultGroup) => { + let savedGroup = null; + if (defaultGroup.id) { + savedGroup = config[groupKey].find((g) => g.id === defaultGroup.id) || null; + } + if (!savedGroup && defaultGroup.name) { + savedGroup = config[groupKey].find((g) => g.name === defaultGroup.name) || null; + } + + if (!savedGroup) { + config[groupKey].push(JSON.parse(JSON.stringify(defaultGroup))); + hasChanges = true; + return; + } + + if (!Array.isArray(savedGroup.sections)) savedGroup.sections = []; + const existingIds = new Set(savedGroup.sections.map((s) => s?.id).filter(Boolean)); + (defaultGroup.sections || []).forEach((defaultSection) => { + if (!defaultSection?.id || existingIds.has(defaultSection.id)) return; + const tombstone = savedGroup.sections.find((s) => s?.id === defaultSection.id && s.deleted === true); + if (tombstone) return; + savedGroup.sections.push(JSON.parse(JSON.stringify(defaultSection))); + existingIds.add(defaultSection.id); + hasChanges = true; + LOG(`Added missing default section: ${defaultSection.id}`); + }); + }); + }); + + return hasChanges; + } + + function countOrphanKefinDefaultSections(config) { + const defaultSectionIds = getAllKefinDefaultSectionIds(); + let count = 0; + KEFIN_DEFAULT_GROUP_KEYS.forEach((groupKey) => { + (config[groupKey] || []).forEach((group) => { + (group.sections || []).forEach((section) => { + if (!shouldKeepDefaultSurfaceSection(section, defaultSectionIds)) count += 1; + }); + }); + }); + return count; + } + + function pruneOrphanKefinDefaultSections(config) { + const defaultSectionIds = getAllKefinDefaultSectionIds(); + let hasChanges = false; + + KEFIN_DEFAULT_GROUP_KEYS.forEach((groupKey) => { + (config[groupKey] || []).forEach((group) => { + if (!Array.isArray(group.sections)) return; + const before = group.sections.length; + group.sections = group.sections.filter((section) => { + const keep = shouldKeepDefaultSurfaceSection(section, defaultSectionIds); + if (!keep) LOG(`Pruning orphan default-surface section: ${section?.id}`); + return keep; + }); + if (group.sections.length !== before) hasChanges = true; + }); + }); + + return hasChanges; + } + + async function backupConfigBeforeDefaultPrune(configToBackup) { + try { + if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { + WARN('ApiClient not available, cannot create pre-prune backup'); + return false; + } + if (!window.KefinTweaksUtils?.resolvePluginId || !window.KefinTweaksUtils?.getPluginConfiguration) { + WARN('Cannot create pre-prune backup: plugin helpers unavailable'); + return false; + } + if (!window.apiHelper?.getAuthHeader) { + WARN('Cannot create pre-prune backup: apiHelper unavailable'); + return false; + } + + const injectorAliases = ['JavaScript Injector', 'JS Injector']; + const pluginId = await window.KefinTweaksUtils.resolvePluginId(injectorAliases); + if (!pluginId) { + WARN('Cannot create pre-prune backup: Injector plugin not found'); + return false; + } + + const injectorConfig = await window.KefinTweaksUtils.getPluginConfiguration(injectorAliases); + if (!injectorConfig.CustomJavaScripts) injectorConfig.CustomJavaScripts = []; + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + const backupName = `KefinTweaks-Config-Backup-${timestamp}`; + const backupScriptContent = `// KefinTweaks Configuration Backup +// Automatically created before default-section prune sync +// Created: ${new Date().toISOString()} +// Do not edit manually + +window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; + + injectorConfig.CustomJavaScripts.push({ + Name: backupName, + Script: backupScriptContent, + Enabled: false, + RequiresAuthentication: false + }); + + const server = window.ApiClient._serverAddress; + const configUrl = `${server}/Plugins/${pluginId}/Configuration`; + const saveResponse = await fetch(configUrl, { + method: 'POST', + headers: { + 'Authorization': window.apiHelper.getAuthHeader(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(injectorConfig) + }); + + if (!saveResponse.ok) { + WARN(`Failed to save pre-prune backup: ${saveResponse.statusText}`); + return false; + } + + LOG(`Created pre-prune backup: ${backupName}`); + return true; + } catch (error) { + ERR('Error creating pre-prune config backup:', error); + return false; + } + } + + function getRecentlyAddedCanonicalQuery() { + return { + path: '/Items/Latest', + queryOptions: { + Fields: 'PrimaryImageAspectRatio,Path', + Limit: 16, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', + }, + }; + } + + function syncQueryFromCanonical(savedQuery, canonicalQuery) { + if (!savedQuery || !canonicalQuery) return false; + let changed = false; + + for (const key of NATIVE_QUERY_SYNC_KEYS.queryLevel) { + if (canonicalQuery[key] !== undefined && savedQuery[key] !== canonicalQuery[key]) { + savedQuery[key] = canonicalQuery[key]; + changed = true; + } + } + + if (!savedQuery.queryOptions) savedQuery.queryOptions = {}; + const savedOpts = savedQuery.queryOptions; + const canonicalOpts = canonicalQuery.queryOptions || {}; + + for (const key of NATIVE_QUERY_SYNC_KEYS.queryOptions) { + if (canonicalOpts[key] !== undefined) { + const canonicalVal = canonicalOpts[key]; + const savedVal = savedOpts[key]; + const arraysEqual = Array.isArray(canonicalVal) && Array.isArray(savedVal) + && canonicalVal.length === savedVal.length + && canonicalVal.every((v, i) => v === savedVal[i]); + if (canonicalVal !== savedVal && !arraysEqual) { + savedOpts[key] = Array.isArray(canonicalVal) ? [...canonicalVal] : canonicalVal; + changed = true; + } + } + } + + if (canonicalOpts.Filters === undefined && savedOpts.Filters === 'IsResumable') { + delete savedOpts.Filters; + changed = true; + } + + return changed; + } + + /** Identity for static section items — cardUrl only (no Name fallback). */ + function itemIdentity(item) { + const cardUrl = item?.cardUrl; + if (cardUrl == null || cardUrl === '') return null; + return String(cardUrl); + } + + /** + * Ensure saved section has at least the canonical default items (append-only). + * @returns {boolean} whether section.items changed + */ + function syncItemsFromCanonical(savedSection, canonicalItems) { + if (!savedSection || !Array.isArray(canonicalItems) || !canonicalItems.length) return false; + + if (!Array.isArray(savedSection.items) || savedSection.items.length === 0) { + savedSection.items = JSON.parse(JSON.stringify(canonicalItems)); + return true; + } + + const existingIds = new Set(); + for (const item of savedSection.items) { + const id = itemIdentity(item); + if (id) existingIds.add(id); + } + + const toAppend = []; + for (const canonicalItem of canonicalItems) { + const id = itemIdentity(canonicalItem); + if (!id || existingIds.has(id)) continue; + toAppend.push(JSON.parse(JSON.stringify(canonicalItem))); + existingIds.add(id); + } + + if (!toAppend.length) return false; + savedSection.items = savedSection.items.concat(toAppend); + return true; + } + + function syncKefinTweaksDefaultSections(config) { + const recentlyAddedTemplate = getRecentlyAddedCanonicalQuery(); + let hasChanges = false; + + KEFIN_DEFAULT_GROUP_KEYS.forEach((groupKey) => { + const canonicalMap = buildCanonicalSectionMapForGroups(groupKey); + const groups = config[groupKey] || []; + for (const group of groups) { + for (const section of group.sections || []) { + if (!section?.id) continue; + + let canonical = canonicalMap.get(section.id); + if (!canonical && section.id.startsWith('recently-added-')) { + canonical = { queries: [recentlyAddedTemplate] }; + } + if (!canonical) continue; + + const hasCanonicalQueries = !!canonical.queries?.length; + const hasCanonicalItems = Array.isArray(canonical.items) && canonical.items.length > 0; + if (!hasCanonicalQueries && !hasCanonicalItems) continue; + + if (hasCanonicalQueries) { + if (!Array.isArray(section.queries)) section.queries = []; + + for (let i = 0; i < canonical.queries.length; i++) { + if (!section.queries[i]) { + section.queries[i] = { queryOptions: {} }; + } + if (syncQueryFromCanonical(section.queries[i], canonical.queries[i])) { + hasChanges = true; + } + } + } + + if (hasCanonicalItems && syncItemsFromCanonical(section, canonical.items)) { + hasChanges = true; + } + } + } + }); + + return hasChanges; + } + + /** + * Always overwrite seasonalThemes from KefinHomeConfig2 (not user-editable yet). + * Preserves enableSeasonalAnimations / enableSeasonalBackground / seasonToggles / enabled. + */ + function syncSeasonalThemesFromDefaults(config) { + const defaults = window.KefinHomeConfig2?.SEASONAL_THEME_SETTINGS; + if (!defaults?.seasonalThemes) return false; + const next = JSON.parse(JSON.stringify(defaults.seasonalThemes)); + const prev = config.SEASONAL_THEME_SETTINGS?.seasonalThemes; + const changed = JSON.stringify(prev) !== JSON.stringify(next); + if (!config.SEASONAL_THEME_SETTINGS) config.SEASONAL_THEME_SETTINGS = {}; + config.SEASONAL_THEME_SETTINGS.seasonalThemes = next; + return changed; + } + + async function ensureKefinTweaksDefaultSections() { + try { + if (!window.ApiClient || !window.ApiClient._loggedIn) { + return; + } + + let admin = false; + try { + admin = await window.apiHelper?.isAdmin?.(); + } catch { + return; + } + if (!admin) return; + + const config = loadConfig(); + const { hasChanges: libraryChanges } = await verifyLibrarySectionsConfig(config); + const addedDefaults = addMissingKefinDefaultSections(config); + + const orphanCount = countOrphanKefinDefaultSections(config); + let prunedOrphans = false; + if (orphanCount > 0) { + const snapshot = JSON.parse(JSON.stringify(window.KefinTweaksConfig || {})); + if (!snapshot.homeScreenConfig) { + snapshot.homeScreenConfig = JSON.parse(JSON.stringify(config)); + } + await backupConfigBeforeDefaultPrune(snapshot); + prunedOrphans = pruneOrphanKefinDefaultSections(config); + } + + const defaultSyncChanges = syncKefinTweaksDefaultSections(config); + const seasonalThemeChanges = syncSeasonalThemesFromDefaults(config); + + if (libraryChanges || addedDefaults || prunedOrphans || defaultSyncChanges || seasonalThemeChanges) { + await saveConfig(config); + LOG('KefinTweaks default home sections synced on startup', { + libraryChanges, + addedDefaults, + prunedOrphans, + defaultSyncChanges, + seasonalThemeChanges + }); + } + } catch (error) { + ERR('Error ensuring KefinTweaks default sections:', error); + } + } + + async function runStartupDefaultSectionSync() { + if (window.__kefinHomeStartupDefaultSectionSync) return; + window.__kefinHomeStartupDefaultSectionSync = true; + + if (window.userHelper?.waitForLogin) { + const loggedIn = await window.userHelper.waitForLogin(); + if (!loggedIn) { + window.__kefinHomeStartupDefaultSectionSync = false; + return; + } + } else if (!window.ApiClient?._loggedIn) { + window.__kefinHomeStartupDefaultSectionSync = false; + return; + } + + try { + let admin = false; + try { + admin = !!(await window.apiHelper?.isAdmin?.()); + } catch { + return; + } + if (!admin) return; + + // Legacy homeScreen → homeScreenConfig (no-ops once homeScreenConfig exists) + if (window.migrateHomeScreenConfig) { + await window.migrateHomeScreenConfig(); + } + await ensureKefinTweaksDefaultSections(); + } catch (error) { + ERR('Startup default section sync failed:', error); + } + } + + /** + * Load configuration from JS Injector and merge with defaults + */ + function loadConfig() { + try { + // Get existing config from window.KefinTweaksConfig + const existingConfig = (window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig) || {}; + + // Get defaults from homeScreenConfig2.js + const defaults = window.KefinHomeConfig2; + if (!defaults) { + throw new Error('KefinHomeConfig2 not found. Ensure homeScreenConfig2.js is loaded.'); + } + + // Merge configs - now using 4 groups + const mergedConfig = { + HOME_SECTION_GROUPS: mergeGroupDefaults( + defaults.HOME_SECTION_GROUPS || [], + existingConfig.HOME_SECTION_GROUPS || [], + 'home' + ), + SEASONAL_SECTION_GROUPS: mergeGroupDefaults( + defaults.SEASONAL_SECTION_GROUPS || [], + existingConfig.SEASONAL_SECTION_GROUPS || [], + 'seasonal' + ), + DISCOVERY_SECTION_GROUPS: mergeGroupDefaults( + defaults.DISCOVERY_SECTION_GROUPS || [], + existingConfig.DISCOVERY_SECTION_GROUPS || [], + 'discovery' + ), + CUSTOM_SECTION_GROUPS: mergeGroupDefaults( + defaults.CUSTOM_SECTION_GROUPS || [], + existingConfig.CUSTOM_SECTION_GROUPS || [], + 'custom' + ), + DISCOVERY_SETTINGS: { ...defaults.DISCOVERY_SETTINGS, ...(existingConfig.DISCOVERY_SETTINGS || {}) }, + SEASONAL_THEME_SETTINGS: { + ...defaults.SEASONAL_THEME_SETTINGS, + ...(existingConfig.SEASONAL_THEME_SETTINGS || {}), + seasonalThemes: JSON.parse(JSON.stringify( + defaults.SEASONAL_THEME_SETTINGS?.seasonalThemes || {} + )) + }, + CACHE: { ...defaults.CACHE, ...(existingConfig.CACHE || {}) }, + SPOTLIGHT_SETTINGS: { ...defaults.SPOTLIGHT_SETTINGS, ...(existingConfig.SPOTLIGHT_SETTINGS || {}) }, + HOME_SETTINGS: { ...defaults.HOME_SETTINGS, ...(existingConfig.HOME_SETTINGS || {}) }, + USER_HOME_SCREEN_SETTINGS: { ...defaults.USER_HOME_SCREEN_SETTINGS, ...(existingConfig.USER_HOME_SCREEN_SETTINGS || {}) }, + LIBRARY_CACHE: { ...defaults.LIBRARY_CACHE, ...(existingConfig.LIBRARY_CACHE || {}) } + }; + + // Soft-migrate legacy DISCOVERY_SETTINGS.minPeopleAppearances → HOME_SETTINGS mins + const existingHome = existingConfig.HOME_SETTINGS || {}; + const legacyPeopleMin = existingConfig.DISCOVERY_SETTINGS?.minPeopleAppearances; + if (legacyPeopleMin != null && existingHome.minPeopleAppearancesTotal == null) { + mergedConfig.HOME_SETTINGS.minPeopleAppearancesTotal = legacyPeopleMin; + mergedConfig.HOME_SETTINGS.minPeopleAppearancesMovies = legacyPeopleMin; + mergedConfig.HOME_SETTINGS.minPeopleAppearancesSeries = legacyPeopleMin; + mergedConfig.HOME_SETTINGS.minPeopleAppearancesEpisodes = legacyPeopleMin; + } + + mergedConfig.HOME_SETTINGS.categories = normalizeHomeCategories(mergedConfig.HOME_SETTINGS.categories); + if (mergedConfig.HOME_SETTINGS.renderCategoriesSeparately == null) { + mergedConfig.HOME_SETTINGS.renderCategoriesSeparately = false; + } + if (mergedConfig.HOME_SETTINGS.showCategoryFilters == null) { + mergedConfig.HOME_SETTINGS.showCategoryFilters = true; + } + if (mergedConfig.HOME_SETTINGS.showCreateSectionButtonOnHome == null) { + mergedConfig.HOME_SETTINGS.showCreateSectionButtonOnHome = false; + } + + // Add ENABLED_NORMAL_SECTIONS and ENABLED_DISCOVERY_SECTIONS + // ENABLED_NORMAL_SECTIONS is all enabled sections from HOME_SECTION_GROUPS, SEASONAL_SECTION_GROUPS, and CUSTOM_SECTION_GROUPS that aren't discovery sections + // If they have a start/end date the current date must fall within that date range + // ENABLED_DISCOVERY_SECTIONS is all enabled sections from DISCOVERY_SECTION_GROUPS and CUSTOM_SECTION_GROUPS that are discovery sections + // If they have a start/end date the current date must fall within that date range + + const enabledHomeSections = flattenSectionGroups(mergedConfig.HOME_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); + const enabledSeasonalSections = flattenSectionGroups(mergedConfig.SEASONAL_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && isInSeasonalPeriod(s.startDate, s.endDate)); + const enabledCustomSections = flattenSectionGroups(mergedConfig.CUSTOM_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); + const enabledDiscoverySections = flattenSectionGroups(mergedConfig.DISCOVERY_SECTION_GROUPS).filter(s => s.enabled === true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); + const enabledCustomDiscoverySections = collectCustomDiscoverySections(mergedConfig.CUSTOM_SECTION_GROUPS, { + isSectionActive: (s) => s.enabled === true, + isInSeasonalPeriod + }); + + mergedConfig.ENABLED_NORMAL_SECTIONS = [...enabledHomeSections, ...enabledSeasonalSections, ...enabledCustomSections]; + mergedConfig.ENABLED_DISCOVERY_SECTIONS = [...enabledDiscoverySections, ...enabledCustomDiscoverySections]; + + // Verify and sync recently-added library sections + //await verifyLibrarySectionsConfig(mergedConfig); + + currentConfig = mergedConfig; + return mergedConfig; + } catch (error) { + ERR('Error loading config:', error); + throw error; + } + } + + /** + * Get merged home screen config (exported for use by other scripts) + * @returns {Promise} Merged configuration object + */ + function getConfig(forceReload = false) { + if (currentConfig && !forceReload) { + return currentConfig; + } + let config = loadConfig(); + return config; + } + + /** + * Get enabled home sections and discovery sections + * @returns {Object} Object with enabledHomeSections and enabledDiscoverySections + */ + function applyGlobalCacheTtlToSections(sections, globalCacheTtl) { + if (!(Number(globalCacheTtl) >= 0) || !Array.isArray(sections)) return sections; + const ttl = Number(globalCacheTtl); + return sections.map((section) => (section ? { ...section, ttl } : section)); + } + + function getSections() { + + const config = getConfig(); + + const enabledHomeSections = flattenSectionGroups(config.HOME_SECTION_GROUPS).filter(s => (s.enabled || s.userConfigurable || s.userConfigurable === undefined) && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); + const enabledSeasonalSections = flattenSectionGroups(config.SEASONAL_SECTION_GROUPS).filter(s => (s.enabled || s.userConfigurable || s.userConfigurable === undefined) && s.discoveryEnabled !== true && isInSeasonalPeriod(s.startDate, s.endDate)); + const enabledCustomSections = flattenSectionGroups(config.CUSTOM_SECTION_GROUPS).filter(s => (s.enabled || s.userConfigurable || s.userConfigurable === undefined) && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); + const enabledDiscoverySections = flattenSectionGroups(config.DISCOVERY_SECTION_GROUPS).filter(s => (s.enabled || s.userConfigurable || s.userConfigurable === undefined) && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); + const enabledCustomDiscoverySections = collectCustomDiscoverySections(config.CUSTOM_SECTION_GROUPS, { + isSectionActive: (s) => s.enabled || s.userConfigurable || s.userConfigurable === undefined, + isInSeasonalPeriod + }); + + const globalCacheTtl = config.CACHE?.globalCacheTtl; + const home = applyGlobalCacheTtlToSections( + [...enabledHomeSections, ...enabledSeasonalSections, ...enabledCustomSections], + globalCacheTtl + ); + const discovery = applyGlobalCacheTtlToSections( + [...enabledDiscoverySections, ...enabledCustomDiscoverySections], + globalCacheTtl + ); + + return { + enabledHomeSections: home, + enabledDiscoverySections: discovery + } + } + + /** + * Merge default groups with admin overrides + * @param {Array} defaultGroups - Array of default HomeScreenSectionGroup objects + * @param {Array} overrideGroups - Array of override HomeScreenSectionGroup objects + * @param {string} sectionType - Surface type to stamp: home | seasonal | discovery, or custom (derives surface + isCustom) + * @returns {Array} Merged groups + */ + function mergeGroupDefaults(defaultGroups, overrideGroups, sectionType = 'home') { + const merged = []; + const overrideGroupMapById = new Map(); + const overrideGroupMapByName = new Map(); + const overrideGroupByOriginalName = new Map(); + + // Create maps of override groups by id, name/author, and _originalName + overrideGroups.forEach(overrideGroup => { + // Map by id (preferred method) + if (overrideGroup.id) { + overrideGroupMapById.set(overrideGroup.id, overrideGroup); + } + + // Map by name/author (fallback for backward compatibility) + const nameKey = overrideGroup.author ? `${overrideGroup.name}::${overrideGroup.author}` : overrideGroup.name || 'Unnamed'; + overrideGroupMapByName.set(nameKey, overrideGroup); + + // Also create a map by _originalName if it exists (for renamed default groups) + if (overrideGroup._originalName) { + const originalKey = overrideGroup.author ? `${overrideGroup._originalName}::${overrideGroup.author}` : overrideGroup._originalName || 'Unnamed'; + overrideGroupByOriginalName.set(originalKey, overrideGroup); + } + }); + + // Merge default groups with overrides + defaultGroups.forEach(defaultGroup => { + let overrideGroup = null; + let matchedBy = null; + + // Try to match by id first (preferred) + if (defaultGroup.id) { + overrideGroup = overrideGroupMapById.get(defaultGroup.id); + if (overrideGroup) { + matchedBy = 'id'; + } + } + + // If no match by id, try matching by name/author (backward compatibility) + if (!overrideGroup) { + const nameKey = defaultGroup.author ? `${defaultGroup.name}::${defaultGroup.author}` : defaultGroup.name || 'Unnamed'; + overrideGroup = overrideGroupMapByName.get(nameKey); + if (overrideGroup) { + matchedBy = 'name'; + } + } + + // If still no match, try matching by _originalName + if (!overrideGroup) { + const nameKey = defaultGroup.author ? `${defaultGroup.name}::${defaultGroup.author}` : defaultGroup.name || 'Unnamed'; + overrideGroup = overrideGroupByOriginalName.get(nameKey); + if (overrideGroup) { + matchedBy = 'originalName'; + } + } + + if (overrideGroup) { + // Merge sections within the group + const mergedSections = mergeSectionsInGroup( + defaultGroup.sections || [], + overrideGroup.sections || [] + ); + + merged.push({ + ...defaultGroup, + ...overrideGroup, + sections: mergedSections + }); + + // Remove from maps using the keys that were used to store them + if (overrideGroup.id && matchedBy === 'id') { + overrideGroupMapById.delete(overrideGroup.id); + } + const currentNameKey = overrideGroup.author ? `${overrideGroup.name}::${overrideGroup.author}` : overrideGroup.name || 'Unnamed'; + overrideGroupMapByName.delete(currentNameKey); + + if (overrideGroup._originalName) { + const originalNameKey = overrideGroup.author ? `${overrideGroup._originalName}::${overrideGroup.author}` : overrideGroup._originalName || 'Unnamed'; + overrideGroupByOriginalName.delete(originalNameKey); + } + } else { + merged.push(defaultGroup); + } + }); + + // Add any custom groups (not in defaults) + overrideGroupMapById.forEach(overrideGroup => { + merged.push(overrideGroup); + }); + overrideGroupMapByName.forEach(overrideGroup => { + // Only add if not already added via id map + if (!overrideGroup.id || !overrideGroupMapById.has(overrideGroup.id)) { + merged.push(overrideGroup); + } + }); + + // Stamp surface type on each section (never type=custom; authorship is isCustom) + merged.forEach(group => { + group.sections.forEach(section => { + if (sectionType === 'custom') { + section.isCustom = true; + const existing = String(section.type || '').toLowerCase(); + if (['home', 'seasonal', 'discovery'].includes(existing)) { + // keep configured surface type + } else if (section.discoveryEnabled === true || section.discoveryType) { + section.type = 'discovery'; + } else if (section.startDate && section.endDate) { + section.type = 'seasonal'; + } else { + section.type = 'home'; + } + } else { + section.type = sectionType; + } + }); + }); + + return merged; + } + + /** + * Merge sections within a group + * @param {Array} defaultSections - Default sections + * @param {Array} overrideSections - Override sections + * @returns {Array} Merged sections + */ + function mergeSectionsInGroup(defaultSections, overrideSections) { + const merged = []; + const overrideMap = new Map(); + + // Create map of overrides by id + overrideSections.forEach(override => { + overrideMap.set(override.id, override); + }); + + // Merge defaults with overrides + defaultSections.forEach(defaultSection => { + const override = overrideMap.get(defaultSection.id); + if (override) { + // Deep merge: override takes precedence but preserve queries structure + merged.push({ + ...defaultSection, + ...override, + queries: override.queries || defaultSection.queries || [] + }); + overrideMap.delete(defaultSection.id); + } else { + merged.push(defaultSection); + } + }); + + // Add any custom sections (not in defaults) + overrideMap.forEach(override => { + merged.push(override); + }); + + // Sort by order + return merged.sort((a, b) => (a.order || 0) - (b.order || 0)); + } + + /** + * Get saved config from JS Injector + * @returns {Promise} Saved full KefinTweaksConfig object + */ + async function getSavedConfig() { + try { + if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { + WARN('ApiClient not available, using window.KefinTweaksConfig as fallback'); + return window.KefinTweaksConfig || {}; + } + + let injectorConfig; + if (window.KefinTweaksUtils && typeof window.KefinTweaksUtils.getPluginConfiguration === 'function') { + try { + injectorConfig = await window.KefinTweaksUtils.getPluginConfiguration([ + 'JavaScript Injector', + 'JS Injector' + ]); + } catch (pluginError) { + WARN('JavaScript Injector plugin not found, using window.KefinTweaksConfig as fallback'); + return window.KefinTweaksConfig || {}; + } + } else { + WARN('KefinTweaksUtils.getPluginConfiguration not available, using window.KefinTweaksConfig as fallback'); + return window.KefinTweaksConfig || {}; + } + + // Find KefinTweaks-Config script + const kefinTweaksScript = injectorConfig.CustomJavaScripts?.find( + script => script.Name === 'KefinTweaks-Config' + ); + + if (kefinTweaksScript && kefinTweaksScript.Script) { + // Extract config from script content + const scriptMatch = kefinTweaksScript.Script.match(/window\.KefinTweaksConfig\s*=\s*({[\s\S]*});/); + if (scriptMatch && scriptMatch[1]) { + try { + const config = JSON.parse(scriptMatch[1]); + return config || {}; + } catch (parseError) { + ERR('Error parsing config from script:', parseError); + } + } + } + + // Fallback: use window.KefinTweaksConfig if available + return window.KefinTweaksConfig || {}; + } catch (error) { + ERR('Error getting saved config:', error); + // Fallback: use window.KefinTweaksConfig if available + return window.KefinTweaksConfig || {}; + } + } + + /** + * Deep merge queryOptions objects, preserving properties from savedQuery + * @param {Object} savedQuery - Query from saved config + * @param {Object} currentQuery - Query from current config + * @returns {Object} Merged query object + */ + function mergeQueryOptions(savedQuery, currentQuery) { + if (!savedQuery && !currentQuery) return {}; + if (!savedQuery) return JSON.parse(JSON.stringify(currentQuery)); + if (!currentQuery) return JSON.parse(JSON.stringify(savedQuery)); + + const merged = JSON.parse(JSON.stringify(savedQuery)); + + // Deep merge queryOptions + if (currentQuery.queryOptions) { + merged.queryOptions = { + ...(savedQuery.queryOptions || {}), + ...currentQuery.queryOptions + }; + } else if (savedQuery.queryOptions) { + merged.queryOptions = JSON.parse(JSON.stringify(savedQuery.queryOptions)); + } + + // Merge other query-level properties (current takes precedence) + Object.keys(currentQuery).forEach(key => { + if (key !== 'queryOptions') { + merged[key] = currentQuery[key]; + } + }); + + return merged; + } + + /** + * Merge queries arrays by index, preserving queryOptions from saved queries + * @param {Array} savedQueries - Queries from saved config + * @param {Array} currentQueries - Queries from current config + * @returns {Array} Merged queries array + */ + function mergeQueries(savedQueries, currentQueries) { + if (!savedQueries || savedQueries.length === 0) { + return currentQueries ? JSON.parse(JSON.stringify(currentQueries)) : []; + } + if (!currentQueries || currentQueries.length === 0) { + return JSON.parse(JSON.stringify(savedQueries)); + } + + const merged = []; + const maxLength = Math.max(savedQueries.length, currentQueries.length); + + for (let i = 0; i < maxLength; i++) { + const savedQuery = savedQueries[i]; + const currentQuery = currentQueries[i]; + + if (savedQuery && currentQuery) { + // Both exist - merge them + merged.push(mergeQueryOptions(savedQuery, currentQuery)); + } else if (currentQuery) { + // Only current exists - use it + merged.push(JSON.parse(JSON.stringify(currentQuery))); + } else if (savedQuery) { + // Only saved exists - preserve it + merged.push(JSON.parse(JSON.stringify(savedQuery))); + } + } + + return merged; + } + + /** + * Merge section objects, preserving properties from savedSection + * @param {Object} savedSection - Section from saved config + * @param {Object} currentSection - Section from current config + * @returns {Object} Merged section object + */ + function mergeSection(savedSection, currentSection) { + if (!savedSection && !currentSection) return null; + if (!savedSection) return JSON.parse(JSON.stringify(currentSection)); + if (!currentSection) return JSON.parse(JSON.stringify(savedSection)); + + const merged = JSON.parse(JSON.stringify(savedSection)); + + // Merge section properties (current takes precedence for editable fields) + Object.keys(currentSection).forEach(key => { + if (key === 'queries') { + // Special handling for queries array + merged.queries = mergeQueries(savedSection.queries, currentSection.queries); + } else { + // For other properties, current config takes precedence + merged[key] = currentSection[key]; + } + }); + + // Ensure id is preserved from currentSection + if (currentSection.id) { + merged.id = currentSection.id; + } + + return merged; + } + + const LIBRARY_SYNC_GROUP_IDS = new Set(['home-recently-added', 'home-popular-genres']); + + /** + * Merge group objects, matching sections by id. + * @param {Object} savedGroup - Group from saved config + * @param {Object} currentGroup - Group from current config + * @param {{ authoritativeSections?: boolean }} [options] - When authoritativeSections is true + * (or group is in LIBRARY_SYNC_GROUP_IDS), current section list wins — saved-only sections + * are not re-appended (discovery prune / library verify). + * @returns {Object} Merged group object + */ + function mergeGroup(savedGroup, currentGroup, options = {}) { + if (!savedGroup && !currentGroup) return null; + if (!savedGroup) return JSON.parse(JSON.stringify(currentGroup)); + if (!currentGroup) return JSON.parse(JSON.stringify(savedGroup)); + + const merged = JSON.parse(JSON.stringify(savedGroup)); + + // Update group properties from current (name, author, etc.) + Object.keys(currentGroup).forEach(key => { + if (key !== 'sections') { + merged[key] = currentGroup[key]; + } + }); + + // Merge sections by id + const mergedSections = []; + const savedSections = savedGroup.sections || []; + const currentSections = currentGroup.sections || []; + const savedSectionMap = new Map(); + + // Create map of saved sections by id + savedSections.forEach(section => { + if (section.id && !section.deleted) { + savedSectionMap.set(section.id, section); + } + }); + + // Process current sections (merge or add) + currentSections.forEach(currentSection => { + if (currentSection.deleted) { + // Skip deleted sections from current config + return; + } + + const savedSection = savedSectionMap.get(currentSection.id); + if (savedSection) { + // Section exists in both - merge them + mergedSections.push(mergeSection(savedSection, currentSection)); + savedSectionMap.delete(currentSection.id); + } else { + // New section - add it + mergedSections.push(JSON.parse(JSON.stringify(currentSection))); + } + }); + + // Library sync groups + callers with authoritativeSections (e.g. discovery): current list wins + const groupId = currentGroup.id || savedGroup.id; + const authoritative = + options.authoritativeSections === true || + LIBRARY_SYNC_GROUP_IDS.has(groupId); + if (!authoritative) { + savedSectionMap.forEach(savedSection => { + if (!savedSection.deleted) { + mergedSections.push(JSON.parse(JSON.stringify(savedSection))); + } + }); + } + + merged.sections = mergedSections; + return merged; + } + + /** + * Merge home screen group configs, matching groups by id + * @param {Array} currentGroups - Groups from current config + * @param {Array} savedGroups - Groups from saved config + * @param {{ authoritativeSections?: boolean }} [options] - Forwarded to mergeGroup + * @returns {Array} Merged groups array + */ + function mergeHomeScreenGroupConfig(currentGroups, savedGroups, options = {}) { + if (!currentGroups || currentGroups.length === 0) { + // Filter out deleted groups from saved config + return (savedGroups || []).filter(g => !g.deleted).map(g => JSON.parse(JSON.stringify(g))); + } + if (!savedGroups || savedGroups.length === 0) { + return JSON.parse(JSON.stringify(currentGroups)); + } + + const merged = []; + const savedGroupMap = new Map(); + + // Create map of saved groups by id (with fallback to name for backward compatibility) + savedGroups.forEach(group => { + if (group.deleted) return; + + const key = group.id || group.name; + if (key) { + savedGroupMap.set(key, group); + } + }); + + // Process current groups (merge or add) + currentGroups.forEach(currentGroup => { + if (currentGroup.deleted) { + // Skip deleted groups from current config + return; + } + + const key = currentGroup.id || currentGroup.name; + const savedGroup = key ? savedGroupMap.get(key) : null; + + if (savedGroup) { + // Group exists in both - merge them + merged.push(mergeGroup(savedGroup, currentGroup, options)); + savedGroupMap.delete(key); + } else { + // New group - add it + merged.push(JSON.parse(JSON.stringify(currentGroup))); + } + }); + + // Add any remaining saved groups that weren't in current (preserve them) + savedGroupMap.forEach(savedGroup => { + if (!savedGroup.deleted) { + merged.push(JSON.parse(JSON.stringify(savedGroup))); + } + }); + + return merged; + } + + /** + * Save configuration to JS Injector + * Merges partial configs with in-memory window.KefinTweaksConfig to preserve existing data. + * Utils performs the single injector Configuration GET + POST. + */ + async function saveConfig(config) { + try { + // Ensure window.KefinTweaksConfig exists (modal already syncs this; cold path only) + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + + const savedConfig = window.KefinTweaksConfig; + const savedHomeScreenConfig = savedConfig.homeScreenConfig || {}; + + // Create merged config + const mergedHomeScreenConfig = { + // Merge default groups (HOME, SEASONAL, DISCOVERY) using deep merge + HOME_SECTION_GROUPS: mergeHomeScreenGroupConfig( + config.HOME_SECTION_GROUPS || [], + savedHomeScreenConfig.HOME_SECTION_GROUPS || [] + ), + SEASONAL_SECTION_GROUPS: mergeHomeScreenGroupConfig( + config.SEASONAL_SECTION_GROUPS || [], + savedHomeScreenConfig.SEASONAL_SECTION_GROUPS || [] + ), + DISCOVERY_SECTION_GROUPS: mergeHomeScreenGroupConfig( + config.DISCOVERY_SECTION_GROUPS || [], + savedHomeScreenConfig.DISCOVERY_SECTION_GROUPS || [], + { authoritativeSections: true } + ), + // Custom groups are replaced entirely (UI is source of truth) + CUSTOM_SECTION_GROUPS: config.CUSTOM_SECTION_GROUPS || [], + // Merge other top-level properties (shallow merge) + DISCOVERY_SETTINGS: { + ...(savedHomeScreenConfig.DISCOVERY_SETTINGS || {}), + ...(config.DISCOVERY_SETTINGS || {}) + }, + SEASONAL_THEME_SETTINGS: { + ...(savedHomeScreenConfig.SEASONAL_THEME_SETTINGS || {}), + ...(config.SEASONAL_THEME_SETTINGS || {}) + }, + CACHE: { + ...(savedHomeScreenConfig.CACHE || {}), + ...(config.CACHE || {}) + }, + SPOTLIGHT_SETTINGS: { + ...(savedHomeScreenConfig.SPOTLIGHT_SETTINGS || {}), + ...(config.SPOTLIGHT_SETTINGS || {}) + }, + HOME_SETTINGS: { + ...(savedHomeScreenConfig.HOME_SETTINGS || {}), + ...(config.HOME_SETTINGS || {}) + }, + USER_HOME_SCREEN_SETTINGS: { + ...(savedHomeScreenConfig.USER_HOME_SCREEN_SETTINGS || {}), + ...(config.USER_HOME_SCREEN_SETTINGS || {}) + }, + LIBRARY_CACHE: { + ...(savedHomeScreenConfig.LIBRARY_CACHE || {}), + ...(config.LIBRARY_CACHE || {}) + } + }; + + // Empty values (= blank CustomPrefs segments) must not persist to JS Injector + omitEmptyPropsFromGroups(mergedHomeScreenConfig.HOME_SECTION_GROUPS); + omitEmptyPropsFromGroups(mergedHomeScreenConfig.SEASONAL_SECTION_GROUPS); + omitEmptyPropsFromGroups(mergedHomeScreenConfig.DISCOVERY_SECTION_GROUPS); + omitEmptyPropsFromGroups(mergedHomeScreenConfig.CUSTOM_SECTION_GROUPS); + + // Update homeScreenConfig with merged result (other top-level keys already on window.KefinTweaksConfig) + window.KefinTweaksConfig.homeScreenConfig = mergedHomeScreenConfig; + currentConfig = mergedHomeScreenConfig; + + // Save to JS Injector using utils + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } else { + // Fallback: try to use configuration.js functions + if (typeof window.KefinTweaksConfiguration !== 'undefined' && window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + WARN('saveConfigToJavaScriptInjector not available'); + return false; + } + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + /** + * Build Jellyfin checkbox HTML + */ + + /** + * Build compact section row HTML + */ + function buildSectionRowHTML(section, type = 'home') { + const isEnabled = section.enabled !== false; + const isSeasonal = section.startDate && section.endDate; + const isSpotlight = section.renderMode === 'Spotlight'; + const isDiscovery = section.discoveryEnabled === true; + const isCustom = type.toLowerCase() === 'custom' || section.id.startsWith('custom'); + const descriptionText = String(section.description || '').trim(); + const descriptionHtml = descriptionText + ? `${escapeHtml(descriptionText)}` + : ''; + + return ` +
+
+ + ${buildToggleSlider('', isEnabled, '', { + dataAttributes: { 'section-id': section.id, 'section-type': type }, + cssClass: 'toggle-slider section-toggle-switch', + wrapInLabel: false + })} + +
+ +
+ Order: ${section.order || 0} + ${section.cardFormat && !isSpotlight ? `${section.cardFormat}` : ''} + ${isSpotlight ? `Spotlight` : ''} + ${isSeasonal ? `Active from: ${section.startDate} - ${section.endDate}` : ''} + ${isDiscovery ? `Discovery` : ''} + ${descriptionHtml} +
+
+
+
+ + + ${isCustom ? ` + + ` : ''} +
+
+ `; + } + + /** + * Check if device is mobile + */ + function isMobile() { + return window.innerWidth <= 768; + } + + function toggleSectionType(e) { + const sectionType = e.target.dataset.sectionType; + const groupContainer = e.target.closest('.section-group'); + if (!groupContainer) return; + + // Get data-section-type-sectionType attribute from the group container + const sectionTypeAttribute = groupContainer.getAttribute(`data-section-type-${sectionType}`); + const newState = sectionTypeAttribute === 'false' ? 'true' : 'false'; + + if (newState === 'true') { + e.target.classList.add('button-submit'); + } else { + e.target.classList.remove('button-submit'); + } + + groupContainer.setAttribute(`data-section-type-${sectionType}`, newState); + } + + function renderSectionTypeToggles(sectionTypes) { + const sectionTypeTogglesContainer = document.createElement('div'); + sectionTypeTogglesContainer.style.display = 'flex'; + sectionTypeTogglesContainer.style.gap = '0.5em'; + + for (const type of sectionTypes) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = type; + button.classList.add('emby-button'); + button.classList.add('section-type-toggle-btn'); + button.classList.add('raised'); + button.classList.add('block'); + button.classList.add('button-submit'); + button.dataset.sectionType = type; + button.style.padding = '0.5em 1em'; + button.style.fontSize = '0.9em'; + // Event handler is attached via event delegation in attachMainModalListeners + sectionTypeTogglesContainer.appendChild(button); + } + + return sectionTypeTogglesContainer.outerHTML; + } + + /** + * Build section navigation HTML (left column) + */ + function buildSectionNavigationHTML(activeType = 'home') { + const types = [ + { id: 'home', label: 'Home' }, + { id: 'seasonal', label: 'Seasonal' }, + { id: 'discovery', label: 'Discovery' }, + { id: 'custom', label: 'Custom' } + ]; + + return ` +
+ ${types.map(type => ` + + `).join('')} +
+ `; + } + + /** + * Build global settings sub-navigation HTML (left column, same style as Edit Sections nav) + */ + function buildGlobalSettingsNavigationHTML(activeSubTab = 'general') { + const tabs = [ + { id: 'general', label: 'General' }, + { id: 'spotlight', label: 'Spotlight' }, + { id: 'discovery', label: 'Discovery' }, + { id: 'categories', label: 'Category' }, + { id: 'cache', label: 'Cache' } + ]; + return ` +
+ ${tabs.map(tab => ` + + `).join('')} +
+ `; + } + + /** + * Build global settings panel content for the given sub-tab + */ + function buildGlobalSettingsPanelContent(subTab) { + switch (subTab) { + case 'general': return buildGeneralSettingsHTML(); + case 'spotlight': return se().buildGlobalSpotlightSettingsHTML?.(currentConfig) || ''; + case 'discovery': return buildDiscoverySettingsHTML(); + case 'categories': return buildCategorySettingsHTML(); + case 'cache': return buildCacheSettingsHTML(); + default: return buildGeneralSettingsHTML(); + } + } + + /** + * Build group HTML with sections and toggle-all button + */ + function buildGroupHTML(group, sectionType) { + let sections = group.sections || []; + if (sections.length === 0) return ''; + + const allEnabled = sections.every(s => s.enabled !== false); + const groupId = `group-${group.name || 'unnamed'}-${group.author || ''}`.replace(/[^a-zA-Z0-9-]/g, '-'); + + // Get unique section types + const sectionTypes = [...new Set(sections.map(s => s.sectionType))]; + + return ` +
+
+
+ + ${group.description ? `
${group.description}
` : ''} + ${group.author ? `
by ${group.author}
` : ''} +
+ ${sectionTypes.length > 1 ? `
${renderSectionTypeToggles(sectionTypes)}
` : ''} + +
+
+ ${sections.map(s => buildSectionRowHTML(s, s.sectionType ?? sectionType)).join('')} +
+
+ `; + } + + /** + * Build section content HTML (right column) + */ + function buildSectionContentHTML(sectionType) { + // Always use currentConfig as source of truth + if (!currentConfig) return '
No configuration loaded.
'; + + switch (sectionType) { + case 'home': { + const homeGroups = currentConfig.HOME_SECTION_GROUPS || []; + + return ` +
+
Home Sections
+
Customize the default Home Sections provided by KefinTweaks.
+
+ ${homeGroups.map(g => buildGroupHTML(g, 'home')).join('')} +
+
+ `; + } + case 'seasonal': { + const seasonalGroups = currentConfig.SEASONAL_SECTION_GROUPS || []; + + return ` +
+
Seasonal Sections
+
Customize the default Seasonal Home Sections provided by KefinTweaks. These sections only appear between the specified start and end dates.
+
+ ${seasonalGroups.map(g => buildGroupHTML(g, 'home')).join('')} +
+
+ `; + } + case 'discovery': { + const discoveryGroups = currentConfig.DISCOVERY_SECTION_GROUPS || []; + + return ` +
+
Discovery Sections
+
Discovery sections will appear dynamically on the home screen as the user scrolls down.
+
+ ${discoveryGroups.map(g => buildGroupHTML(g, 'discovery')).join('')} +
+
+ `; + } + case 'custom': { + const customGroups = currentConfig.CUSTOM_SECTION_GROUPS || []; + + return ` +
+
Custom Sections
+
Customize your Custom Home Sections here. You can add, edit, and delete your own sections.
+ ${customGroups.map(g => buildGroupHTML(g, 'custom')).join('')} +
+ +
+ `; + } + default: + return '
Unknown section type
'; + } + } + + /** + * Build main configuration modal content + */ + function buildMainConfigHTML() { + // Always use currentConfig as source of truth + if (!currentConfig) return '
No configuration loaded.
'; + + // Use currentActiveTab to determine which tab/content should be displayed + const activeTab = normalizeActiveTab(currentActiveTab); + currentActiveTab = activeTab; + + return ` +
+ +
+ + + +
+ + +
+ + + + + +
+ + +
+
+ +
+ ${buildGlobalSettingsNavigationHTML(currentGlobalSettingsSubTab)} +
+ +
+ ${buildGlobalSettingsPanelContent(currentGlobalSettingsSubTab)} +
+
+
+ + +
+
+ +
+ ${buildSectionNavigationHTML('home')} +
+ +
+ ${buildSectionContentHTML('home')} +
+
+
+ + +
+
+
+
+ + +
+
Share your custom home screen sections or import configurations from others.
+
+ + +
+
Browse Community Sections
+
Browse and import curated home screen sections from the community.
+
Submit your own Home Screen Sections here.
+
+
+
+
+ + +
+
+
+
+ + `; + } + + /** + * Build general settings HTML + */ + function buildGeneralSettingsHTML() { + if (!currentConfig) return ''; + const seasonal = currentConfig.SEASONAL_THEME_SETTINGS || {}; + const homeSettings = currentConfig.HOME_SETTINGS || {}; + const userHomeScreen = currentConfig.USER_HOME_SCREEN_SETTINGS || {}; + const libraryCache = currentConfig.LIBRARY_CACHE || {}; + const D = GENERAL_TOGGLE_DESCRIPTIONS; + const maxPeopleCount = homeSettings.maxPeopleCount ?? 100; + const maxPeopleOptions = MAX_PEOPLE_COUNT_OPTIONS.map((n) => ({ value: String(n), label: String(n) })); + const settingsGroupStyle = 'margin-top: 0.75em; margin-bottom: 1.5em; border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 1em;'; + return ` +
+ ${hscToggleCard('userHome-inlineConfigure', userHomeScreen.inlineConfigure !== false, 'Inline Section Configuration', D)} + ${hscToggleCard('userHome-pinning', userHomeScreen.pinning !== false, 'Pin To Home', D)} +
+ ${hscToggleCard('userHome-pairNativeHomeSections', userHomeScreen.pairNativeHomeSections !== false, 'Pair Home Sections to Native Jellyfin User Preferences', D)} +
+ ${hscToggleCard('home-fadeInSections', homeSettings.fadeInSections === true, 'Fade In Sections', D)} + ${hscToggleCard('home-showStaleDataBeforeRefresh', homeSettings.showStaleDataBeforeRefresh === true, 'Show Stale Items', D)} + ${hscToggleCard('seasonal-enableSeasonalAnimations', seasonal.enableSeasonalAnimations !== false, 'Seasonal Animations', D)} + ${hscToggleCard('seasonal-enableSeasonalBackground', seasonal.enableSeasonalBackground !== false, 'Seasonal Backgrounds', D)} +
+ ${hscToggleCard('home-ensureThumbsForPopularTVNetworks', homeSettings.ensureThumbsForPopularTVNetworks === true, 'Require Studio Thumbs', D)} +
+
+ ${hscToggleCard('home-showCreateSectionButtonOnHome', homeSettings.showCreateSectionButtonOnHome === true, 'Quick Create Button', D)} +
+
+
+
+ +
+ When a section loads with no items: 0 removes it immediately. Any other value shows “No items found” for that many milliseconds, then fades the section out. +
+
+
+ +
+
+
+
+
+
Top People
+
+ Sections related to Top People from your library will use these values to determine which People qualify as "Top People". If a Person meets any of the minimum criteria listed below, they will be considered a Top Person. Top People will be sorted based on their total number of appearances, caching only the Top X number of people based on the Maximum Person Count. +
+
+ ${hscToggleCard('home-loadPeopleEpisodeData', homeSettings.loadPeopleEpisodeData === true, 'Load Episode Data for People', D)} +
+
+ ${buildTextInput('home-minPeopleAppearancesTotal', homeSettings.minPeopleAppearancesTotal ?? 10, 'Minimum Total Appearances', 'number')} + ${buildTextInput('home-minPeopleAppearancesMovies', homeSettings.minPeopleAppearancesMovies ?? 10, 'Minimum Movies Appearances', 'number')} + ${buildTextInput('home-minPeopleAppearancesSeries', homeSettings.minPeopleAppearancesSeries ?? 10, 'Minimum Series Appearances', 'number')} + ${buildTextInput('home-minPeopleAppearancesEpisodes', homeSettings.minPeopleAppearancesEpisodes ?? 10, 'Minimum Episodes Appearances', 'number')} + ${buildSelect('home-maxPeopleCount', maxPeopleOptions, String(maxPeopleCount), 'Maximum Person Count')} +
+
+ `; + } + + /** + * Build discovery settings HTML + */ + function buildDiscoverySettingsHTML() { + if (!currentConfig) return ''; + const discovery = currentConfig.DISCOVERY_SETTINGS || {}; + const D = DISCOVERY_TOGGLE_DESCRIPTIONS; + + return ` +
+
+

Discovery Sections are Home Screen sections that are dynamically created when a user scrolls down the Home Screen. When Discovery Sections are requested, KefinTweaks will dynamically build a Discovery Group from the list of enabled Discovery Sections. It will render the Discovery Group and then when the user reaches the bottom of the page again, a new Discovery Group will be generated and rendered.

+

KefinTweaks provides many default Discovery Sections, but you may also create your own static Discovery Sections. If you create multiple Discovery Sections within the same Group, KefinTweaks will select the first unrendered section from that Group when it builds the next Discovery Group.

+
+
+ ${hscToggleCard('discovery-enabled', discovery.enabled !== false, 'Enabled', D)} + ${hscToggleCard('discovery-infiniteScroll', discovery.infiniteScroll !== false, 'Infinite Scroll', D)} + ${hscToggleCard('discovery-renderSpotlightAboveMatching', discovery.renderSpotlightAboveMatching === true, 'Group Related Sections', D)} + ${hscToggleCard('discovery-randomizeOrder', discovery.randomizeOrder === true, 'Randomize Order', D)} + ${hscToggleCard('discovery-fadeInSections', discovery.fadeInSections === true, 'Fade In Sections', D)} + ${buildTextInput('discovery-spotlightDiscoveryChance', discovery.spotlightDiscoveryChance ?? 0.5, 'Spotlight Discovery Chance (0-1)', 'number')} + ${buildTextInput('discovery-minGenreMovieCount', discovery.minGenreMovieCount || 50, 'Genre Movie Count', 'number')} + ${buildTextInput('discovery-minGenreSeriesCount', discovery.minGenreSeriesCount || 10, 'Genre Series Count', 'number')} + ${buildTextInput('discovery-minStudioMovieCount', discovery.minStudioMovieCount || 25, 'Studio Movie Count', 'number')} + ${buildTextInput('discovery-minStudioSeriesCount', discovery.minStudioSeriesCount || 5, 'Studio Series Count', 'number')} +
+
+ `; + } + + /** + * Build seasonal settings HTML (deprecated - moved to General Settings) + */ + function buildSeasonalSettingsHTML() { + return ''; + } + + const CATEGORY_SENTINEL_IDS = new Set(['none', 'discovery', 'pinned']); + + function normalizeHomeCategories(list) { + const defaults = window.KefinHomeConfig2?.HOME_SETTINGS?.categories || []; + const incoming = Array.isArray(list) ? list : []; + const byId = new Map(); + defaults.forEach((cat) => { + if (cat?.id) byId.set(cat.id, { ...cat }); + }); + incoming.forEach((cat) => { + if (!cat?.id) return; + const prev = byId.get(cat.id) || {}; + byId.set(cat.id, { + id: cat.id, + name: cat.name || prev.name || cat.id, + icon: cat.icon || prev.icon || 'label' + }); + }); + const ordered = []; + const seen = new Set(); + incoming.forEach((cat) => { + if (!cat?.id || seen.has(cat.id)) return; + ordered.push(byId.get(cat.id)); + seen.add(cat.id); + }); + defaults.forEach((cat) => { + if (!cat?.id || seen.has(cat.id)) return; + ordered.push(byId.get(cat.id)); + seen.add(cat.id); + }); + return ordered.filter(Boolean); + } + + function buildCategoryRowHTML(cat, index, total) { + const isSentinel = CATEGORY_SENTINEL_IDS.has(cat.id); + return ` +
+ ${index + 1}. + + + + + ${isSentinel ? '' : ` + `} +
+ `; + } + + function buildCategorySettingsHTML() { + if (!currentConfig) return ''; + const homeSettings = currentConfig.HOME_SETTINGS || {}; + const categories = normalizeHomeCategories(homeSettings.categories); + const D = GENERAL_TOGGLE_DESCRIPTIONS; + return ` +
+
+ Categories control which Home Screen sections are visible from the fixed category rail. Custom sections can be assigned a category in the section editor. +
+
+ ${hscToggleCard('home-showCategoryFilters', homeSettings.showCategoryFilters !== false, 'Show Category Filters', D)} + ${hscToggleCard('home-renderCategoriesSeparately', homeSettings.renderCategoriesSeparately === true, 'Render Categories Separately', D)} +
+
Categories
+
+ Home, Discover, and Pinned are built-in. Their ids cannot change and they cannot be deleted. Custom categories get a stable id when created. +
+
+ ${categories.map((cat, i) => buildCategoryRowHTML(cat, i, categories.length)).join('')} +
+
+ + + +
+
+ `; + } + + function collectCategoriesFromForm(root) { + const rows = Array.from(root.querySelectorAll('.kefin-home-category-row')); + return rows.map((row) => ({ + id: row.dataset.categoryId, + name: (row.querySelector('.kefin-home-category-name')?.value || '').trim() || row.dataset.categoryId, + icon: (row.querySelector('.kefin-home-category-icon')?.value || '').trim() || 'label' + })).filter((cat) => cat.id); + } + + function refreshCategoryListUI(root) { + const list = root.querySelector('#kefin-home-category-list'); + if (!list) return; + const categories = normalizeHomeCategories(currentConfig.HOME_SETTINGS?.categories); + list.innerHTML = categories.map((cat, i) => buildCategoryRowHTML(cat, i, categories.length)).join(''); + } + + function wireCategorySettingsListeners(root) { + if (!root?.querySelector('#kefin-home-category-list')) return; + + const persistCategories = () => { + currentConfig.HOME_SETTINGS = { + ...(currentConfig.HOME_SETTINGS || {}), + showCategoryFilters: root.querySelector('#home-showCategoryFilters')?.checked === true, + renderCategoriesSeparately: root.querySelector('#home-renderCategoriesSeparately')?.checked === true, + categories: normalizeHomeCategories(collectCategoriesFromForm(root)) + }; + applyGlobalSettingsFromForm({ save: true }); + try { + window.homeScreen3?.refreshHomeCategoryChrome?.(); + } catch (_) { /* ignore */ } + }; + + root.querySelector('#home-showCategoryFilters')?.addEventListener('change', () => { + updateHscToggleCardHints(root, GENERAL_TOGGLE_DESCRIPTIONS); + persistCategories(); + }); + + root.querySelector('#home-renderCategoriesSeparately')?.addEventListener('change', () => { + updateHscToggleCardHints(root, GENERAL_TOGGLE_DESCRIPTIONS); + persistCategories(); + }); + + root.addEventListener('change', (e) => { + if (e.target.matches?.('.kefin-home-category-name, .kefin-home-category-icon')) { + persistCategories(); + } + }); + + root.addEventListener('click', (e) => { + const up = e.target.closest?.('.kefin-home-category-up'); + const down = e.target.closest?.('.kefin-home-category-down'); + const remove = e.target.closest?.('.kefin-home-category-remove'); + const add = e.target.closest?.('#kefin-home-category-add-btn'); + if (!up && !down && !remove && !add) return; + + let categories = collectCategoriesFromForm(root); + + if (add) { + const name = (root.querySelector('#kefin-home-category-add-name')?.value || '').trim(); + const icon = (root.querySelector('#kefin-home-category-add-icon')?.value || '').trim() || 'label'; + if (!name) return; + const id = `cat_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; + categories.push({ id, name, icon }); + const nameInput = root.querySelector('#kefin-home-category-add-name'); + const iconInput = root.querySelector('#kefin-home-category-add-icon'); + if (nameInput) nameInput.value = ''; + if (iconInput) iconInput.value = ''; + } else { + const index = parseInt((up || down || remove).dataset.index, 10); + if (!Number.isFinite(index)) return; + if (up && index > 0) { + [categories[index - 1], categories[index]] = [categories[index], categories[index - 1]]; + } else if (down && index < categories.length - 1) { + [categories[index + 1], categories[index]] = [categories[index], categories[index + 1]]; + } else if (remove) { + const id = categories[index]?.id; + if (CATEGORY_SENTINEL_IDS.has(id)) return; + categories.splice(index, 1); + } + } + + currentConfig.HOME_SETTINGS = { + ...(currentConfig.HOME_SETTINGS || {}), + showCategoryFilters: root.querySelector('#home-showCategoryFilters')?.checked === true, + renderCategoriesSeparately: root.querySelector('#home-renderCategoriesSeparately')?.checked === true, + categories: normalizeHomeCategories(categories) + }; + refreshCategoryListUI(root); + applyGlobalSettingsFromForm({ save: true }); + try { + window.homeScreen3?.refreshHomeCategoryChrome?.(); + } catch (_) { /* ignore */ } + }); + } + + function escapeHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + /** + * Convert milliseconds to minutes + */ + function msToMinutes(ms) { + return Math.round(ms / 60000); + } + + /** + * Convert minutes to milliseconds + */ + function minutesToMs(minutes) { + return minutes * 60000; + } + + function getDefaultTtlPresets() { + const presets = window.KefinHomeScreenAdvancedEditor?.getCachePresets?.(); + if (Array.isArray(presets) && presets.length) { + return presets.filter((p) => p.key !== 'FORCE_REFRESH'); + } + const cache = currentConfig?.CACHE || window.KefinHomeConfig2?.CACHE || {}; + return [ + { key: 'VERY_SHORT', label: 'Very Short (1 min)', ms: cache.VERY_SHORT_TTL ?? 60000 }, + { key: 'SHORT', label: 'Short (5 min)', ms: cache.SHORT_TTL ?? 300000 }, + { key: 'DEFAULT', label: 'Default (1 hour)', ms: cache.DEFAULT_TTL ?? 3600000 }, + { key: 'LONG', label: 'Long (24 hours)', ms: cache.LONG_TTL ?? 86400000 }, + { key: 'STATIC', label: 'Static (1 week)', ms: cache.STATIC_TTL ?? 604800000 }, + { key: 'DISCOVERY', label: 'Discovery (6 hours)', ms: cache.DISCOVERY_TTL ?? 21600000 }, + { key: 'CUSTOM', label: 'Custom', ms: null } + ]; + } + + /** Full section-cache style presets including No Cache + Disabled (undefined). */ + function getGlobalCachePresets() { + const presets = window.KefinHomeScreenAdvancedEditor?.getCachePresets?.(); + const cache = currentConfig?.CACHE || window.KefinHomeConfig2?.CACHE || {}; + const base = Array.isArray(presets) && presets.length + ? presets.slice() + : [ + { key: 'FORCE_REFRESH', label: 'No Cache', ms: cache.FORCE_REFRESH_TTL ?? 0 }, + { key: 'VERY_SHORT', label: 'Very Short (1 min)', ms: cache.VERY_SHORT_TTL ?? 60000 }, + { key: 'SHORT', label: 'Short (5 min)', ms: cache.SHORT_TTL ?? 300000 }, + { key: 'DEFAULT', label: 'Default (1 hour)', ms: cache.DEFAULT_TTL ?? 3600000 }, + { key: 'LONG', label: 'Long (24 hours)', ms: cache.LONG_TTL ?? 86400000 }, + { key: 'STATIC', label: 'Static (1 week)', ms: cache.STATIC_TTL ?? 604800000 }, + { key: 'DISCOVERY', label: 'Discovery (6 hours)', ms: cache.DISCOVERY_TTL ?? 21600000 }, + { key: 'CUSTOM', label: 'Custom', ms: null } + ]; + return [ + { key: 'DISABLED', label: 'Disabled', ms: undefined }, + ...base + ]; + } + + function matchDefaultTtlPreset(ms) { + const value = Number(ms); + if (!Number.isFinite(value)) { + return { preset: 'DEFAULT', customValue: '', customUnit: 'minutes' }; + } + const exact = getDefaultTtlPresets().find((p) => p.key !== 'CUSTOM' && p.ms === value); + if (exact) return { preset: exact.key, customValue: '', customUnit: 'minutes' }; + const minutes = Math.round(value / 60000); + if (minutes > 0 && minutes * 60000 === value) { + return { preset: 'CUSTOM', customValue: String(minutes), customUnit: 'minutes' }; + } + const seconds = Math.round(value / 1000); + return { preset: 'CUSTOM', customValue: String(seconds), customUnit: 'seconds' }; + } + + function matchGlobalCachePreset(ms) { + if (ms == null || ms === '' || !Number.isFinite(Number(ms))) { + return { preset: 'DISABLED', customValue: '', customUnit: 'minutes' }; + } + const value = Number(ms); + const exact = getGlobalCachePresets().find( + (p) => p.key !== 'CUSTOM' && p.key !== 'DISABLED' && p.ms === value + ); + if (exact) return { preset: exact.key, customValue: '', customUnit: 'minutes' }; + const minutes = Math.round(value / 60000); + if (minutes > 0 && minutes * 60000 === value) { + return { preset: 'CUSTOM', customValue: String(minutes), customUnit: 'minutes' }; + } + const seconds = Math.round(value / 1000); + return { preset: 'CUSTOM', customValue: String(seconds), customUnit: 'seconds' }; + } + + function resolveDefaultTtlMsFromForm(root) { + const presetEl = root.querySelector('#cache-DEFAULT_TTL-preset'); + if (!presetEl) return null; + const presetKey = presetEl.value || 'DEFAULT'; + if (presetKey === 'CUSTOM') { + const raw = parseFloat(root.querySelector('#cache-DEFAULT_TTL-custom-value')?.value || ''); + const unit = root.querySelector('#cache-DEFAULT_TTL-custom-unit')?.value || 'minutes'; + const unitMs = TTL_UNIT_OPTIONS.find((u) => u.value === unit)?.ms || 60000; + if (!Number.isFinite(raw) || raw < 0) return 0; + return Math.round(raw * unitMs); + } + const preset = getDefaultTtlPresets().find((p) => p.key === presetKey); + return preset?.ms ?? (currentConfig?.CACHE?.DEFAULT_TTL ?? 3600000); + } + + /** @returns {number|undefined} undefined = Disabled (omit / clear globalCacheTtl) */ + function resolveGlobalCacheTtlMsFromForm(root) { + const presetEl = root.querySelector('#cache-globalCacheTtl-preset'); + if (!presetEl) return null; + const presetKey = presetEl.value || 'DISABLED'; + if (presetKey === 'DISABLED') return undefined; + if (presetKey === 'CUSTOM') { + const raw = parseFloat(root.querySelector('#cache-globalCacheTtl-custom-value')?.value || ''); + const unit = root.querySelector('#cache-globalCacheTtl-custom-unit')?.value || 'minutes'; + const unitMs = TTL_UNIT_OPTIONS.find((u) => u.value === unit)?.ms || 60000; + if (!Number.isFinite(raw) || raw < 0) return 0; + return Math.round(raw * unitMs); + } + const preset = getGlobalCachePresets().find((p) => p.key === presetKey); + return preset?.ms ?? undefined; + } + + /** + * Build cache settings HTML + */ + function buildCacheSettingsHTML() { + if (!currentConfig) return ''; + const cache = currentConfig.CACHE || {}; + const defaultMatch = matchDefaultTtlPreset(cache.DEFAULT_TTL ?? 1800000); + const isCustomDefault = defaultMatch.preset === 'CUSTOM'; + const presetOptions = getDefaultTtlPresets().map((p) => ({ + value: p.key, + label: p.label + })); + const globalMatch = matchGlobalCachePreset(cache.globalCacheTtl); + const isCustomGlobal = globalMatch.preset === 'CUSTOM'; + const globalPresetOptions = getGlobalCachePresets().map((p) => ({ + value: p.key, + label: p.label + })); + + return ` +
+
+

Every Home Screen Section has a cache expiration time associated to it. If the section has no explicitly defined cache value, it will use the Default setting below.

+

When a Section is being loaded, if the cache has not expired, the cached items will be rendered instead of requesting new data. If a Section has "No Cache" set, it will always request new data from the server.

+

The Cache settings listed below will appear as options when creating or editing a Home Screen Section in the "Advanced Options" at the bottom of the editor.

+

Global Cache: when set (not Disabled), individual section cache timers are ignored and every section uses this value.

+
+
+
+ ${buildSelect('cache-globalCacheTtl-preset', globalPresetOptions, globalMatch.preset, 'Global Cache')} +
+ ${buildTextInput('cache-globalCacheTtl-custom-value', globalMatch.customValue || '', 'Custom Duration', 'number')} + ${buildSelect('cache-globalCacheTtl-custom-unit', TTL_UNIT_OPTIONS.map((u) => ({ value: u.value, label: u.value })), globalMatch.customUnit || 'minutes', 'Unit')} +
+
+
+ ${buildSelect('cache-DEFAULT_TTL-preset', presetOptions, defaultMatch.preset, 'Default')} +
+ ${buildTextInput('cache-DEFAULT_TTL-custom-value', defaultMatch.customValue || '', 'Custom Duration', 'number')} + ${buildSelect('cache-DEFAULT_TTL-custom-unit', TTL_UNIT_OPTIONS.map((u) => ({ value: u.value, label: u.value })), defaultMatch.customUnit || 'minutes', 'Unit')} +
+
+ ${buildTextInput('cache-VERY_SHORT_TTL', msToMinutes(cache.VERY_SHORT_TTL || 60000), 'Very Short (minutes)', 'number')} + ${buildTextInput('cache-SHORT_TTL', msToMinutes(cache.SHORT_TTL || 300000), 'Short (minutes)', 'number')} + ${buildTextInput('cache-LONG_TTL', msToMinutes(cache.LONG_TTL || 86400000), 'Long (minutes)', 'number')} + ${buildTextInput('cache-STATIC_TTL', msToMinutes(cache.STATIC_TTL || 604800000), 'Static (minutes)', 'number')} + ${buildTextInput('cache-DISCOVERY_TTL', msToMinutes(cache.DISCOVERY_TTL || 3600000), 'Discovery (minutes)', 'number')} +
+
+ `; + } + + /** + * Collect settings present in the currently rendered Global Settings panel into currentConfig. + * Missing panels are left unchanged (avoids resetting values when switching sub-tabs). + */ + function applyGlobalSettingsFromForm({ save = true, immediate = false } = {}) { + if (!currentConfig) return; + const dialog = document.querySelector(`.dialogContainer[data-modal-id="${MAIN_MODAL_ID}"]`); + const root = dialog?.querySelector('#global-settings-content') || dialog; + if (!root) return; + + if (root.querySelector('#home-showCategoryFilters') + || root.querySelector('#home-renderCategoriesSeparately') + || root.querySelector('#kefin-home-category-list')) { + currentConfig.HOME_SETTINGS = { + ...(currentConfig.HOME_SETTINGS || {}), + showCategoryFilters: root.querySelector('#home-showCategoryFilters')?.checked === true, + renderCategoriesSeparately: root.querySelector('#home-renderCategoriesSeparately')?.checked === true, + ...(root.querySelector('#kefin-home-category-list') + ? { categories: normalizeHomeCategories(collectCategoriesFromForm(root)) } + : {}) + }; + try { + window.homeScreen3?.refreshHomeCategoryChrome?.(); + } catch (_) { /* ignore */ } + } + + if (root.querySelector('#discovery-enabled')) { + currentConfig.DISCOVERY_SETTINGS = { + ...(currentConfig.DISCOVERY_SETTINGS || {}), + enabled: root.querySelector('#discovery-enabled')?.checked !== false, + infiniteScroll: root.querySelector('#discovery-infiniteScroll')?.checked !== false, + minGenreMovieCount: parseInt(root.querySelector('#discovery-minGenreMovieCount')?.value || '50', 10), + minGenreSeriesCount: parseInt(root.querySelector('#discovery-minGenreSeriesCount')?.value || '10', 10), + minStudioMovieCount: parseInt(root.querySelector('#discovery-minStudioMovieCount')?.value || '25', 10), + minStudioSeriesCount: parseInt(root.querySelector('#discovery-minStudioSeriesCount')?.value || '5', 10), + spotlightDiscoveryChance: parseFloat(root.querySelector('#discovery-spotlightDiscoveryChance')?.value || '0.5'), + renderSpotlightAboveMatching: root.querySelector('#discovery-renderSpotlightAboveMatching')?.checked === true, + randomizeOrder: root.querySelector('#discovery-randomizeOrder')?.checked === true, + fadeInSections: root.querySelector('#discovery-fadeInSections')?.checked === true + }; + } + + if (root.querySelector('#seasonal-enableSeasonalAnimations') + || root.querySelector('#home-fadeInSections') + || root.querySelector('#userHome-inlineConfigure') + || root.querySelector('#libraryCache-mdblistApiKey') + || root.querySelector('#home-minPeopleAppearancesTotal') + || root.querySelector('#home-dismissEmptySectionTimer') + || root.querySelector('#home-showCreateSectionButtonOnHome') + || root.querySelector('#home-loadPeopleEpisodeData')) { + const prevLoadPeopleEpisodeData = currentConfig.HOME_SETTINGS?.loadPeopleEpisodeData === true; + currentConfig.SEASONAL_THEME_SETTINGS = { + ...(currentConfig.SEASONAL_THEME_SETTINGS || {}), + enableSeasonalAnimations: root.querySelector('#seasonal-enableSeasonalAnimations')?.checked !== false, + enableSeasonalBackground: root.querySelector('#seasonal-enableSeasonalBackground')?.checked !== false, + seasonToggles: (window.KefinHomeConfig2?.SEASONAL_THEME_SETTINGS?.seasonToggles || []).map(season => ({ + ...season, + enabled: root.querySelector(`#seasonal-${season.id}`) + ? root.querySelector(`#seasonal-${season.id}`)?.checked === true + : season.enabled + })) + }; + const dismissEmptyRaw = parseInt(root.querySelector('#home-dismissEmptySectionTimer')?.value ?? '0', 10); + currentConfig.HOME_SETTINGS = { + ...(currentConfig.HOME_SETTINGS || {}), + fadeInSections: root.querySelector('#home-fadeInSections')?.checked === true, + ensureThumbsForPopularTVNetworks: root.querySelector('#home-ensureThumbsForPopularTVNetworks')?.checked === true, + showStaleDataBeforeRefresh: root.querySelector('#home-showStaleDataBeforeRefresh')?.checked === true, + dismissEmptySectionTimer: Number.isFinite(dismissEmptyRaw) && dismissEmptyRaw > 0 ? dismissEmptyRaw : 0, + showCreateSectionButtonOnHome: root.querySelector('#home-showCreateSectionButtonOnHome')?.checked === true, + minPeopleAppearancesTotal: parseInt(root.querySelector('#home-minPeopleAppearancesTotal')?.value || '10', 10), + minPeopleAppearancesMovies: parseInt(root.querySelector('#home-minPeopleAppearancesMovies')?.value || '10', 10), + minPeopleAppearancesSeries: parseInt(root.querySelector('#home-minPeopleAppearancesSeries')?.value || '10', 10), + minPeopleAppearancesEpisodes: parseInt(root.querySelector('#home-minPeopleAppearancesEpisodes')?.value || '10', 10), + maxPeopleCount: parseInt(root.querySelector('#home-maxPeopleCount')?.value || '100', 10), + loadPeopleEpisodeData: root.querySelector('#home-loadPeopleEpisodeData')?.checked === true + }; + syncEnsureThumbsOnPopularStudios(currentConfig); + currentConfig.USER_HOME_SCREEN_SETTINGS = { + ...(currentConfig.USER_HOME_SCREEN_SETTINGS || {}), + inlineConfigure: root.querySelector('#userHome-inlineConfigure')?.checked !== false, + pinning: root.querySelector('#userHome-pinning')?.checked !== false, + pairNativeHomeSections: root.querySelector('#userHome-pairNativeHomeSections')?.checked !== false + }; + if (root.querySelector('#libraryCache-mdblistApiKey')) { + currentConfig.LIBRARY_CACHE = { + ...(currentConfig.LIBRARY_CACHE || {}), + mdblistApiKey: root.querySelector('#libraryCache-mdblistApiKey')?.value || '' + }; + } + + const nextLoadEpisodes = currentConfig.HOME_SETTINGS.loadPeopleEpisodeData === true; + if (prevLoadPeopleEpisodeData !== nextLoadEpisodes && window.PeopleCache?.invalidate) { + window.PeopleCache.invalidate(); + } + } + + if (root.querySelector('#cache-DEFAULT_TTL-preset') + || root.querySelector('#cache-VERY_SHORT_TTL') + || root.querySelector('#cache-globalCacheTtl-preset')) { + const defaultTtl = resolveDefaultTtlMsFromForm(root); + const nextCache = { + ...(currentConfig.CACHE || {}), + DEFAULT_TTL: defaultTtl != null ? defaultTtl : (currentConfig.CACHE?.DEFAULT_TTL ?? 1800000), + VERY_SHORT_TTL: minutesToMs(parseInt(root.querySelector('#cache-VERY_SHORT_TTL')?.value || '1', 10)), + SHORT_TTL: minutesToMs(parseInt(root.querySelector('#cache-SHORT_TTL')?.value || '5', 10)), + LONG_TTL: minutesToMs(parseInt(root.querySelector('#cache-LONG_TTL')?.value || '1440', 10)), + STATIC_TTL: minutesToMs(parseInt(root.querySelector('#cache-STATIC_TTL')?.value || '10080', 10)), + DISCOVERY_TTL: minutesToMs(parseInt(root.querySelector('#cache-DISCOVERY_TTL')?.value || '60', 10)), + FORCE_REFRESH_TTL: 0 + }; + if (root.querySelector('#cache-globalCacheTtl-preset')) { + const globalTtl = resolveGlobalCacheTtlMsFromForm(root); + if (globalTtl === undefined) { + delete nextCache.globalCacheTtl; + } else if (globalTtl != null) { + nextCache.globalCacheTtl = globalTtl; + } + } + currentConfig.CACHE = nextCache; + } + + if (root.querySelector('#spotlight-cycleBackdrops') || root.querySelector('[id^="spotlight-"]')) { + const spotlight = se().collectSpotlightConfigFromDialog?.(root, 'spotlight-', { useIntervalSeconds: true }); + if (spotlight && typeof spotlight === 'object') { + currentConfig.SPOTLIGHT_SETTINGS = spotlight; + } + } + + if (!save) return; + + const runSave = () => saveConfig(currentConfig); + if (immediate) { + clearTimeout(globalSettingsSaveTimer); + globalSettingsSaveTimer = null; + return runSave(); + } + clearTimeout(globalSettingsSaveTimer); + globalSettingsSaveTimer = setTimeout(() => { + globalSettingsSaveTimer = null; + runSave(); + }, 300); + } + + /** @deprecated Prefer applyGlobalSettingsFromForm — kept for callers that expect a return object */ + function collectGlobalSettings() { + applyGlobalSettingsFromForm({ save: false }); + return { + DISCOVERY_SETTINGS: currentConfig?.DISCOVERY_SETTINGS, + SEASONAL_THEME_SETTINGS: currentConfig?.SEASONAL_THEME_SETTINGS, + CACHE: currentConfig?.CACHE, + SPOTLIGHT_SETTINGS: currentConfig?.SPOTLIGHT_SETTINGS, + HOME_SETTINGS: currentConfig?.HOME_SETTINGS, + USER_HOME_SCREEN_SETTINGS: currentConfig?.USER_HOME_SCREEN_SETTINGS, + LIBRARY_CACHE: currentConfig?.LIBRARY_CACHE + }; + } + + const GROUP_TYPE_BY_SECTION_TYPE = { + home: 'HOME_SECTION_GROUPS', + seasonal: 'SEASONAL_SECTION_GROUPS', + discovery: 'DISCOVERY_SECTION_GROUPS', + custom: 'CUSTOM_SECTION_GROUPS' + }; + + function resolveEditorKind() { + return 'advanced'; + } + + function buildGroupContext(section) { + const found = findSectionInAllGroups(currentConfig, section.id); + let currentGroupName = found?.group?.name || ''; + if (!currentGroupName && section._targetGroupName && section._targetGroupName !== 'New...') { + currentGroupName = section._targetGroupName; + } + const allGroups = currentConfig.CUSTOM_SECTION_GROUPS || []; + const existingGroupNames = [...new Set(allGroups.map(g => g.name).filter(Boolean))]; + return { currentGroupName, existingGroupNames, found }; + } + + /** + * Refresh main modal content + */ + + async function openSectionEditor(section, sectionType = 'home', options = {}) { + const found = findSectionInAllGroups(currentConfig, section.id); + const isExisting = !!found; + const isNewCustom = sectionType === 'custom' && !isExisting + && !window.KefinHomeScreenEditorProfiles?.isKnownDefaultSection(section.id); + + const editCtxBase = { + sectionType, + config: currentConfig, + onSave: async (sectionData, saveOptions = {}) => { + const targetGroupName = sectionData._targetGroupName; + delete sectionData._targetGroupName; + const quiet = saveOptions?.quiet === true; + + const found = findSectionInAllGroups(currentConfig, sectionData.id); + const savingToCustom = !found || found.groupType === 'CUSTOM_SECTION_GROUPS' + || (targetGroupName && !found); + if (savingToCustom || sectionData.isCustom) { + sectionData.isCustom = true; + } + const surface = String(sectionData.type || '').toLowerCase(); + if (!['home', 'seasonal', 'discovery'].includes(surface)) { + if (sectionData.discoveryEnabled === true || sectionData.discoveryType) { + sectionData.type = 'discovery'; + } else if (sectionData.startDate && sectionData.endDate) { + sectionData.type = 'seasonal'; + } else { + sectionData.type = 'home'; + } + } + + if (found) { + const sectionTypeForRow = GROUP_TYPE_TO_SECTION_TYPE[found.groupType] || sectionData.type || 'home'; + if (targetGroupName && targetGroupName !== found.group.name) { + found.group.sections.splice(found.sectionIndex, 1); + addSectionToGroup(currentConfig[found.groupType], targetGroupName, sectionData); + await saveConfig(currentConfig); + if (!quiet) showToast('Section saved'); + refreshMainModal({ + focusSectionId: sectionData.id, + sectionType: sectionTypeForRow + }); + } else { + updateSectionInGroups(currentConfig[found.groupType], sectionData.id, sectionData); + await saveConfig(currentConfig); + if (!quiet) showToast('Section saved'); + if (updateSectionRowInPlace(sectionData, sectionTypeForRow)) { + scrollSectionRowIntoView(sectionData.id); + } else { + refreshMainModal({ + focusSectionId: sectionData.id, + sectionType: sectionTypeForRow + }); + } + } + } else { + addSectionToGroup(currentConfig.CUSTOM_SECTION_GROUPS, targetGroupName || 'Custom Sections', sectionData); + await saveConfig(currentConfig); + if (!quiet) showToast('Saved new Home Screen Section'); + refreshMainModal({ + focusSectionId: sectionData.id, + sectionType: 'custom' + }); + } + + if (options.refreshHomeOnSave && !mainModalInstance) { + try { + await window.homeScreen3?.refreshHomeSectionById?.(sectionData.id, sectionData); + } catch (e) { + WARN('Failed to refresh home section after save:', e); + } + } + }, + onPreview: async (sectionData, type, btn) => { + await previewSection(sectionData, type, btn); + } + }; + + if (isNewCustom && window.KefinHomeScreenSectionEditor?.openCreateSection) { + return window.KefinHomeScreenSectionEditor.openCreateSection(section, editCtxBase); + } + + if (!window.KefinHomeScreenSectionEditor?.openEditSection) { + showToast('Section editor not available'); + return; + } + + const originalSection = JSON.parse(JSON.stringify(section)); + const groupType = found?.groupType || GROUP_TYPE_BY_SECTION_TYPE[sectionType] || null; + const isCustomSection = groupType === 'CUSTOM_SECTION_GROUPS'; + const editorProfile = window.KefinHomeScreenEditorProfiles?.resolveEditorProfile(section, { + groupType, + isCustomSection + }) || 'full'; + const lockVisibility = window.KefinHomeScreenEditorProfiles?.getEditorProfileDefinition(editorProfile)?.lockVisibility === true; + + return window.KefinHomeScreenSectionEditor.openEditSection(section, { + ...editCtxBase, + editorKind: resolveEditorKind(), + editorProfile, + groupType, + lockVisibility, + originalSection, + groupContext: buildGroupContext(section) + }); + } + + const GROUP_TYPE_TO_SECTION_TYPE = { + HOME_SECTION_GROUPS: 'home', + SEASONAL_SECTION_GROUPS: 'seasonal', + DISCOVERY_SECTION_GROUPS: 'discovery', + CUSTOM_SECTION_GROUPS: 'custom' + }; + + /** + * Whether the Section Editor can open this section (admin + in config + not pinned). + * @param {string} sectionId + * @returns {Promise} + */ + async function canOpenSectionEditor(sectionId) { + if (!sectionId || String(sectionId).startsWith('pinned-')) return false; + try { + const admin = await window.apiHelper?.isAdmin?.(); + if (!admin) return false; + } catch { + return false; + } + getConfig(); + return !!findSectionInAllGroups(currentConfig, sectionId); + } + + /** + * Open the Section Editor for a section id (same path as clicking a row in admin config). + * @param {string} sectionId + * @returns {Promise} + */ + async function openSectionEditorForId(sectionId) { + if (!(await canOpenSectionEditor(sectionId))) return false; + getConfig(); + const found = findSectionInAllGroups(currentConfig, sectionId); + if (!found?.section) return false; + const sectionType = GROUP_TYPE_TO_SECTION_TYPE[found.groupType] || 'home'; + await openSectionEditor(found.section, sectionType, { refreshHomeOnSave: true }); + return true; + } + + + function escapeSectionIdForSelector(sectionId) { + if (typeof CSS !== 'undefined' && CSS.escape) { + return CSS.escape(sectionId); + } + return String(sectionId).replace(/["\\]/g, '\\$&'); + } + + /** + * Scroll a .section-row into view inside #section-content (the Edit Sections scrollport). + * @param {string} sectionId + * @returns {boolean} + */ + function scrollSectionRowIntoView(sectionId) { + if (!mainModalInstance || !sectionId) return false; + const root = mainModalInstance.dialogContainer || mainModalInstance.dialogContent; + if (!root) return false; + + const content = root.querySelector('#section-content'); + if (!content) return false; + + const escapedId = escapeSectionIdForSelector(sectionId); + const row = content.querySelector(`.section-row[data-section-id="${escapedId}"]`); + if (!row) return false; + + const contentRect = content.getBoundingClientRect(); + const rowRect = row.getBoundingClientRect(); + const padding = 16; + if (rowRect.top < contentRect.top + padding) { + content.scrollTop += rowRect.top - contentRect.top - padding; + } else if (rowRect.bottom > contentRect.bottom - padding) { + content.scrollTop += rowRect.bottom - contentRect.bottom + padding; + } + return true; + } + + /** + * Replace a single .section-row in the main modal without full refresh (preserves scroll). + * @returns {boolean} true if row was updated + */ + function updateSectionRowInPlace(sectionData, sectionType = 'home') { + if (!mainModalInstance?.dialogContent || !sectionData?.id) return false; + const escapedId = escapeSectionIdForSelector(sectionData.id); + const row = mainModalInstance.dialogContent.querySelector(`.section-row[data-section-id="${escapedId}"]`); + if (!row) return false; + + const type = sectionType || row.dataset.sectionType || 'home'; + const wrap = document.createElement('div'); + wrap.innerHTML = buildSectionRowHTML(sectionData, type).trim(); + const newRow = wrap.firstElementChild; + if (!newRow) return false; + row.replaceWith(newRow); + return true; + } + + /** + * Refresh main modal content. + * @param {{ focusSectionId?: string, sectionType?: string }} [options] + */ + function refreshMainModal(options = {}) { + if (!mainModalInstance) return; + + const focusSectionId = options.focusSectionId || null; + const focusSectionType = options.sectionType || null; + + // Store current section type if on sections tab + let currentSectionType = 'home'; + if (currentActiveTab === 'sections') { + const activeNavBtn = mainModalInstance.dialogContent.querySelector('.section-type-nav-btn.active'); + if (activeNavBtn) { + currentSectionType = activeNavBtn.dataset.sectionType; + } + } + if (focusSectionType) { + currentSectionType = focusSectionType; + } + + // Store current active tab before refresh + const savedActiveTab = focusSectionId + ? 'sections' + : normalizeActiveTab(currentActiveTab); + + const sectionContentEl = mainModalInstance.dialogContent?.querySelector('#section-content'); + const sectionScrollTop = sectionContentEl?.scrollTop ?? 0; + + const content = document.createElement('div'); + content.innerHTML = buildMainConfigHTML(); + mainModalInstance.updateContent(content); + + // Reset the listeners flag so they can be re-attached to the new content +/* if (mainModalInstance.dialog) { + delete mainModalInstance.dialog.dataset.mainModalListenersAttached; + delete mainModalInstance.dialog.dataset.sectionListenersAttached; + } + + // Re-attach event listeners (including tab listeners) + attachMainModalListeners(); + attachTabListeners(mainModalInstance); */ + + // Restore active tab + if (savedActiveTab) { + const dialog = mainModalInstance.dialogContainer; + if (dialog) { + const tabBtn = dialog.querySelector(`.config-tab-btn[data-tab="${savedActiveTab}"]`); + if (tabBtn) { + tabBtn.click(); + } + } + } + + // If we were on sections tab (or focusing a section), restore the section type navigation + if (savedActiveTab === 'sections' || currentActiveTab === 'sections') { + const dialog = mainModalInstance.dialogContainer; + const navBtns = dialog.querySelectorAll('.section-type-nav-btn'); + navBtns.forEach(btn => { + if (btn.dataset.sectionType === currentSectionType) { + btn.click(); + } + }); + } + + const restoreOrFocus = () => { + if (focusSectionId) { + scrollSectionRowIntoView(focusSectionId); + return; + } + if (savedActiveTab === 'sections' || currentActiveTab === 'sections') { + const el = mainModalInstance?.dialogContent?.querySelector('#section-content'); + if (el) el.scrollTop = sectionScrollTop; + } + }; + restoreOrFocus(); + requestAnimationFrame(restoreOrFocus); + } + + + /** + * Render home sections order editor + * @param {Array} sections - Flattened array of sections from HOME_SECTIONS, SEASONAL_SECTIONS, CUSTOM_SECTIONS + */ + function renderHomeSectionsOrderEditor(sections) { + if (!sections || !Array.isArray(sections)) { + return '
No sections found.
'; + } + + const filteredSections = sections.filter(s => !s.hidden); + + // Verify sections are ordered by .order value and sort if needed + const sortedSections = [...filteredSections].sort((a, b) => { + const orderA = a.order || 0; + const orderB = b.order || 0; + return orderA - orderB; + }); + + if (sortedSections.length === 0) { + return '
No sections to order.
'; + } + + // Create a dummy group with all sections to reuse buildGroupHTML + const dummyGroup = { + name: 'All Home Sections', + sections: sortedSections, + author: '' + }; + + return ` + ${buildGroupHTML(dummyGroup, 'all')} + `; + } + + + /** + * Mount / refresh the Benchmark tab UI (cancels any in-flight run). + */ + function initBenchmarkTab(modalInstance) { + const dialog = modalInstance?.dialogContainer || modalInstance?.dialogContent; + const root = dialog?.querySelector('#home-screen-benchmark-root'); + if (!root) return; + if (window.KefinHomeScreenBenchmark?.cancelActiveRun) { + window.KefinHomeScreenBenchmark.cancelActiveRun(true); + } + if (window.KefinHomeScreenBenchmark?.renderHomeScreenBenchmarkUI) { + window.KefinHomeScreenBenchmark.renderHomeScreenBenchmarkUI(root, { + getConfig: () => currentConfig + }); + } else { + root.innerHTML = '

Benchmark module not loaded.

'; + } + } + + /** + * Attach tab switching listeners + */ + function attachTabListeners(modalInstance) { + const dialog = modalInstance.dialogContainer; + if (!dialog) return; + + // Use event delegation to prevent stacking listeners when switching tabs + dialog.addEventListener('click', (e) => { + const btn = e.target.closest('.config-tab-btn'); + if (!btn) return; + const targetTab = btn.dataset.tab; + + // Persist Global Settings before leaving the settings tab / switching away + if (currentActiveTab === 'settings' && targetTab !== 'settings') { + applyGlobalSettingsFromForm({ save: true, immediate: true }); + } + + // Update the current active tab tracker + currentActiveTab = normalizeActiveTab(targetTab); + + const tabButtons = dialog.querySelectorAll('.config-tab-btn'); + const tabContents = dialog.querySelectorAll('.config-tab-content'); + + // Update button states + tabButtons.forEach(b => { + b.classList.toggle('active', b === btn); + }); + + // Update tab content visibility + tabContents.forEach(content => { + content.style.display = 'none'; + }); + const targetContent = dialog.querySelector(`#tab-${currentActiveTab}`); + if (targetContent) { + targetContent.style.display = 'block'; + + // Refresh tab content based on currentConfig when switching tabs + if (currentActiveTab === 'order') { + // Order Sections tab - refresh with current config + const container = targetContent.querySelector('#order-sections-container'); + if (container) { + const allSections = [ + ...flattenSectionGroups(currentConfig.HOME_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Default' })), + ...flattenSectionGroups(currentConfig.SEASONAL_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Seasonal' })), + ...flattenSectionGroups(currentConfig.CUSTOM_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Custom' })) + ]; + container.innerHTML = renderHomeSectionsOrderEditor(allSections); + } + } else if (currentActiveTab === 'sections') { + // Edit Sections tab - refresh section content based on current active section type + const contentContainer = dialog.querySelector('#section-content'); + if (contentContainer) { + // Get the currently active section type navigation button + const activeNavBtn = dialog.querySelector('.section-type-nav-btn.active'); + const activeType = activeNavBtn ? activeNavBtn.dataset.sectionType : 'home'; + + // Re-render the section content with current config + contentContainer.innerHTML = buildSectionContentHTML(activeType); + } + } else if (currentActiveTab === 'import-export') { + setupCommunityCollectionsTab(dialog); + } else if (currentActiveTab === 'troubleshoot') { + initBenchmarkTab(modalInstance); + } + } + }); + +/* const tabButtons = dialog.querySelectorAll('.config-tab-btn'); + const tabContents = dialog.querySelectorAll('.config-tab-content'); + + tabButtons.forEach(btn => { + btn.addEventListener('click', () => { + const targetTab = btn.dataset.tab; + + // Update the current active tab tracker + currentActiveTab = targetTab; + + // Update button states + tabButtons.forEach(b => { + b.classList.remove('active'); + b.style.borderBottomColor = 'transparent'; + b.style.color = 'rgba(255,255,255,0.7)'; + }); + btn.classList.add('active'); + btn.style.borderBottomColor = 'var(--theme-primary-color, #00a4dc)'; + btn.style.color = 'var(--theme-primary-color, #00a4dc)'; + + // Update tab content visibility + tabContents.forEach(content => { + content.style.display = 'none'; + }); + const targetContent = dialog.querySelector(`#tab-${targetTab}`); + if (targetContent) { + targetContent.style.display = 'block'; + + // Refresh tab content based on currentConfig when switching tabs + if (targetTab === 'order') { + // Order Sections tab - refresh with current config + const container = targetContent.querySelector('#order-sections-container'); + if (container) { + const allSections = [ + ...flattenSectionGroups(currentConfig.HOME_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Default' })), + ...flattenSectionGroups(currentConfig.SEASONAL_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Seasonal' })), + ...flattenSectionGroups(currentConfig.CUSTOM_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Custom' })) + ]; + container.innerHTML = renderHomeSectionsOrderEditor(allSections); + } + } else if (targetTab === 'sections') { + // Edit Sections tab - refresh section content based on current active section type + const contentContainer = dialog.querySelector('#section-content'); + if (contentContainer) { + // Get the currently active section type navigation button + const activeNavBtn = dialog.querySelector('.section-type-nav-btn.active'); + const activeType = activeNavBtn ? activeNavBtn.dataset.sectionType : 'home'; + + // Re-render the section content with current config + contentContainer.innerHTML = buildSectionContentHTML(activeType); + } + } + } + }); + }); */ + } + + /** + * Attach event listeners to main modal + */ + function syncDefaultTtlCustomRow(contentRoot) { + const root = contentRoot || document.querySelector(`.dialogContainer[data-modal-id="${MAIN_MODAL_ID}"] #global-settings-content`); + if (!root) return; + const preset = root.querySelector('#cache-DEFAULT_TTL-preset'); + const row = root.querySelector('#cache-DEFAULT_TTL-custom-row'); + if (!row || !preset) return; + row.style.display = preset.value === 'CUSTOM' ? 'grid' : 'none'; + } + + function syncGlobalCacheTtlCustomRow(contentRoot) { + const root = contentRoot || document.querySelector(`.dialogContainer[data-modal-id="${MAIN_MODAL_ID}"] #global-settings-content`); + if (!root) return; + const preset = root.querySelector('#cache-globalCacheTtl-preset'); + const row = root.querySelector('#cache-globalCacheTtl-custom-row'); + if (!row || !preset) return; + row.style.display = preset.value === 'CUSTOM' ? 'grid' : 'none'; + } + + function wireGlobalSettingsPanelListeners(contentRoot) { + if (!contentRoot) return; + syncDefaultTtlCustomRow(contentRoot); + syncGlobalCacheTtlCustomRow(contentRoot); + window.KefinTweaksUI?.bindToggleCards?.(contentRoot); + updateHscToggleCardHints(contentRoot, GENERAL_TOGGLE_DESCRIPTIONS); + updateHscToggleCardHints(contentRoot, DISCOVERY_TOGGLE_DESCRIPTIONS); + wireCategorySettingsListeners(contentRoot); + } + + function ensureGlobalSettingsLiveSync(dialog) { + if (!dialog || dialog.dataset.hscGlobalSettingsWired === 'true') return; + dialog.dataset.hscGlobalSettingsWired = 'true'; + + dialog.addEventListener('change', (e) => { + if (!e.target?.closest?.('#global-settings-content')) return; + if (e.target.id === 'cache-DEFAULT_TTL-preset') syncDefaultTtlCustomRow(); + if (e.target.id === 'cache-globalCacheTtl-preset') syncGlobalCacheTtlCustomRow(); + const content = dialog.querySelector('#global-settings-content'); + updateHscToggleCardHints(content, GENERAL_TOGGLE_DESCRIPTIONS); + updateHscToggleCardHints(content, DISCOVERY_TOGGLE_DESCRIPTIONS); + applyGlobalSettingsFromForm({ save: true }); + }); + dialog.addEventListener('input', (e) => { + if (!e.target?.closest?.('#global-settings-content')) return; + applyGlobalSettingsFromForm({ save: true }); + }); + dialog.addEventListener('click', (e) => { + const toggle = e.target.closest?.('.toggle-slider, .kefin-toggle-switch'); + if (!toggle || !toggle.closest('#global-settings-content')) return; + + if (toggle.classList.contains('kefin-toggle-switch')) { + const checkboxId = toggle.dataset.checkboxId; + const checkbox = checkboxId ? document.getElementById(checkboxId) : null; + if (checkbox && toggle.dataset.kefinToggleBound !== 'true') { + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + updateToggleSwitchUI(toggle, checkbox.checked); + } + } + + setTimeout(() => { + const content = dialog.querySelector('#global-settings-content'); + updateHscToggleCardHints(content, GENERAL_TOGGLE_DESCRIPTIONS); + updateHscToggleCardHints(content, DISCOVERY_TOGGLE_DESCRIPTIONS); + applyGlobalSettingsFromForm({ save: true }); + }, 0); + }); + } + + function openNewCustomSectionEditor(options = {}) { + getConfig(); + const defaultTtl = Number(currentConfig?.CACHE?.DEFAULT_TTL); + const newSection = { + id: `custom-${Date.now()}`, + name: 'New Custom Section', + enabled: true, + order: 100, + category: 'none', + cardFormat: 'Poster', + ...(Number.isFinite(defaultTtl) ? { ttl: defaultTtl } : {}), + queries: [{ + queryOptions: { + SortBy: 'Random', + Limit: window.KefinHomeScreenEditorConstants?.DEFAULT_SECTION_QUERY_LIMIT ?? 16 + } + }] + }; + openSectionEditor(newSection, 'custom', options); + } + + function applyOrderListFromForm(dialogRoot) { + const orderList = dialogRoot?.querySelector('#order-sections-list'); + if (!orderList) return; + const allRows = Array.from(orderList.querySelectorAll('.section-order-row')); + allRows.forEach((r, index) => { + const sectionId = r.dataset.sectionId; + if (!sectionId) return; + const found = findSectionInAllGroups(currentConfig, sectionId); + if (found?.section) { + found.section.order = (index + 1) * 10; + } + }); + } + + async function collectAndSaveConfig(modalInstance) { + clearTimeout(globalSettingsSaveTimer); + globalSettingsSaveTimer = null; + applyGlobalSettingsFromForm({ save: false }); + applyOrderListFromForm(modalInstance?.dialogContent); + await saveConfig(currentConfig); + } + + function buildSectionIdMapFromGroups(...groupLists) { + const map = new Map(); + groupLists.forEach((groups) => { + flattenSectionGroups(groups).forEach((section) => { + if (section?.id) map.set(section.id, section); + }); + }); + return map; + } + + function copyRetainKeys(target, source, keys) { + if (!target || !source) return; + keys.forEach((key) => { + if (Object.prototype.hasOwnProperty.call(source, key)) { + target[key] = JSON.parse(JSON.stringify(source[key])); + } + }); + } + + function copyQueryAgeFields(target, source) { + if (!target || !source) return; + const srcQuery = source.queries?.[0]; + if (!srcQuery || !Array.isArray(target.queries) || !target.queries[0]) return; + if (Object.prototype.hasOwnProperty.call(srcQuery, 'minAge')) { + target.queries[0].minAge = srcQuery.minAge; + } + if (Object.prototype.hasOwnProperty.call(srcQuery, 'maxAge')) { + target.queries[0].maxAge = srcQuery.maxAge; + } + } + + function applyRetainOptionsToGroups(groups, priorById, options) { + (groups || []).forEach((group) => { + (group.sections || []).forEach((section) => { + const prior = priorById.get(section.id); + if (!prior) return; + if (options.retainStatus) { + if (Object.prototype.hasOwnProperty.call(prior, 'enabled')) { + section.enabled = prior.enabled; + } + if (Object.prototype.hasOwnProperty.call(prior, 'userConfigurable')) { + section.userConfigurable = prior.userConfigurable; + } + } + if (options.retainNameCaption) { + copyRetainKeys(section, prior, ['name', 'caption']); + } + if (options.retainAppearance) { + copyRetainKeys(section, prior, RESTORE_APPEARANCE_KEYS); + } + if (options.retainAdvanced) { + copyRetainKeys(section, prior, RESTORE_ADVANCED_KEYS); + copyQueryAgeFields(section, prior); + } + }); + }); + } + + function stampGroupSectionTypes(groups, sectionType) { + (groups || []).forEach((group) => { + (group.sections || []).forEach((section) => { + section.type = sectionType; + }); + }); + } + + async function applyRestoreDefaults(options) { + const defaults = window.KefinHomeConfig2; + if (!defaults || !currentConfig) { + throw new Error('Home Screen defaults are not available.'); + } + + const priorById = buildSectionIdMapFromGroups( + currentConfig.HOME_SECTION_GROUPS, + currentConfig.SEASONAL_SECTION_GROUPS, + currentConfig.DISCOVERY_SECTION_GROUPS + ); + + const homeGroups = JSON.parse(JSON.stringify(defaults.HOME_SECTION_GROUPS || [])); + const seasonalGroups = JSON.parse(JSON.stringify(defaults.SEASONAL_SECTION_GROUPS || [])); + const discoveryGroups = JSON.parse(JSON.stringify(defaults.DISCOVERY_SECTION_GROUPS || [])); + + stampGroupSectionTypes(homeGroups, 'home'); + stampGroupSectionTypes(seasonalGroups, 'seasonal'); + stampGroupSectionTypes(discoveryGroups, 'discovery'); + + applyRetainOptionsToGroups(homeGroups, priorById, options); + applyRetainOptionsToGroups(seasonalGroups, priorById, options); + applyRetainOptionsToGroups(discoveryGroups, priorById, options); + + currentConfig.HOME_SECTION_GROUPS = homeGroups; + currentConfig.SEASONAL_SECTION_GROUPS = seasonalGroups; + currentConfig.DISCOVERY_SECTION_GROUPS = discoveryGroups; + if (!options.retainCustoms) { + currentConfig.CUSTOM_SECTION_GROUPS = []; + } + + // Pre-sync so saveConfig merge does not reintroduce removed sections + if (!window.KefinTweaksConfig) window.KefinTweaksConfig = {}; + if (!window.KefinTweaksConfig.homeScreenConfig) window.KefinTweaksConfig.homeScreenConfig = {}; + const saved = window.KefinTweaksConfig.homeScreenConfig; + saved.HOME_SECTION_GROUPS = JSON.parse(JSON.stringify(homeGroups)); + saved.SEASONAL_SECTION_GROUPS = JSON.parse(JSON.stringify(seasonalGroups)); + saved.DISCOVERY_SECTION_GROUPS = JSON.parse(JSON.stringify(discoveryGroups)); + if (!options.retainCustoms) { + saved.CUSTOM_SECTION_GROUPS = []; + } + + await saveConfig(currentConfig); + refreshMainModal(); + showToast('Home Screen defaults restored'); + } + + function confirmAndRestoreDefaults() { + if (!window.ModalSystem) { + ERR('ModalSystem not available for restore defaults'); + return; + } + if (window.ModalSystem.isOpen(RESTORE_DEFAULTS_MODAL_ID)) { + window.ModalSystem.close(RESTORE_DEFAULTS_MODAL_ID); + } + + const D = RESTORE_RETAIN_TOGGLE_DESCRIPTIONS; + const content = document.createElement('div'); + content.innerHTML = ` +
+ Restoring Defaults for KefinTweaks Home Screen will update the existing Home Screen configuration. +
+
+ ${hscToggleCard('restore-retain-status', true, 'Retain Section Status', D)} + ${hscToggleCard('restore-retain-name-caption', true, 'Retain Section Name and Caption', D)} + ${hscToggleCard('restore-retain-appearance', true, 'Retain Section Appearance', D)} + ${hscToggleCard('restore-retain-advanced', true, 'Retain Section Advanced Options', D)} +
+ ${hscToggleCard('restore-retain-customs', true, 'Retain Custom Sections', D)} +
+
+ `; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.5em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + window.ModalSystem.create({ + id: RESTORE_DEFAULTS_MODAL_ID, + title: 'Restore Defaults', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + fixedSize: true, + onOpen: (modal) => { + const root = modal.dialogContent || content; + window.KefinTweaksUI?.bindToggleCards?.(root); + updateHscToggleCardHints(root, RESTORE_RETAIN_TOGGLE_DESCRIPTIONS); + root.addEventListener('change', (e) => { + if (e.target?.matches?.('input[type="checkbox"][id^="restore-retain-"]')) { + updateHscToggleCardHints(root, RESTORE_RETAIN_TOGGLE_DESCRIPTIONS); + } + }); + + modal.dialogFooter?.querySelector('#hsc-restore-defaults-cancel')?.addEventListener('click', () => { + window.ModalSystem.close(RESTORE_DEFAULTS_MODAL_ID); + }); + modal.dialogFooter?.querySelector('#hsc-restore-defaults-confirm')?.addEventListener('click', async () => { + const confirmBtn = modal.dialogFooter.querySelector('#hsc-restore-defaults-confirm'); + if (confirmBtn) confirmBtn.disabled = true; + try { + const options = { + retainStatus: root.querySelector('#restore-retain-status')?.checked !== false, + retainNameCaption: root.querySelector('#restore-retain-name-caption')?.checked !== false, + retainAppearance: root.querySelector('#restore-retain-appearance')?.checked !== false, + retainAdvanced: root.querySelector('#restore-retain-advanced')?.checked !== false, + retainCustoms: root.querySelector('#restore-retain-customs')?.checked !== false + }; + await applyRestoreDefaults(options); + window.ModalSystem.close(RESTORE_DEFAULTS_MODAL_ID); + } catch (e) { + ERR('Error restoring Home Screen defaults:', e); + showToast('Error restoring defaults'); + } finally { + if (confirmBtn) confirmBtn.disabled = false; + } + }); + } + }); + } + + function getServerEnabledSectionsForUpdatePreview() { + const config = currentConfig || getConfig(); + if (!config) return []; + const inSeason = (s) => (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true); + const enabledFrom = (groups) => flattenSectionGroups(groups || []).filter( + (s) => s.enabled === true && s.discoveryEnabled !== true && inSeason(s) + ); + return [ + ...enabledFrom(config.HOME_SECTION_GROUPS), + ...enabledFrom(config.SEASONAL_SECTION_GROUPS), + ...enabledFrom(config.CUSTOM_SECTION_GROUPS) + ]; + } + + function confirmAndUpdateAllUsers() { + if (!window.ModalSystem) { + ERR('ModalSystem not available for update confirmation'); + return; + } + if (window.ModalSystem.isOpen(UPDATE_ALL_CONFIRM_MODAL_ID)) { + window.ModalSystem.close(UPDATE_ALL_CONFIRM_MODAL_ID); + } + + // Reflect current order UI before previewing native slots + try { + applyOrderListFromForm(mainModalInstance?.dialogContent); + } catch (_) { /* preview best-effort */ } + + const serverEnabled = getServerEnabledSectionsForUpdatePreview(); + const slots = window.KefinUserHomeScreenConfig?.buildHomesectionSlotsFromKefin?.(serverEnabled) || []; + const pairedSlotPreview = slots.filter((id) => id && id !== 'none').join(', ') || '(none)'; + + const content = document.createElement('div'); + content.innerHTML = ` +
+ WARNING: This will forcefully update the KefinTweaks Home Screen user preferences for all accounts on your server. Any changes those users have made to customize their home screen will be lost. +
+
+ Users with Pair Home Sections on will get these native Jellyfin slots: + ${pairedSlotPreview} +
+
+ Users with Pair Home Sections off get all native slots set to none. +
+ `; + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.5em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + window.ModalSystem.create({ + id: UPDATE_ALL_CONFIRM_MODAL_ID, + title: 'Update All Users', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + fixedSize: false, + dialogStyle: { + width: 'min(28rem, calc(100vw - 2rem))', + maxWidth: '28rem' + }, + onOpen: (modal) => { + modal.dialogFooter?.querySelector('#hsc-update-all-cancel')?.addEventListener('click', () => { + window.ModalSystem.close(UPDATE_ALL_CONFIRM_MODAL_ID); + }); + modal.dialogFooter?.querySelector('#hsc-update-all-confirm')?.addEventListener('click', async () => { + const confirmBtn = modal.dialogFooter.querySelector('#hsc-update-all-confirm'); + if (confirmBtn) confirmBtn.disabled = true; + try { + await collectAndSaveConfig(mainModalInstance); + window.ModalSystem.close(UPDATE_ALL_CONFIRM_MODAL_ID); + showToast('Configuration saved. Updating users…'); + await window.KefinUserHomeScreenConfig?.updateUserHomeScreenConfiguration?.(); + } catch (e) { + ERR('Error updating configuration for all users:', e); + showToast('Error updating users'); + } finally { + if (confirmBtn) confirmBtn.disabled = false; + } + }); + } + }); + } + + function attachMainModalListeners(modalInstance) { + const dialog = modalInstance?.dialogContainer ?? document.querySelector(`.dialogContainer[data-modal-id="${MAIN_MODAL_ID}"]`); + if (!dialog) return; + + if (dialog.dataset.mainModalListenersAttached === 'true') { + return; // Listeners already attached, skip + } + dialog.dataset.mainModalListenersAttached = 'true'; + + // Attach initial section row listeners + attachSectionRowListeners(dialog); + + // Group toggle-all buttons + // Use event delegation to prevent stacking listeners when switching tabs + dialog.addEventListener('click', async (e) => { + const btn = e.target.closest('.group-toggle-all-btn'); + if (!btn) return; + + e.stopPropagation(); + const groupId = btn.dataset.groupId; + const currentEnabled = btn.dataset.enabled === 'true'; + const newEnabled = !currentEnabled; + + const groupElement = dialog.querySelector(`.section-group[data-group-id="${groupId}"]`); + if (!groupElement) return; + + const sectionsList = groupElement.querySelector('.group-sections-list'); + if (!sectionsList) return; + + // Find all sections in this group and update them + const sectionRows = sectionsList.querySelectorAll('.section-row'); + const sectionIds = Array.from(sectionRows).map(row => row.dataset.sectionId); + + // Update all sections in the group + sectionIds.forEach(sectionId => { + const found = findSectionInAllGroups(currentConfig, sectionId); + if (found && found.section) { + found.section.enabled = newEnabled; + } + }); + + await saveConfig(currentConfig); + + // Update UI + btn.dataset.enabled = newEnabled; + btn.querySelector('span').textContent = newEnabled ? 'Disable All' : 'Enable All'; + + // Update all toggle switches in the group + sectionRows.forEach(row => { + const toggleBtn = row.querySelector('.toggle-slider'); + if (toggleBtn) { + updateToggleSliderUI(toggleBtn, newEnabled); + } + }); + }); + /* dialog.querySelectorAll('.group-toggle-all-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const groupId = btn.dataset.groupId; + const currentEnabled = btn.dataset.enabled === 'true'; + const newEnabled = !currentEnabled; + + const groupElement = dialog.querySelector(`.section-group[data-group-id="${groupId}"]`); + if (!groupElement) return; + + const sectionsList = groupElement.querySelector('.group-sections-list'); + if (!sectionsList) return; + + // Find all sections in this group and update them + const sectionRows = sectionsList.querySelectorAll('.section-row'); + const sectionIds = Array.from(sectionRows).map(row => row.dataset.sectionId); + + // Update all sections in the group + sectionIds.forEach(sectionId => { + const found = findSectionInAllGroups(currentConfig, sectionId); + if (found && found.section) { + found.section.enabled = newEnabled; + } + }); + + await saveConfig(currentConfig); + + // Update UI + btn.dataset.enabled = newEnabled; + btn.querySelector('span').textContent = newEnabled ? 'Disable All' : 'Enable All'; + + // Update all toggle switches in the group + sectionRows.forEach(row => { + const toggleBtn = row.querySelector('.toggle-slider'); + if (toggleBtn) { + updateToggleSliderUI(toggleBtn, newEnabled); + } + }); + }); + }); */ + + // Add custom section button - use event delegation + dialog.addEventListener('click', (e) => { + const btn = e.target.closest('.add-custom-section-btn') || e.target.closest('.section-type-nav-btn[data-section-type="create"]'); + if (!btn) return; + openNewCustomSectionEditor(); + }); + + // Section type toggle buttons - use event delegation + dialog.addEventListener('click', (e) => { + const btn = e.target.closest('.section-type-toggle-btn'); + if (!btn) return; + toggleSectionType(e); + }); + + // Section type navigation buttons + // Use event delegation to prevent stacking listeners when switching tabs + dialog.addEventListener('click', (e) => { + const btn = e.target.closest('.section-type-nav-btn:not([data-section-type="create"])'); + if (!btn) return; + const sectionType = btn.dataset.sectionType; + + // Update active button + dialog.querySelectorAll('.section-type-nav-btn').forEach(b => { + b.classList.remove('active'); + b.style.background = 'rgba(255,255,255,0.05)'; + b.style.borderColor = 'rgba(255,255,255,0.1)'; + b.style.color = 'inherit'; + const label = b.querySelector('.listItemBodyText'); + if (label) label.style.fontWeight = '400'; + }); + btn.classList.add('active'); + btn.style.background = 'rgba(0, 164, 220, 0.2)'; + btn.style.borderColor = 'rgba(0, 164, 220, 0.5)'; + btn.style.color = 'var(--theme-primary-color, #00a4dc)'; + const label = btn.querySelector('.listItemBodyText'); + if (label) label.style.fontWeight = '500'; + + // Update content + const contentContainer = dialog.querySelector('#section-content'); + if (contentContainer) { + contentContainer.innerHTML = buildSectionContentHTML(sectionType); + } + }); + + // Global Settings sub-tab navigation (left nav in Global Settings tab) + dialog.addEventListener('click', (e) => { + const btn = e.target.closest('.global-settings-nav-btn'); + if (!btn) return; + const subTab = btn.dataset.globalSettingsTab; + if (!subTab) return; + + applyGlobalSettingsFromForm({ save: true, immediate: true }); + + currentGlobalSettingsSubTab = subTab; + + dialog.querySelectorAll('.global-settings-nav-btn').forEach(b => { + b.classList.remove('active'); + b.style.background = 'rgba(255,255,255,0.05)'; + b.style.borderColor = 'rgba(255,255,255,0.1)'; + b.style.color = 'inherit'; + const label = b.querySelector('.listItemBodyText'); + if (label) label.style.fontWeight = '400'; + }); + btn.classList.add('active'); + btn.style.background = 'rgba(0, 164, 220, 0.2)'; + btn.style.borderColor = 'rgba(0, 164, 220, 0.5)'; + btn.style.color = 'var(--theme-primary-color, #00a4dc)'; + const label = btn.querySelector('.listItemBodyText'); + if (label) label.style.fontWeight = '500'; + + const contentContainer = dialog.querySelector('#global-settings-content'); + if (contentContainer) { + contentContainer.innerHTML = buildGlobalSettingsPanelContent(subTab); + if (subTab === 'spotlight') se().attachGlobalSpotlightSettings?.(contentContainer, currentConfig); + wireGlobalSettingsPanelListeners(contentContainer); + } + }); + + ensureGlobalSettingsLiveSync(dialog); + + const globalSettingsContent = dialog.querySelector('#global-settings-content'); + if (globalSettingsContent) { + if (currentGlobalSettingsSubTab === 'spotlight') { + se().attachGlobalSpotlightSettings?.(globalSettingsContent, currentConfig); + } + wireGlobalSettingsPanelListeners(globalSettingsContent); + } + + dialog.addEventListener('click', (e) => { + if (e.target.closest('.hsc-toolbar-new-section-btn')) { + openNewCustomSectionEditor(); + return; + } + if (e.target.closest('.hsc-toolbar-update-btn')) { + confirmAndUpdateAllUsers(); + return; + } + if (e.target.closest('.hsc-toolbar-restore-defaults-btn')) { + confirmAndRestoreDefaults(); + } + }); + /* dialog.querySelectorAll('.section-type-nav-btn').forEach(btn => { + btn.addEventListener('click', () => { + const sectionType = btn.dataset.sectionType; + + // Update active button + dialog.querySelectorAll('.section-type-nav-btn').forEach(b => { + b.classList.remove('active'); + b.style.background = 'rgba(255,255,255,0.05)'; + b.style.borderColor = 'rgba(255,255,255,0.1)'; + b.style.color = 'inherit'; + const label = b.querySelector('.listItemBodyText'); + if (label) label.style.fontWeight = '400'; + }); + btn.classList.add('active'); + btn.style.background = 'rgba(0, 164, 220, 0.2)'; + btn.style.borderColor = 'rgba(0, 164, 220, 0.5)'; + btn.style.color = 'var(--theme-primary-color, #00a4dc)'; + const label = btn.querySelector('.listItemBodyText'); + if (label) label.style.fontWeight = '500'; + + // Update content + const contentContainer = dialog.querySelector('#section-content'); + if (contentContainer) { + contentContainer.innerHTML = buildSectionContentHTML(sectionType); + } + }); + }); */ + + // Community Collections tab setup + setupCommunityCollectionsTab(dialog); + + // Import / Export — delegated so buttons survive refreshMainModal content swaps + dialog.addEventListener('click', (e) => { + if (e.target.closest('.export-sections-btn')) { + e.preventDefault(); + showExportDialog(); + return; + } + if (e.target.closest('.import-sections-btn')) { + e.preventDefault(); + showImportDialog(); + } + }); + } + + /** + * Get section order value from config by section ID + */ + function getSectionOrder(sectionId) { + const found = findSectionInAllGroups(currentConfig, sectionId); + return found?.section?.order || 0; + } + + /** + * Update section position in place without refreshing the entire modal + * This preserves scroll position and provides a smoother UX + */ + function updateSectionPositionInPlace(draggedElement, container) { + if (!draggedElement || !container) return; + + // Get all section rows in the container + const allRows = Array.from(container.querySelectorAll('.section-row')); + if (allRows.length === 0) return; + + // Store scroll position - try to find the scrollable parent + const scrollableParent = container.closest('.dialogContent') || container.closest('.dialog') || container.parentElement; + const scrollTop = scrollableParent?.scrollTop || 0; + + // Sort rows by their order value from config + allRows.sort((a, b) => { + const aId = a.dataset.sectionId; + const bId = b.dataset.sectionId; + const aOrder = getSectionOrder(aId); + const bOrder = getSectionOrder(bId); + return aOrder - bOrder; + }); + + // Re-append rows in sorted order and update order display + allRows.forEach(row => { + container.appendChild(row); + + // Update the order display in the row + const sectionId = row.dataset.sectionId; + const order = getSectionOrder(sectionId); + + // Find all secondary text spans and update the one that shows Order + const secondarySpans = row.querySelectorAll('.listItemBodyText.secondary'); + secondarySpans.forEach(span => { + const text = span.textContent.trim(); + if (text.startsWith('Order:')) { + span.textContent = `Order: ${order}`; + } + }); + }); + + // Restore scroll position + if (scrollableParent && scrollableParent.scrollTop !== undefined) { + scrollableParent.scrollTop = scrollTop; + } + } + + /** + * Calculate new order value based on drag position using DOM elements + * New logic: if section after dropped position exists, use its order - 1; if not, use preceding section order + 1 + */ + function calculateNewOrder(draggedElement, targetElement, dropPosition) { + const container = targetElement.parentElement; + const allRows = Array.from(container.querySelectorAll('.section-row')); + + // Find the target's position in the DOM + const targetIndex = allRows.indexOf(targetElement); + + if (dropPosition === 'above') { + // Dropped above target element + if (targetIndex === 0) { + // Dropped at the beginning - use first section order - 1 + const firstSectionId = targetElement.dataset.sectionId; + const firstOrder = getSectionOrder(firstSectionId); + return Math.max(0, firstOrder - 1); + } else { + // Dropped between elements - use target's order - 1 (section after dropped position) + const targetSectionId = targetElement.dataset.sectionId; + const targetOrder = getSectionOrder(targetSectionId); + return Math.max(0, targetOrder - 1); + } + } else { + // dropPosition === 'below' + if (targetIndex === allRows.length - 1) { + // Dropped at the end - use last section order + 1 (preceding section + 1) + const lastSectionId = targetElement.dataset.sectionId; + const lastOrder = getSectionOrder(lastSectionId); + return lastOrder + 1; + } else { + // Dropped between elements - check if section after exists + const nextElement = allRows[targetIndex + 1]; + if (nextElement && nextElement !== draggedElement) { + // Section after exists - use its order - 1 + const nextSectionId = nextElement.dataset.sectionId; + const nextOrder = getSectionOrder(nextSectionId); + return Math.max(0, nextOrder - 1); + } else { + // No section after - use preceding section (target) order + 1 + const targetSectionId = targetElement.dataset.sectionId; + const targetOrder = getSectionOrder(targetSectionId); + return targetOrder + 1; + } + } + } + } + + /** + * Attach listeners to section rows (helper for dynamic content) + * Uses event delegation to prevent stacking listeners when switching tabs + */ + function attachSectionRowListeners(dialog) { + // Check if listeners have already been attached using a data attribute + if (dialog.dataset.sectionListenersAttached === 'true') { + return; // Listeners already attached, skip + } + + // Mark that listeners have been attached + dialog.dataset.sectionListenersAttached = 'true'; + + const defaultSections = flattenSectionGroups(window.KefinHomeConfig2?.HOME_SECTION_GROUPS || []); + const defaultIds = new Set(defaultSections.map(s => s.id)); + + // Consolidated handler for editable section and group names - use event delegation + dialog.addEventListener('blur', async (e) => { + // Handle section names + const sectionNameEl = e.target.closest('.editable-section-name'); + if (sectionNameEl) { + const sectionId = sectionNameEl.dataset.sectionId; + const newName = sectionNameEl.value.trim(); + + if (!newName) { + showToast('Section name cannot be empty'); + // Restore old name + const found = findSectionInAllGroups(currentConfig, sectionId); + if (found?.section) { + sectionNameEl.value = found.section.name || 'Unnamed Section'; + } + return; + } + + // Update section name in config + const found = findSectionInAllGroups(currentConfig, sectionId); + if (found?.section && found.section.name !== newName) { + found.section.name = newName; + await saveConfig(currentConfig); + showToast(`Section renamed to "${newName}"`); + } + return; + } + + // Handle group names + const groupNameEl = e.target.closest('.editable-group-name'); + if (groupNameEl) { + const groupId = groupNameEl.dataset.groupId; + const newName = groupNameEl.value.trim(); + const groupElement = dialog.querySelector(`.section-group[data-group-id="${groupId}"]`); + const oldName = groupElement?.dataset.groupName; + + if (!newName) { + showToast('Group name cannot be empty'); + // Restore old name + groupNameEl.value = oldName || 'Unnamed Group'; + return; + } + + if (oldName === newName) { + return; // No change + } + + // Find and update group in config + let groupFound = false; + let foundGroup = null; + let groupArrayType = null; + const allGroupArrays = [ + { groups: currentConfig.HOME_SECTION_GROUPS, type: 'HOME_SECTION_GROUPS' }, + { groups: currentConfig.SEASONAL_SECTION_GROUPS, type: 'SEASONAL_SECTION_GROUPS' }, + { groups: currentConfig.DISCOVERY_SECTION_GROUPS, type: 'DISCOVERY_SECTION_GROUPS' }, + { groups: currentConfig.CUSTOM_SECTION_GROUPS, type: 'CUSTOM_SECTION_GROUPS' } + ]; + + for (const { groups, type } of allGroupArrays) { + if (!groups) continue; + const group = groups.find(g => { + const gId = `group-${g.name || 'unnamed'}-${g.author || ''}`.replace(/[^a-zA-Z0-9-]/g, '-'); + return gId === groupId; + }); + if (group) { + foundGroup = group; + groupArrayType = type; + groupFound = true; + break; + } + } + + if (groupFound && foundGroup) { + // Check if this is a default group (not CUSTOM) and handle _originalName + if (groupArrayType !== 'CUSTOM_SECTION_GROUPS') { + // If _originalName is not set, check if this is a default group + if (!foundGroup._originalName) { + const defaults = window.KefinHomeConfig2; + if (defaults) { + const defaultGroups = defaults[groupArrayType] || []; + const isDefaultGroup = defaultGroups.some(dg => { + const dgKey = dg.author ? `${dg.name}::${dg.author}` : dg.name || 'Unnamed'; + const currentKey = foundGroup.author ? `${oldName}::${foundGroup.author}` : oldName || 'Unnamed'; + return dgKey === currentKey; + }); + + // If it's a default group, store the original name + if (isDefaultGroup) { + foundGroup._originalName = oldName; + } + } + } + // If _originalName is already set, preserve it (don't overwrite) + } + + // Update the group name + foundGroup.name = newName; + + // Update data attribute + groupElement.dataset.groupName = newName; + await saveConfig(currentConfig); + showToast(`Group renamed to "${newName}"`); + } else { + showToast('Could not find group to rename'); + groupNameEl.value = oldName || 'Unnamed Group'; + } + } + }, true); // Use capture phase + + // Consolidated handler for Enter key - prevents default behavior and blurs input + dialog.addEventListener('keydown', (e) => { + const nameEl = e.target.closest('.editable-section-name, .editable-group-name'); + if (!nameEl) return; + + if (e.key === 'Enter') { + e.preventDefault(); + nameEl.blur(); + } + }, true); + + // Drag and drop handlers - use event delegation + let draggedElement = null; + let dropPosition = 'above'; // Store drop position for drop handler + + dialog.addEventListener('dragstart', (e) => { + const row = e.target.closest('.section-row'); + if (!row) return; + + draggedElement = row; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', row.innerHTML); + row.style.opacity = '0.5'; + }); + + dialog.addEventListener('dragend', (e) => { + const row = e.target.closest('.section-row'); + if (!row) return; + + row.style.opacity = '1'; + // Remove drag-over styling from all rows + dialog.querySelectorAll('.section-row').forEach(r => { + r.style.borderTop = ''; + }); + }); + + dialog.addEventListener('dragover', (e) => { + const row = e.target.closest('.section-row'); + if (!row) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + // Calculate drop position based on cursor Y position relative to element center + const rect = row.getBoundingClientRect(); + const elementCenterY = rect.top + rect.height / 2; + dropPosition = e.clientY < elementCenterY ? 'above' : 'below'; + + // Visual feedback: show drop indicator + dialog.querySelectorAll('.section-row').forEach(r => { + r.style.borderTop = ''; + }); + + // Show indicator on the target row based on drop position + row.style.borderTop = '2px solid var(--theme-primary-color, #00a4dc)'; + }); + + dialog.addEventListener('drop', async (e) => { + const row = e.target.closest('.section-row'); + if (!row) return; + e.preventDefault(); + + if (!draggedElement || draggedElement === row) return; + + const sectionType = row.dataset.sectionType; + const draggedSectionId = draggedElement.dataset.sectionId; + const draggedSectionType = draggedElement.dataset.sectionType; + + // Check if we're on the Order Sections tab + const isOrderTab = currentActiveTab === 'order'; + + // If on sections tab, restrict dragging within same group + if (!isOrderTab) { + // Find which group the dragged section belongs to + const draggedFound = findSectionInAllGroups(currentConfig, draggedSectionId); + const targetFound = findSectionInAllGroups(currentConfig, row.dataset.sectionId); + + // Only allow drop if both sections are in the same group + if (!draggedFound || !targetFound || draggedFound.group !== targetFound.group) { + return; + } + } + + // Calculate new order using DOM elements + const newOrder = calculateNewOrder(draggedElement, row, dropPosition); + + // Update order in config - find section across all groups + const found = findSectionInAllGroups(currentConfig, draggedSectionId); + if (found) { + found.section.order = newOrder; + // Sort sections within the group by order + found.group.sections.sort((a, b) => (a.order || 0) - (b.order || 0)); + } + + await saveConfig(currentConfig); + + // Update section position in place instead of refreshing entire modal + // This preserves scroll position + let container = e.target.closest('.group-sections-list'); + + if (container) { + updateSectionPositionInPlace(draggedElement, container); + } else { + // Fallback to refresh if container not found + refreshMainModal(); + } + }); + + // Unified toggle slider click handler - handles both section toggles and settings toggles + dialog.addEventListener('click', async (e) => { + // Check if click is on toggle slider button or its children + const btn = e.target.closest('.toggle-slider'); + if (!btn) return; + + // Stop propagation to prevent section row click handler from firing + e.stopPropagation(); + + // Check if this is a section toggle (has data-section-id) + const sectionId = btn.dataset.sectionId; + if (sectionId) { + // Section toggle: update section config and save + const sectionType = btn.dataset.sectionType; + const found = findSectionInAllGroups(currentConfig, sectionId); + const section = found?.section; + + if (section) { + section.enabled = !section.enabled; + await saveConfig(currentConfig); + + // Update the toggle switch UI + updateToggleSliderUI(btn, section.enabled); + } + } else { + // Settings toggle: toggle hidden checkbox and update UI + const checkboxId = btn.dataset.checkboxId; + if (checkboxId) { + const checkbox = document.getElementById(checkboxId); + if (checkbox) { + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + updateToggleSliderUI(btn, checkbox.checked); + } + } else { + // Fallback: just toggle the visual state + const currentState = btn.dataset.enabled === 'true'; + updateToggleSliderUI(btn, !currentState); + } + } + }); + + // Group toggle-all buttons - use event delegation + dialog.addEventListener('click', async (e) => { + const btn = e.target.closest('.group-toggle-all-btn'); + if (!btn) return; + e.stopPropagation(); + const groupId = btn.dataset.groupId; + const currentEnabled = btn.dataset.enabled === 'true'; + const newEnabled = !currentEnabled; + + const groupElement = dialog.querySelector(`.section-group[data-group-id="${groupId}"]`); + if (!groupElement) return; + + const sectionsList = groupElement.querySelector('.group-sections-list'); + if (!sectionsList) return; + + // Find all sections in this group and update them + const sectionRows = sectionsList.querySelectorAll('.section-row'); + const sectionIds = Array.from(sectionRows).map(row => row.dataset.sectionId); + + // Update all sections in the group + sectionIds.forEach(sectionId => { + const found = findSectionInAllGroups(currentConfig, sectionId); + if (found && found.section) { + found.section.enabled = newEnabled; + } + }); + + await saveConfig(currentConfig); + + // Update UI + btn.dataset.enabled = newEnabled; + btn.querySelector('span').textContent = newEnabled ? 'Disable All' : 'Enable All'; + + // Update all toggle switches in the group + sectionRows.forEach(row => { + const toggleBtn = row.querySelector('.toggle-slider'); + if (toggleBtn) { + updateToggleSliderUI(toggleBtn, newEnabled); + } + }); + }); + + // Preview section buttons - use event delegation + dialog.addEventListener('click', async (e) => { + const btn = e.target.closest('.section-preview-btn'); + if (!btn) return; + e.stopPropagation(); + const sectionId = btn.dataset.sectionId; + const sectionType = btn.dataset.sectionType; + + // Find section across all groups + const found = findSectionInAllGroups(currentConfig, sectionId); + const section = found?.section; + + if (section) { + await previewSection(section, sectionType, btn); + } + }); + + // Click on section row to edit - use event delegation + dialog.addEventListener('click', (e) => { + const row = e.target.closest('.section-row'); + if (!row) return; + + // Don't trigger if clicking on delete button, toggle switch, preview button, or editable name + if (e.target.closest('.section-delete-btn') || + e.target.closest('.toggle-slider') || + e.target.closest('.section-preview-btn') || + e.target.closest('.editable-section-name')) { + return; + } + + const sectionId = row.dataset.sectionId; + const sectionType = row.dataset.sectionType; + + // Find section across all groups + const found = findSectionInAllGroups(currentConfig, sectionId); + const section = found?.section; + + if (section) { + openSectionEditor(section, sectionType); + } + }); + + // Delete custom section buttons - use event delegation + dialog.addEventListener('click', async (e) => { + const btn = e.target.closest('.section-delete-btn'); + if (!btn) return; + e.stopPropagation(); + const sectionId = btn.dataset.sectionId; + const groups = currentConfig.CUSTOM_SECTION_GROUPS || []; + const found = findSectionInGroups(groups, sectionId); + const sectionName = found?.section?.name || sectionId; + if (confirm(`Delete section "${sectionName}"?`)) { + removeSectionFromGroups(groups, sectionId); + await saveConfig(currentConfig); + showToast(`Section "${sectionName}" deleted`); + refreshMainModal(); + } + }); + } + + /** + * Setup community collections grid (Import / Export tab). + * Selecting a collection opens a secondary import modal. + */ + function setupCommunityCollectionsTab(dialog) { + const host = dialog.querySelector('#communityCollectionsHost') + || dialog.querySelector('#tab-import-export'); + let collectionsGrid = dialog.querySelector('#communityCollectionsGrid'); + + if (!host) { + return; + } + + if (!window.KefinCommunityConfig) { + host.innerHTML = '
Community configuration not loaded. Please ensure homeScreenConfig-community.js is loaded.
'; + return; + } + + const communityCollections = window.KefinCommunityConfig.collections || []; + + if (communityCollections.length === 0) { + host.innerHTML = '
No community collections available.
'; + return; + } + + if (!collectionsGrid) { + host.innerHTML = '
'; + collectionsGrid = dialog.querySelector('#communityCollectionsGrid'); + } + if (!collectionsGrid) return; + + collectionsGrid.innerHTML = communityCollections.map((collection, index) => { + return ` +
+
+
+
${collection.name || 'Unnamed Collection'}
+
by ${collection.author || 'Anonymous'}
+
+ +
+
+ ${collection.sections ? collection.sections.length : 0} section${collection.sections && collection.sections.length !== 1 ? 's' : ''} +
+
+ `; + }).join(''); + + collectionsGrid.querySelectorAll('.community-collection-card').forEach((card, index) => { + card.addEventListener('click', (e) => { + if (e.target.closest('.community-collection-info-btn')) { + return; + } + openCommunityImportModal(communityCollections[index]); + }); + }); + + collectionsGrid.querySelectorAll('.community-collection-info-btn').forEach((btn, index) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const collection = communityCollections[index]; + const description = collection.description || 'No description available.'; + showInfoPopover(btn, description, collection.name || 'Collection'); + }); + }); + } + + /** + * Open secondary modal to pick sections from a community collection and import them. + */ + function openCommunityImportModal(collection) { + if (!collection) return; + if (!window.ModalSystem?.create) { + showToast('Modal system not available'); + return; + } + + const collectionDesc = collection.description + ? `
${escapeHtml(String(collection.description))}
` + : ''; + const content = document.createElement('div'); + content.innerHTML = ` + ${collectionDesc} +
Select sections to import from this collection.
+
+
+
+ + `; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.5em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + window.ModalSystem.create({ + id: COMMUNITY_IMPORT_MODAL_ID, + title: collection.name || 'Import Collection', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + fixedSize: true, + onOpen: (modal) => { + const dialog = modal.dialogContent || content; + const sectionsList = dialog.querySelector('#communitySectionsList'); + const sectionsContainer = dialog.querySelector('#communitySectionsContainer'); + const sectionsEmpty = dialog.querySelector('#communitySectionsEmpty'); + const confirmBtn = modal.dialogFooter?.querySelector('#confirmCommunityImportBtn') + || dialog.querySelector('#confirmCommunityImportBtn'); + + renderSectionsList(collection, sectionsList, sectionsContainer, sectionsEmpty, confirmBtn); + + modal.dialogFooter?.querySelector('#community-import-cancel')?.addEventListener('click', () => { + window.ModalSystem.close(COMMUNITY_IMPORT_MODAL_ID); + }); + + confirmBtn?.addEventListener('click', async () => { + const selectedIndices = Array.from(dialog.querySelectorAll('.import-community-check:checked:not(:disabled)')) + .map(c => parseInt(c.dataset.index, 10)); + + if (selectedIndices.length === 0) { + showToast('Please select at least one new section to import.'); + return; + } + + const selectedSections = selectedIndices.map(i => { + const section = JSON.parse(JSON.stringify(collection.sections[i])); + return convertToHomeScreenSection(section); + }); + + const { importedCount, skippedCount } = importCommunitySectionsIntoConfig( + collection, + selectedSections + ); + + if (importedCount === 0) { + showToast(skippedCount > 0 + ? 'Selected sections are already imported.' + : 'Nothing new to import.'); + return; + } + + confirmBtn.disabled = true; + try { + await saveConfig(currentConfig); + window.ModalSystem.close(COMMUNITY_IMPORT_MODAL_ID); + const skipNote = skippedCount > 0 ? ` (${skippedCount} already present)` : ''; + showToast(`Successfully imported ${importedCount} section${importedCount !== 1 ? 's' : ''}${skipNote}. Refreshing...`); + refreshMainModal(); + } catch (e) { + ERR('Error importing community sections:', e); + showToast('Error importing sections'); + confirmBtn.disabled = false; + } + }); + } + }); + } + + /** + * Render sections list for selected collection + */ + function renderSectionsList(collection, sectionsList, sectionsContainer, sectionsEmpty, confirmBtn) { + if (!sectionsList) return; + + if (!collection || !collection.sections || collection.sections.length === 0) { + sectionsList.innerHTML = '
No sections found in this collection.
'; + if (sectionsContainer) sectionsContainer.style.display = 'block'; + if (sectionsEmpty) sectionsEmpty.style.display = 'none'; + if (confirmBtn) confirmBtn.style.display = 'none'; + return; + } + + const existingGroup = findCommunityImportGroup(currentConfig.CUSTOM_SECTION_GROUPS || [], collection); + let availableCount = 0; + + sectionsList.innerHTML = ` +
+ ${collection.sections.map((s, i) => { + const name = escapeHtml(s.name || 'Unnamed'); + const alreadyImported = isCommunitySectionInGroup(existingGroup, s); + if (!alreadyImported) availableCount++; + const desc = s.description + ? `
${escapeHtml(String(s.description))}
` + : ''; + const importedNote = alreadyImported + ? `
Already imported
` + : ''; + return ` + + `; + }).join('')} +
+ `; + + if (sectionsContainer) sectionsContainer.style.display = 'block'; + if (sectionsEmpty) sectionsEmpty.style.display = 'none'; + if (confirmBtn) { + confirmBtn.style.display = 'inline-block'; + confirmBtn.disabled = availableCount === 0; + confirmBtn.textContent = availableCount === 0 ? 'Already Imported' : 'Import Selected'; + } + } + + /** + * Show info popover for collection description + */ + function showInfoPopover(triggerElement, description, title) { + // Remove any existing popover + const existingPopover = document.querySelector('.community-info-popover'); + if (existingPopover) { + existingPopover.remove(); + } + + // Create popover element + const popover = document.createElement('div'); + popover.className = 'community-info-popover'; + popover.style.cssText = ` + position: absolute; + background: rgba(26,26,26,0.98); + border: 1px solid rgba(255,255,255,0.2); + border-radius: 8px; + padding: 1em; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + z-index: 10000; + max-width: 400px; + min-width: 250px; + `; + popover.innerHTML = ` +
${title}
+
${description}
+ + `; + + // Position popover relative to trigger + const rect = triggerElement.getBoundingClientRect(); + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; + + popover.style.top = `${rect.bottom + scrollTop + 8}px`; + popover.style.left = `${rect.left + scrollLeft}px`; + + // Add close button handler + popover.querySelector('.community-popover-close').addEventListener('click', () => { + popover.remove(); + }); + + // Close on outside click + const closeOnOutsideClick = (e) => { + if (!popover.contains(e.target) && e.target !== triggerElement) { + popover.remove(); + document.removeEventListener('click', closeOnOutsideClick); + } + }; + setTimeout(() => { + document.addEventListener('click', closeOnOutsideClick); + }, 0); + + document.body.appendChild(popover); + } + + /** + * Convert legacy section format to HomeScreenSection format + */ + function convertToHomeScreenSection(section) { + // If already in new format, return as-is + if (section.queries && Array.isArray(section.queries)) { + return section; + } + + // Convert legacy format + const newSection = { + id: section.id, + name: section.name, + enabled: section.enabled !== false, + order: section.order || 100, + cardFormat: section.cardFormat || 'Poster', + itemLimit: section.itemLimit || 20, + queries: [] + }; + + // Build query from legacy fields + const query = { + queryOptions: {} + }; + + if (section.path) { + query.path = section.path; + } + if (section.dataSource) { + query.dataSource = section.dataSource; + } + + // Copy all other fields to queryOptions + Object.keys(section).forEach(key => { + if (!['id', 'name', 'enabled', 'order', 'cardFormat', 'itemLimit', 'path', 'dataSource', 'queries', 'type'].includes(key)) { + query.queryOptions[key] = section[key]; + } + }); + + newSection.queries.push(query); + return newSection; + } + + /** + * Check if section ID exists in config + */ + function sectionIdExists(config, sectionId) { + const allHomeSections = flattenSectionGroups(config.HOME_SECTION_GROUPS || []); + const allSeasonalSections = flattenSectionGroups(config.SEASONAL_SECTION_GROUPS || []); + const allDiscoverySections = flattenSectionGroups(config.DISCOVERY_SECTION_GROUPS || []); + const allCustomSections = flattenSectionGroups(config.CUSTOM_SECTION_GROUPS || []); + const allIds = [ + ...allHomeSections.map(s => s.id), + ...allSeasonalSections.map(s => s.id), + ...allDiscoverySections.map(s => s.id), + ...allCustomSections.map(s => s.id) + ]; + return allIds.includes(sectionId); + } + + /** + * Find an existing custom group matching a community collection (name + author). + */ + function findCommunityImportGroup(groups, collection) { + const name = collection?.name || 'Imported Collection'; + const author = collection?.author; + return (groups || []).find((g) => { + if (g.name !== name) return false; + if (author) return g.author === author; + return !g.author; + }) || null; + } + + /** + * Whether a community section is already present in a target group. + * Matches exact id, prior community re-import ids, or same section name. + */ + function isCommunitySectionInGroup(group, section) { + if (!group?.sections?.length || !section) return false; + const id = section.id; + const name = section.name; + return group.sections.some((s) => { + if (id && s.id === id) return true; + if (id && s.id && String(s.id).startsWith(`${id}_community_`)) return true; + if (name && s.name === name) return true; + return false; + }); + } + + /** + * Import selected community sections into CUSTOM_SECTION_GROUPS, + * merging into an existing name+author group and skipping duplicates. + * @returns {{ importedCount: number, skippedCount: number, group: Object|null }} + */ + function importCommunitySectionsIntoConfig(collection, selectedSections) { + if (!currentConfig.CUSTOM_SECTION_GROUPS) currentConfig.CUSTOM_SECTION_GROUPS = []; + + const existingGroup = findCommunityImportGroup(currentConfig.CUSTOM_SECTION_GROUPS, collection); + const toAdd = []; + let skippedCount = 0; + + selectedSections.forEach((section) => { + if (isCommunitySectionInGroup(existingGroup, section)) { + skippedCount++; + return; + } + if (section.id && sectionIdExists(currentConfig, section.id)) { + section.id = `${section.id}_community_${Date.now()}_${Math.floor(Math.random() * 1000)}`; + } + toAdd.push(section); + }); + + if (toAdd.length === 0) { + return { importedCount: 0, skippedCount, group: existingGroup }; + } + + let targetGroup = existingGroup; + if (!targetGroup) { + targetGroup = { + name: collection.name || 'Imported Collection', + author: collection.author, + description: collection.description, + sections: [] + }; + currentConfig.CUSTOM_SECTION_GROUPS.push(targetGroup); + } else if (collection.description && !targetGroup.description) { + targetGroup.description = collection.description; + } + + if (!targetGroup.sections) targetGroup.sections = []; + targetGroup.sections.push(...toAdd); + targetGroup.sections.sort((a, b) => (a.order || 0) - (b.order || 0)); + + return { importedCount: toAdd.length, skippedCount, group: targetGroup }; + } + + /** + * Show community import dialog + */ + async function showCommunityImportDialog() { + // Ensure community config is available + if (!window.KefinCommunityConfig) { + showToast('Community configuration not loaded. Please ensure homeScreenConfig-community.js is loaded.'); + return; + } + + const communityCollections = window.KefinCommunityConfig.collections || []; + + if (communityCollections.length === 0) { + showToast('No community collections available.'); + return; + } + + const content = document.createElement('div'); + content.innerHTML = ` +
+
Select Home Screen Sections to import:
+
These sections are created and submitted by community members like you!
+
Submit your own Home Screen Sections here.
+ +
+ +
+ + + + +
+ `; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.innerHTML = ` + + + `; + + let selectedCollection = null; + + window.ModalSystem.create({ + id: 'kefinCommunityImportModal', + title: 'Import Community Sections', + content: content, + footer: footer, + closeOnBackdrop: false, + closeOnEscape: true, + onOpen: (modalInstance) => { + const select = content.querySelector('#communityCollectionSelect'); + const descContainer = content.querySelector('#collectionDescription'); + const selectionContainer = content.querySelector('#importSelectionContainer'); + const selectionList = content.querySelector('#importSelectionList'); + const confirmBtn = modalInstance.dialogFooter.querySelector('#confirmCommunityImportBtn'); + + select.addEventListener('change', () => { + const index = select.value; + if (index === '') { + selectedCollection = null; + descContainer.style.display = 'none'; + selectionContainer.style.display = 'none'; + confirmBtn.style.display = 'none'; + return; + } + + selectedCollection = communityCollections[parseInt(index)]; + + // Show description + descContainer.textContent = selectedCollection.description || 'No description.'; + descContainer.style.display = 'block'; + + // Render sections list + let html = ''; + let availableCount = 0; + if (selectedCollection.sections && selectedCollection.sections.length > 0) { + const existingGroup = findCommunityImportGroup( + currentConfig.CUSTOM_SECTION_GROUPS || [], + selectedCollection + ); + html += ` +
+ ${selectedCollection.sections.map((s, i) => { + const alreadyImported = isCommunitySectionInGroup(existingGroup, s); + if (!alreadyImported) availableCount++; + return ` + + `; + }).join('')} +
`; + } else { + html = '
No sections found in this collection.
'; + } + + selectionList.innerHTML = html; + selectionContainer.style.display = 'block'; + confirmBtn.style.display = 'inline-block'; + confirmBtn.disabled = availableCount === 0; + confirmBtn.textContent = availableCount === 0 ? 'Already Imported' : 'Import Selected'; + }); + + confirmBtn.addEventListener('click', async () => { + if (!selectedCollection) return; + + const selectedIndices = Array.from(content.querySelectorAll('.import-community-check:checked:not(:disabled)')) + .map(c => parseInt(c.dataset.index)); + + if (selectedIndices.length === 0) { + showToast('Please select at least one new section to import.'); + return; + } + + const selectedSections = selectedIndices.map(i => { + const section = JSON.parse(JSON.stringify(selectedCollection.sections[i])); + return convertToHomeScreenSection(section); + }); + + const { importedCount, skippedCount } = importCommunitySectionsIntoConfig( + selectedCollection, + selectedSections + ); + + if (importedCount === 0) { + showToast(skippedCount > 0 + ? 'Selected sections are already imported.' + : 'Nothing new to import.'); + return; + } + + await saveConfig(currentConfig); + const skipNote = skippedCount > 0 ? ` (${skippedCount} already present)` : ''; + showToast(`Successfully imported ${importedCount} section${importedCount !== 1 ? 's' : ''}${skipNote}. Refreshing...`); + refreshMainModal(); + window.ModalSystem.close('kefinCommunityImportModal'); + }); + } + }); + } + + /** + * Show export dialog + */ + async function showExportDialog() { + const sectionGroups = currentConfig.CUSTOM_SECTION_GROUPS || []; + + if (sectionGroups.length === 0 || !sectionGroups.some(g => g.sections && g.sections.length > 0)) { + alert('No custom sections found to export.'); + return; + } + + const content = document.createElement('div'); + content.innerHTML = ` +
+
+
+ + +
+
+
+ + +
+ `; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.innerHTML = ` + + + `; + + window.ModalSystem.create({ + id: 'kefinExportModal', + title: 'Export Custom Sections', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modalInstance) => { + const sectionGroupsList = content.querySelector('#exportSectionGroupsList'); + renderSectionGroupsCheckboxList(sectionGroupsList, sectionGroups, { + checkboxClass: 'export-custom-check', + groupCheckboxClass: 'export-custom-group-check' + }); + + const generateBtn = modalInstance.dialogFooter.querySelector('#generateExportBtn'); + generateBtn.addEventListener('click', () => { + // Collect selected sections by group + const selectedGroups = []; + + sectionGroups.forEach((group, groupIndex) => { + if (!group || !Array.isArray(group.sections)) return; + + const selectedSections = []; + group.sections.forEach((section, sectionIndex) => { + const checkbox = content.querySelector(`.export-custom-check[data-group-index="${groupIndex}"][data-section-index="${sectionIndex}"]`); + if (checkbox && checkbox.checked) { + selectedSections.push(section); + } + }); + + // Only include groups that have at least one section selected + if (selectedSections.length > 0) { + selectedGroups.push({ + name: group.name, + author: group.author, + description: group.description, + sections: selectedSections + }); + } + }); + + if (selectedGroups.length === 0) { + alert('Please select at least one section to export.'); + return; + } + + // Export as array of section groups + const json = JSON.stringify(selectedGroups, null, 2); + const outputContainer = content.querySelector('#exportOutputContainer'); + const output = content.querySelector('#exportOutput'); + + output.value = json; + outputContainer.style.display = 'block'; + generateBtn.style.display = 'none'; + + content.querySelector('#copyExportBtn').addEventListener('click', () => { + output.select(); + document.execCommand('copy'); + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Copied to clipboard'); + } else { + alert('Copied to clipboard'); + } + }); + + content.querySelector('#downloadExportBtn').addEventListener('click', () => { + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `kefin-tweaks-export-${new Date().toISOString().slice(0,10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }); + }); + } + }); + } + + /** + * Show import dialog + */ + async function showImportDialog() { + const content = document.createElement('div'); + content.innerHTML = ` +
+
Paste JSON content or a URL (Raw GitHub, Gist, Pastebin):
+ + + +
+ `; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.innerHTML = ` + + + + `; + + let parsedData = null; + let sectionGroups = null; + + window.ModalSystem.create({ + id: 'kefinImportModal', + title: 'Import Custom Sections', + content: content, + footer: footer, + closeOnBackdrop: false, + closeOnEscape: true, + onOpen: (modalInstance) => { + const loadBtn = modalInstance.dialogFooter.querySelector('#loadImportBtn'); + const confirmBtn = modalInstance.dialogFooter.querySelector('#confirmImportBtn'); + const input = content.querySelector('#importInput'); + const selectionContainer = content.querySelector('#importSelectionContainer'); + const selectionList = content.querySelector('#importSelectionList'); + + loadBtn.addEventListener('click', async () => { + const rawInput = input.value.trim(); + if (!rawInput) return; + + loadBtn.disabled = true; + loadBtn.textContent = 'Loading...'; + + try { + let jsonStr = rawInput; + // Check if URL + if (rawInput.startsWith('http://') || rawInput.startsWith('https://')) { + const res = await fetch(rawInput); + if (!res.ok) throw new Error(`Failed to fetch URL: ${res.status}`); + jsonStr = await res.text(); + } + + parsedData = JSON.parse(jsonStr); + + // Accept array of section groups or object with sectionGroups property + if (Array.isArray(parsedData)) { + sectionGroups = parsedData; + } else if (parsedData.sectionGroups && Array.isArray(parsedData.sectionGroups)) { + sectionGroups = parsedData.sectionGroups; + } else { + throw new Error('Invalid format: Expected array of section groups or object with sectionGroups property.'); + } + + if (!sectionGroups || sectionGroups.length === 0) { + throw new Error('No section groups found.'); + } + + // Render selection using shared helper + renderSectionGroupsCheckboxList(selectionList, sectionGroups, { + checkboxClass: 'import-custom-check', + groupCheckboxClass: 'import-custom-group-check' + }); + + selectionContainer.style.display = 'block'; + confirmBtn.style.display = 'inline-block'; + } catch (error) { + showToast(`Error loading data: ${error.message}`); + } finally { + loadBtn.disabled = false; + loadBtn.textContent = 'Load'; + } + }); + + confirmBtn.addEventListener('click', async () => { + if (!sectionGroups || sectionGroups.length === 0) return; + + // Collect selected sections by group + const groupsToImport = []; + + sectionGroups.forEach((group, groupIndex) => { + if (!group || !Array.isArray(group.sections)) return; + + const selectedSections = []; + group.sections.forEach((section, sectionIndex) => { + const checkbox = content.querySelector(`.import-custom-check[data-group-index="${groupIndex}"][data-section-index="${sectionIndex}"]`); + if (checkbox && checkbox.checked) { + const sectionCopy = JSON.parse(JSON.stringify(section)); // Deep copy + const convertedSection = convertToHomeScreenSection(sectionCopy); + + // Check for ID collision + if (convertedSection.id && sectionIdExists(currentConfig, convertedSection.id)) { + convertedSection.id = `${convertedSection.id}_imported_${Date.now()}_${Math.floor(Math.random()*1000)}`; + } + + selectedSections.push(convertedSection); + } + }); + + // Only include groups that have at least one section selected + if (selectedSections.length > 0) { + groupsToImport.push({ + name: group.name, + author: group.author, + description: group.description, + sections: selectedSections + }); + } + }); + + if (groupsToImport.length === 0) { + showToast('Please select at least one section to import.'); + return; + } + + if (!currentConfig.CUSTOM_SECTION_GROUPS) currentConfig.CUSTOM_SECTION_GROUPS = []; + + // Append groups to CUSTOM_SECTION_GROUPS + groupsToImport.forEach(group => { + currentConfig.CUSTOM_SECTION_GROUPS.push(group); + }); + + const importedCount = groupsToImport.reduce((sum, g) => sum + g.sections.length, 0); + + if (importedCount > 0) { + await saveConfig(currentConfig); + showToast(`Successfully imported ${importedCount} sections in ${groupsToImport.length} group${groupsToImport.length !== 1 ? 's' : ''}. Refreshing...`); + refreshMainModal(); + window.ModalSystem.close('kefinImportModal'); + } else { + window.ModalSystem.close('kefinImportModal'); + } + }); + } + }); + } + + /** + * Open main configuration modal + */ + async function openConfigModal() { + try { + // Load config + const config = loadConfig(); + + // Build content + const content = document.createElement('div'); + content.innerHTML = buildMainConfigHTML(); + + // Build footer + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + + footer.innerHTML = ` + + `; + + /* footer.innerHTML = ` + + + + `; */ + + // Create modal + mainModalInstance = window.ModalSystem.create({ + id: MAIN_MODAL_ID, + title: 'Home Screen Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '1400px', height: '90vh' } + : undefined, + onOpen: (modalInstance) => { + // Attach tab switching + attachTabListeners(modalInstance); + + // Attach event listeners + attachMainModalListeners(modalInstance); + + if (normalizeActiveTab(currentActiveTab) === 'troubleshoot') { + initBenchmarkTab(modalInstance); + } + + const saveAllBtn = modalInstance.dialogFooter.querySelector('#save-all-btn'); + if (saveAllBtn) { + saveAllBtn.addEventListener('click', async () => { + saveAllBtn.disabled = true; + try { + await collectAndSaveConfig(modalInstance); + showToast('Configuration saved!'); + } catch (e) { + ERR('Error saving configuration:', e); + showToast('Error saving configuration'); + } finally { + saveAllBtn.disabled = false; + } + }); + } + + const updateAllBtn = modalInstance.dialogFooter.querySelector('#update-all-btn'); + if (updateAllBtn) { + updateAllBtn.addEventListener('click', () => { + confirmAndUpdateAllUsers(); + }); + } + + // Populate Order Sections tab if it's the active tab + if (currentActiveTab === 'order') { + const orderContainer = modalInstance.dialogContent.querySelector('#order-sections-container'); + if (orderContainer && orderContainer.children.length === 0) { + const allSections = [ + ...flattenSectionGroups(currentConfig.HOME_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Default' })), + ...flattenSectionGroups(currentConfig.SEASONAL_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Seasonal' })), + ...flattenSectionGroups(currentConfig.CUSTOM_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Custom' })) + ]; + orderContainer.innerHTML = renderHomeSectionsOrderEditor(allSections); + } + } + } + }); + + LOG('Main configuration modal opened'); + } catch (error) { + ERR('Error opening config modal:', error); + showToast('Error opening configuration: ' + error.message); + } + } + + /** + * Discovery templates need a source pick before queries are concrete. + */ + function sectionNeedsDiscoveryResolve(section) { + if (!section) return false; + if (Array.isArray(section.items) && section.items.length > 0) return false; + if (Array.isArray(section.externalListUrls) && section.externalListUrls.length > 0) return false; + if (section.discoveryType) return true; + const type = String(section.type || '').toLowerCase(); + return ['genre', 'studio', 'person', 'collection', 'similar', 'watchlist', 'tag'].includes(type); + } + + function snapshotDiscoveryDedupeState(discoveryState) { + if (!discoveryState?.renderedDiscoveryIds) return null; + const renderedDiscoveryIds = {}; + Object.keys(discoveryState.renderedDiscoveryIds).forEach((key) => { + const set = discoveryState.renderedDiscoveryIds[key]; + renderedDiscoveryIds[key] = set instanceof Set ? new Set(set) : new Set(); + }); + return { + renderedDiscoveryIds, + currentDiscoveryGenre: discoveryState.currentDiscoveryGenre ?? null, + currentDiscoveryStudio: discoveryState.currentDiscoveryStudio ?? null + }; + } + + function restoreDiscoveryDedupeState(discoveryState, snapshot) { + if (!discoveryState?.renderedDiscoveryIds || !snapshot?.renderedDiscoveryIds) return; + Object.keys(discoveryState.renderedDiscoveryIds).forEach((key) => { + const live = discoveryState.renderedDiscoveryIds[key]; + if (!(live instanceof Set)) return; + live.clear(); + const saved = snapshot.renderedDiscoveryIds[key]; + if (saved instanceof Set) saved.forEach((id) => live.add(id)); + }); + discoveryState.currentDiscoveryGenre = snapshot.currentDiscoveryGenre ?? null; + discoveryState.currentDiscoveryStudio = snapshot.currentDiscoveryStudio ?? null; + } + + async function resolveDiscoverySectionForPreview(section) { + const helper = window.sectionHelper; + if (!helper?.buildDiscoverySectionInstance) { + throw new Error('Discovery resolver unavailable for preview'); + } + const discoveryState = helper.getDiscoveryState?.(); + const snapshot = snapshotDiscoveryDedupeState(discoveryState); + try { + const resolved = await helper.buildDiscoverySectionInstance(section, { + pairSpotlight: false, + resolveViewMore: false + }); + if (!resolved?.queries?.length && !resolved?.items?.length) { + throw new Error('Could not resolve discovery source for preview'); + } + return resolved; + } finally { + restoreDiscoveryDedupeState(discoveryState, snapshot); + } + } + + /** + * Overlay resolved discovery display fields onto a live draft so appearance + * edits stay on the draft while titles/captions come from resolve. + * @param {Object} draft + * @param {Object|null|undefined} resolved + * @returns {Object} + */ + function mergeDraftWithResolvedPreview(draft, resolved) { + if (!draft) return draft; + if (!resolved) return draft; + const merged = { ...draft }; + if (resolved.name != null) merged.name = resolved.name; + if (resolved.caption != null) merged.caption = resolved.caption; + if (resolved.captionUrl != null) merged.captionUrl = resolved.captionUrl; + if (resolved.viewMoreUrl != null) merged.viewMoreUrl = resolved.viewMoreUrl; + if (resolved.metadata != null) merged.metadata = resolved.metadata; + return merged; + } + + /** + * Fetch items for a section preview (shared by modal preview and wizard inline preview). + * @param {Object} section + * @param {Object} [options] + * @param {boolean} [options.useSpotlightFields] - Request spotlight-grade Fields regardless of renderMode + * @returns {Promise<{ items: Array, limit: number, section: Object }>} + */ + async function fetchSectionPreviewItems(section, options = {}) { + if (!window.ApiClient || !window.apiHelper || !window.cardBuilder) { + throw new Error('Required dependencies not available for preview'); + } + + const userId = ApiClient.getCurrentUserId(); + const serverUrl = ApiClient.serverAddress(); + + if (!userId || !serverUrl) { + throw new Error('Unable to get user ID or server URL'); + } + + let previewSection = section; + if (sectionNeedsDiscoveryResolve(section)) { + previewSection = await resolveDiscoverySectionForPreview(section); + } + + const queries = previewSection.queries || []; + if (Array.isArray(previewSection.externalListUrls) && previewSection.externalListUrls.length > 0) { + if (!window.KefinExternalList?.resolveExternalListItems) { + throw new Error('External list module not available for preview'); + } + const listResult = await window.KefinExternalList.resolveExternalListItems(previewSection); + const items = listResult?.Items || []; + const processed = window.cardBuilder.postProcessItems + ? window.cardBuilder.postProcessItems(previewSection, items) + : items; + return { items: processed, limit: processed.length, section: previewSection }; + } + + if (previewSection.items?.length) { + const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot || ''; + const serverId = ApiClient.serverId(); + const normalizeTemplate = (value) => (value || '') + .replace(/\$\{kefinTweaksRoot\}/g, kefinTweaksRoot) + .replace(/\$\{serverId\}/g, serverId); + + const items = previewSection.items.map((item, index) => { + const posterUrl = normalizeTemplate(item.posterUrl); + const thumbUrl = normalizeTemplate(item.thumbUrl); + const squareUrl = normalizeTemplate(item.squareUrl); + const imageUrl = normalizeTemplate(item.imageUrl); + const cardUrl = normalizeTemplate(item.cardUrl); + const backdropUrl = normalizeTemplate(item.backdropUrl); + const bannerUrl = normalizeTemplate(item.bannerUrl); + const logoUrl = normalizeTemplate(item.logoUrl); + + return { + Name: item.Name, + Id: item.Id || 'static-preview-' + (previewSection.id || 'draft') + '-' + index, + Type: item.Type || 'Folder', + posterUrl, + thumbUrl, + squareUrl, + imageUrl, + cardUrl, + backdropUrl, + bannerUrl, + logoUrl, + CustomFooterText: item.cardFooter || undefined + }; + }); + + const processed = window.cardBuilder.postProcessItems + ? window.cardBuilder.postProcessItems(previewSection, items) + : items; + return { items: processed, limit: processed.length, section: previewSection }; + } + + if (queries.length === 0) { + throw new Error('Section has no queries to preview'); + } + + // Match home-screen selection: picker / random load one query; otherwise merge all + const resolveQueries = window.cardBuilder.resolveQueriesToLoad; + const queriesToLoad = typeof resolveQueries === 'function' + ? resolveQueries(previewSection, { initialize: true }) + : queries; + + const useSpotlightFields = options.useSpotlightFields === true + || previewSection.renderMode === 'Spotlight'; + + let allItems = []; + for (const query of queriesToLoad) { + const previewQuery = withPreviewQueryOptions(query); + let queryUrl = null; + + if (previewQuery.dataSource) { + queryUrl = null; + } else { + queryUrl = window.apiHelper.buildQueryFromSection(previewQuery, userId, serverUrl, useSpotlightFields, { sectionType: previewSection.type }); + } + + try { + let items = []; + if (previewQuery.dataSource) { + const response = await window.apiHelper.fetchFromDataSource(previewQuery.dataSource, previewQuery.queryOptions, false); + items = response.Items || response || []; + } else if (queryUrl && typeof queryUrl === 'string') { + const result = await apiHelper.getQuery(queryUrl, { useCache: false }); + items = result.Items || result || []; + } + allItems = allItems.concat(items); + } catch (error) { + console.error('[Preview] Error fetching query:', error); + throw new Error(`Error fetching query: ${error.message}`); + } + } + + if (allItems.length === 0) { + return { items: [], limit: 0, section: previewSection }; + } + + const limit = previewSection.itemLimit + || withPreviewQueryOptions(queriesToLoad[0]).queryOptions.Limit + || PREVIEW_DEFAULT_LIMIT; + allItems = window.cardBuilder.postProcessItems(previewSection, allItems); + const limitedItems = allItems.slice(0, parseInt(limit, 10)); + + return { items: limitedItems, limit: parseInt(limit, 10), section: previewSection }; + } + + const EDITOR_PREVIEW_SECTION_ID = 'hsae-editor-preview'; + + function normalizeEditorPreviewConfig(draftSection) { + return { + ...draftSection, + id: EDITOR_PREVIEW_SECTION_ID, + enabled: true, + order: 0, + userConfigurable: false, + renderMode: draftSection.renderMode || 'Normal' + }; + } + + function buildEditorPreviewProgressiveSection(draftSection, initialItems) { + const config = normalizeEditorPreviewConfig(draftSection); + const items = Array.isArray(initialItems) ? initialItems : []; + + let mappedDataPromise = null; + const ensureData = () => { + if (!mappedDataPromise) { + mappedDataPromise = Promise.resolve(items); + } + return mappedDataPromise; + }; + + const result = { + data: items, + isStale: false, + isStalePromise: Promise.resolve(false), + ensureData + }; + + Object.defineProperty(result, 'dataPromise', { + configurable: true, + enumerable: true, + get() { + return ensureData(); + } + }); + + return { config, result }; + } + + function attachEditorPreviewRefresh(containerEl, getDraftSection) { + if (!containerEl || typeof getDraftSection !== 'function') return; + + const refreshBtn = containerEl.querySelector('.section-refresh-button'); + if (!refreshBtn) return; + + const newBtn = refreshBtn.cloneNode(true); + refreshBtn.replaceWith(newBtn); + + newBtn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + const sectionEl = newBtn.closest('.emby-scroller-container') + || containerEl.querySelector('.emby-scroller-container'); + if (sectionEl) sectionEl.dataset.refreshing = 'true'; + + try { + const draft = getDraftSection(); + const { items, section: resolvedSection } = await fetchSectionPreviewItems(draft); + const previewSection = mergeDraftWithResolvedPreview(draft, resolvedSection); + await renderSectionPreviewInto(previewSection, items, containerEl, { getDraftSection }); + } catch (err) { + console.error('[Preview] Refresh failed:', err); + containerEl.innerHTML = `

${err.message || 'Preview refresh failed'}

`; + } finally { + if (sectionEl) delete sectionEl.dataset.refreshing; + } + }); + } + + /** + * Render section preview into a DOM container (no modal). + * @returns {Promise} + */ + async function renderSectionPreviewInto(section, items, containerEl, options = {}) { + if (!containerEl) return false; + if (!window.cardBuilder) return false; + + if (!items || items.length === 0) { + containerEl.innerHTML = '

No items found for preview.

'; + return false; + } + + const renderMode = section.renderMode || (section.spotlight ? 'Spotlight' : 'Normal'); + const useSpotlight = renderMode === 'Spotlight' || (renderMode === 'Random' && Math.random() < 0.5); + + if (useSpotlight && typeof window.cardBuilder.renderSpotlightSection === 'function') { + const spotlightCfg = section.spotlightConfig || {}; + const previewElement = window.cardBuilder.renderSpotlightSection(items, section.name || 'Preview', { + autoPlay: false, + showDots: true, + showNavButtons: true, + viewMoreUrl: null, + panAnimation: spotlightCfg.panAnimation !== false, + spotlightLayout: spotlightCfg.spotlightLayout, + spotlightSize: spotlightCfg.spotlightSize, + tileCount: spotlightCfg.tileCount + }); + + if (previewElement) { + containerEl.innerHTML = ''; + containerEl.appendChild(previewElement); + return true; + } + + containerEl.innerHTML = '

Unable to render preview with the current configuration.

'; + return false; + } + + if (typeof window.cardBuilder.renderProgressiveSections !== 'function') { + containerEl.innerHTML = '

Preview unavailable.

'; + return false; + } + + containerEl.innerHTML = ''; + const progressiveSection = buildEditorPreviewProgressiveSection(section, items); + await window.cardBuilder.renderProgressiveSections( + containerEl, + [Promise.resolve(progressiveSection)], + { showStaleDataBeforeRefresh: true } + ); + + const getDraftSection = options.getDraftSection; + if (typeof getDraftSection === 'function') { + attachEditorPreviewRefresh(containerEl, getDraftSection); + } + + return true; + } + + /** + * Preview section by fetching items and rendering them + */ + async function previewSection(section, sectionType, btn) { + if (!window.ApiClient || !window.apiHelper || !window.cardBuilder) { + showToast('Required dependencies not available for preview'); + return; + } + + Dashboard.showLoadingMsg(); + + try { + const { items, section: resolvedSection } = await fetchSectionPreviewItems(section); + const previewSection = mergeDraftWithResolvedPreview(section, resolvedSection); + + if (items.length === 0) { + showToast('No items found for preview'); + + if (btn) { + btn.textContent = 'No Items'; + btn.style.border = '1px solid #ff6b6b'; + btn.style.color = '#ff6b6b'; + } + setTimeout(() => { + if (btn) { + btn.textContent = 'Preview'; + btn.style.border = '1px solid #ccc'; + btn.style.color = '#ccc'; + } + }, 3000); + return; + } + + const previewModalId = 'kefin-preview-section'; + const content = document.createElement('div'); + content.style.cssText = 'padding: 1em; max-width: 1400px; width: 100%;'; + content.innerHTML = ` +
+ Preview: ${previewSection.name || 'Unnamed Section'} (${items.length} item${items.length !== 1 ? 's' : ''}) +
+
+ `; + + const footer = document.createElement('div'); + footer.innerHTML = ``; + + window.ModalSystem.create({ + id: previewModalId, + title: 'Section Preview', + content: content, + footer: footer, + size: 'large', + closeOnBackdrop: true, + closeOnEscape: true, + dialogStyle: { width: '1400px' }, + onOpen: async (modalInstance) => { + const container = modalInstance.dialogContent.querySelector('#preview-container'); + await renderSectionPreviewInto(previewSection, items, container); + } + }); + + } catch (error) { + console.error('[Preview] Error:', error); + showToast(`Preview error: ${error.message}`); + } finally { + Dashboard.hideLoadingMsg(); + } + } + + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.homeScreen = { openConfigModal }; + + // Export getConfig for use by other scripts + window.KefinHomeScreen = window.KefinHomeScreen || {}; + window.KefinHomeScreen.isInSeasonalPeriod = isInSeasonalPeriod; + window.KefinHomeScreen.getConfig = getConfig; + window.KefinHomeScreen.getSections = getSections; + window.KefinHomeScreen.fetchSectionPreviewItems = fetchSectionPreviewItems; + window.KefinHomeScreen.mergeDraftWithResolvedPreview = mergeDraftWithResolvedPreview; + window.KefinHomeScreen.renderSectionPreviewInto = renderSectionPreviewInto; + window.KefinHomeScreen.buildEditorPreviewProgressiveSection = buildEditorPreviewProgressiveSection; + window.KefinHomeScreen.canOpenSectionEditor = canOpenSectionEditor; + window.KefinHomeScreen.openSectionEditorForId = openSectionEditorForId; + window.KefinHomeScreen.openNewCustomSection = (options = {}) => openNewCustomSectionEditor({ + refreshHomeOnSave: true, + ...options + }); + window.KefinHomeScreen.publishSectionPresentationDefaults = publishSectionPresentationDefaults; + window.KefinHomeScreen.findSectionInAllGroups = findSectionInAllGroups; + window.KefinHomeScreen.ensureKefinTweaksDefaultSections = ensureKefinTweaksDefaultSections; + window.KefinHomeScreen.collectCustomDiscoverySections = collectCustomDiscoverySections; + + + LOG('Home Screen Configuration UI loaded'); + + // Load user configuration script after this script is initialized + function loadUserConfigurationScript() { + try { + const root = window.KefinTweaksConfig?.kefinTweaksRoot; + if (!root) { + WARN('kefinTweaksRoot not configured, cannot load user configuration script'); + return; + } + + // Check if script is already loaded + if (document.querySelector('script[data-kefin-user-homescreen-config]')) { + LOG('User home screen configuration script already loaded'); + return; + } + + const scriptUrl = `${root}configuration/homeScreen-user-configuration.js`; + const script = document.createElement('script'); + script.src = scriptUrl; + script.async = true; + script.setAttribute('data-kefin-user-homescreen-config', 'true'); + + script.onload = () => { + LOG('User home screen configuration script loaded successfully'); + }; + + script.onerror = (error) => { + ERR('Failed to load user home screen configuration script:', scriptUrl, error); + }; + + document.head.appendChild(script); + } catch (error) { + ERR('Error loading user configuration script:', error); + } + } + + // Load user config script after a short delay to ensure this script is fully initialized + //setTimeout(loadUserConfigurationScript, 100); + + // Defer admin library/studio catalog sync so it does not compete with home first paint + const STARTUP_DEFAULT_SECTION_SYNC_DELAY_MS = 15000; + setTimeout(() => { + runStartupDefaultSectionSync().catch(err => ERR('Startup default section sync failed:', err)); + }, STARTUP_DEFAULT_SECTION_SYNC_DELAY_MS); + +})(); \ No newline at end of file diff --git a/configuration/homeScreen-migration.js b/configuration/homeScreen-migration.js new file mode 100644 index 0000000..1a990ff --- /dev/null +++ b/configuration/homeScreen-migration.js @@ -0,0 +1,726 @@ +// KefinTweaks HomeScreen Configuration Migration +// Migrates legacy KefinTweaksConfig.homeScreen to new KefinTweaksConfig.homeScreenConfig format +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks HomeScreen Migration]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks HomeScreen Migration]', ...args); + const ERR = (...args) => console.error('[KefinTweaks HomeScreen Migration]', ...args); + + /** + * Check if migration is needed and perform migration + * @returns {Promise} True if migration was performed, false otherwise + */ + async function migrateHomeScreenConfig() { + // Check if migration is needed + if (!window.KefinTweaksConfig) { + LOG('KefinTweaksConfig not found, skipping migration'); + return false; + } + + // If homeScreenConfig already exists, skip migration + if (window.KefinTweaksConfig.homeScreenConfig) { + LOG('homeScreenConfig already exists, skipping migration'); + return false; + } + + // If homeScreen doesn't exist, nothing to migrate + if (!window.KefinTweaksConfig.homeScreen) { + LOG('No legacy homeScreen config found, skipping migration'); + return false; + } + + // Check if KefinHomeConfig2 defaults are available + if (!window.KefinHomeConfig2) { + WARN('KefinHomeConfig2 defaults not available, cannot perform migration'); + return false; + } + + LOG('Starting migration from legacy homeScreen to homeScreenConfig...'); + + try { + const legacyHomeScreen = window.KefinTweaksConfig.homeScreen; + const defaults = window.KefinHomeConfig2; + + // Initialize new config structure with defaults + const newHomeScreenConfig = { + HOME_SECTION_GROUPS: JSON.parse(JSON.stringify(defaults.HOME_SECTION_GROUPS || [])), + SEASONAL_SECTION_GROUPS: JSON.parse(JSON.stringify(defaults.SEASONAL_SECTION_GROUPS || [])), + DISCOVERY_SECTION_GROUPS: JSON.parse(JSON.stringify(defaults.DISCOVERY_SECTION_GROUPS || [])), + CUSTOM_SECTION_GROUPS: [], + DISCOVERY_SETTINGS: JSON.parse(JSON.stringify(defaults.DISCOVERY_SETTINGS || {})), + SEASONAL_THEME_SETTINGS: JSON.parse(JSON.stringify(defaults.SEASONAL_THEME_SETTINGS || {})), + HOME_SETTINGS: JSON.parse(JSON.stringify(defaults.HOME_SETTINGS || {})) + }; + + // Migrate discovery settings + migrateDiscoverySettings(legacyHomeScreen, newHomeScreenConfig); + + // Migrate seasonal theme settings + migrateSeasonalThemeSettings(legacyHomeScreen, newHomeScreenConfig); + + // Migrate HOME sections + migrateHomeSections(legacyHomeScreen, newHomeScreenConfig); + + // Migrate SEASONAL sections + migrateSeasonalSections(legacyHomeScreen, newHomeScreenConfig); + + // Migrate DISCOVERY sections + migrateDiscoverySections(legacyHomeScreen, newHomeScreenConfig); + + // Migrate CUSTOM sections + migrateCustomSections(legacyHomeScreen, newHomeScreenConfig); + + // Save migrated config to JS Injector plugin + window.KefinTweaksConfig.homeScreenConfig = newHomeScreenConfig; + 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'); + } + LOG('Migration completed successfully'); + } catch (error) { + ERR('Migration failed:', error); + return false; + } + return true; + } + + /** + * Migrate discovery settings + */ + function migrateDiscoverySettings(legacyHomeScreen, newConfig) { + if (!legacyHomeScreen.discovery) return; + + const discovery = legacyHomeScreen.discovery; + const settings = newConfig.DISCOVERY_SETTINGS; + + LOG('Migrating discovery settings...'); + + if (discovery.enabled !== undefined) settings.enabled = discovery.enabled; + if (discovery.infiniteScroll !== undefined) settings.infiniteScroll = discovery.infiniteScroll; + if (discovery.minGenreMovieCount !== undefined) settings.minGenreMovieCount = discovery.minGenreMovieCount; + if (discovery.defaultItemLimit !== undefined) settings.defaultItemLimit = discovery.defaultItemLimit; + if (discovery.defaultSortOrder !== undefined) settings.defaultSortOrder = discovery.defaultSortOrder; + if (discovery.defaultCardFormat !== undefined) settings.defaultCardFormat = discovery.defaultCardFormat; + if (discovery.spotlightDiscoveryChance !== undefined) settings.spotlightDiscoveryChance = discovery.spotlightDiscoveryChance; + if (discovery.renderSpotlightAboveMatching !== undefined) settings.renderSpotlightAboveMatching = discovery.renderSpotlightAboveMatching; + if (discovery.randomizeOrder !== undefined) settings.randomizeOrder = discovery.randomizeOrder; + if (discovery.spotlight && typeof discovery.spotlight === 'object') { + settings.spotlight = {}; + Object.assign(settings.spotlight, discovery.spotlight); + } + + // Legacy discovery.minPeopleAppearances → HOME_SETTINGS mins + if (discovery.minPeopleAppearances !== undefined) { + if (!newConfig.HOME_SETTINGS) newConfig.HOME_SETTINGS = {}; + const min = discovery.minPeopleAppearances; + const home = newConfig.HOME_SETTINGS; + if (home.minPeopleAppearancesTotal == null) home.minPeopleAppearancesTotal = min; + if (home.minPeopleAppearancesMovies == null) home.minPeopleAppearancesMovies = min; + if (home.minPeopleAppearancesSeries == null) home.minPeopleAppearancesSeries = min; + if (home.minPeopleAppearancesEpisodes == null) home.minPeopleAppearancesEpisodes = min; + } + } + + /** + * Migrate seasonal theme settings + */ + function migrateSeasonalThemeSettings(legacyHomeScreen, newConfig) { + if (!legacyHomeScreen.seasonal) return; + + const seasonal = legacyHomeScreen.seasonal; + const settings = newConfig.SEASONAL_THEME_SETTINGS; + + LOG('Migrating seasonal theme settings...'); + + if (seasonal.enabled !== undefined) settings.enabled = seasonal.enabled; + if (seasonal.enableSeasonalAnimations !== undefined) settings.enableSeasonalAnimations = seasonal.enableSeasonalAnimations; + if (seasonal.enableSeasonalBackground !== undefined) settings.enableSeasonalBackground = seasonal.enableSeasonalBackground; + } + + /** + * Find section in default groups by ID + */ + function findSectionInDefaults(sectionId, groups) { + for (const group of groups) { + if (group.sections) { + const section = group.sections.find(s => s.id === sectionId); + if (section) { + return { section, group, sectionIndex: group.sections.indexOf(section) }; + } + } + } + return null; + } + + /** + * Find seasonal section using matching algorithm + */ + function findSeasonalSectionByMatch(legacySection, seasonGroup, seasonId) { + if (!seasonGroup || !seasonGroup.sections) return null; + + const sections = seasonGroup.sections; + + // Try exact ID match (for descriptive IDs like halloween-tag) + if (legacySection.id && !legacySection.id.startsWith('seasonal-')) { + const exactMatch = sections.find(s => { + const expectedId = `seasonal.${seasonId}.${legacySection.id}`; + return s.id === expectedId || s.id.endsWith(`.${legacySection.id}`); + }); + if (exactMatch) { + LOG(` Matched section by exact ID: ${legacySection.id} → ${exactMatch.id}`); + return exactMatch; + } + } + + // Try name match + if (legacySection.name) { + const nameMatch = sections.find(s => s.name === legacySection.name); + if (nameMatch) { + LOG(` Matched section by name: "${legacySection.name}" → ${nameMatch.id}`); + return nameMatch; + } + } + + // Try type + source pattern match + if (legacySection.type && legacySection.source !== undefined) { + const typeSourceMatch = sections.find(s => { + if (!s.queries || !s.queries[0] || !s.queries[0].queryOptions) return false; + const queryOptions = s.queries[0].queryOptions; + + if (legacySection.type === 'Tag' && queryOptions.Tags === legacySection.source) return true; + if (legacySection.type === 'Genre' && (queryOptions.Genres === legacySection.source || queryOptions.GenreIds === legacySection.source)) return true; + if (legacySection.type === 'Parent' && queryOptions.ParentId === legacySection.source) return true; + return false; + }); + if (typeSourceMatch) { + LOG(` Matched section by type+source: ${legacySection.type}/${legacySection.source} → ${typeSourceMatch.id}`); + return typeSourceMatch; + } + } + + return null; + } + + /** + * Find discovery section by ID + */ + function findDiscoverySectionById(sectionId, groups) { + for (const group of groups) { + if (group.sections) { + const section = group.sections.find(s => s.id === sectionId); + if (section) { + return { section, group, sectionIndex: group.sections.indexOf(section) }; + } + } + } + return null; + } + + /** + * Merge legacy section config into default section + */ + function mergeSectionConfig(defaultSection, legacyConfig) { + const merged = JSON.parse(JSON.stringify(defaultSection)); + + // Map root fields — missing legacy enabled means on (same as seasonal/custom) + merged.enabled = legacyConfig.enabled !== false; + if (legacyConfig.name !== undefined) merged.name = legacyConfig.name; + if (legacyConfig.order !== undefined) merged.order = legacyConfig.order; + if (legacyConfig.cardFormat !== undefined) merged.cardFormat = legacyConfig.cardFormat; + if (legacyConfig.startDate !== undefined) merged.startDate = legacyConfig.startDate; + if (legacyConfig.endDate !== undefined) merged.endDate = legacyConfig.endDate; + if (legacyConfig.discoveryEnabled !== undefined) merged.discoveryEnabled = legacyConfig.discoveryEnabled; + + // RenderMode mapping: only use spotlight if renderMode doesn't exist + if (legacyConfig.renderMode !== undefined) { + merged.renderMode = legacyConfig.renderMode; + } else if (legacyConfig.spotlight === true) { + merged.renderMode = 'Spotlight'; + } + + if (legacyConfig.spotlightConfig !== undefined) merged.spotlightConfig = legacyConfig.spotlightConfig; + if (legacyConfig.overflowCard !== undefined) merged.overflowCard = legacyConfig.overflowCard; + if (legacyConfig.minimumShowsForNetwork !== undefined) merged.minimumShowsForNetwork = legacyConfig.minimumShowsForNetwork; + if (legacyConfig.flattenSeries !== undefined) merged.flattenSeries = legacyConfig.flattenSeries; + if (legacyConfig.viewMoreUrl !== undefined) merged.viewMoreUrl = legacyConfig.viewMoreUrl; + + // Map query options fields + if (merged.queries && merged.queries.length > 0) { + const query = merged.queries[0]; + if (!query.queryOptions) query.queryOptions = {}; + + if (legacyConfig.itemLimit !== undefined) query.queryOptions.Limit = legacyConfig.itemLimit; + if (legacyConfig.sortOrder !== undefined) query.queryOptions.SortBy = legacyConfig.sortOrder; + if (legacyConfig.sortOrderDirection !== undefined) query.queryOptions.SortOrder = legacyConfig.sortOrderDirection; + if (legacyConfig.searchTerm !== undefined && legacyConfig.searchTerm) query.queryOptions.SearchTerm = legacyConfig.searchTerm; + if (legacyConfig.includeItemTypes !== undefined) { + query.queryOptions.IncludeItemTypes = Array.isArray(legacyConfig.includeItemTypes) + ? legacyConfig.includeItemTypes + : legacyConfig.includeItemTypes.split(',').map(s => s.trim()).filter(Boolean); + } + if (legacyConfig.isPlayed !== undefined && legacyConfig.isPlayed !== null) { + query.queryOptions.IsUnplayed = legacyConfig.isPlayed === false; + } + if (legacyConfig.minAgeInDays !== undefined && legacyConfig.minAgeInDays !== null) { + query.minAge = legacyConfig.minAgeInDays; + } + if (legacyConfig.maxAgeInDays !== undefined && legacyConfig.maxAgeInDays !== null) { + query.maxAge = legacyConfig.maxAgeInDays; + } + + // Merge additionalQueryOptions + if (legacyConfig.additionalQueryOptions && Array.isArray(legacyConfig.additionalQueryOptions)) { + legacyConfig.additionalQueryOptions.forEach(opt => { + if (opt && opt.key && opt.value !== undefined) { + query.queryOptions[opt.key] = opt.value; + } + }); + } + } + + return merged; + } + + /** + * Convert legacy Type/source to queries array + */ + function convertLegacyTypeToQuery(type, source, legacySection) { + const query = { + queryOptions: {} + }; + + // Build base queryOptions from legacy section fields + if (legacySection.itemLimit !== undefined) query.queryOptions.Limit = legacySection.itemLimit; + if (legacySection.sortOrder !== undefined) query.queryOptions.SortBy = legacySection.sortOrder; + if (legacySection.sortOrderDirection !== undefined) query.queryOptions.SortOrder = legacySection.sortOrderDirection; + if (legacySection.searchTerm !== undefined && legacySection.searchTerm) query.queryOptions.SearchTerm = legacySection.searchTerm; + if (legacySection.includeItemTypes !== undefined) { + query.queryOptions.IncludeItemTypes = Array.isArray(legacySection.includeItemTypes) + ? legacySection.includeItemTypes + : legacySection.includeItemTypes.split(',').map(s => s.trim()).filter(Boolean); + } + if (legacySection.isPlayed !== undefined && legacySection.isPlayed !== null) { + query.queryOptions.IsUnplayed = legacySection.isPlayed === false; + } + if (legacySection.minAgeInDays !== undefined && legacySection.minAgeInDays !== null) { + query.minAge = legacySection.minAgeInDays; + } + if (legacySection.maxAgeInDays !== undefined && legacySection.maxAgeInDays !== null) { + query.maxAge = legacySection.maxAgeInDays; + } + + // Convert Type/source to queryOptions + if (type === 'Playlist' || type === 'Collection' || type === 'Parent') { + if (source && source.trim()) { + query.queryOptions.ParentId = source.trim(); + } + } else if (type === 'Genre') { + const genreSource = source ? source.trim() : ''; + if (genreSource) { + // Check if it's a 32-char hex ID + if (/^[a-f0-9]{32}$/i.test(genreSource)) { + query.queryOptions.GenreIds = genreSource; + } else { + // Comma-separated genre names + query.queryOptions.Genres = genreSource; + } + } + } else if (type === 'Tag') { + if (source && source.trim()) { + query.queryOptions.Tags = source.trim(); + } + } + + // Merge additionalQueryOptions + if (legacySection.additionalQueryOptions && Array.isArray(legacySection.additionalQueryOptions)) { + legacySection.additionalQueryOptions.forEach(opt => { + if (opt && opt.key && opt.value !== undefined) { + query.queryOptions[opt.key] = opt.value; + } + }); + } + + return query; + } + + /** + * Migrate HOME sections + */ + function migrateHomeSections(legacyHomeScreen, newConfig) { + LOG('Migrating HOME sections...'); + + // Migrate recentlyReleased.movies + if (legacyHomeScreen.recentlyReleased?.movies) { + const found = findSectionInDefaults('recentlyReleased.movies', newConfig.HOME_SECTION_GROUPS); + if (found) { + const moviesConfig = legacyHomeScreen.recentlyReleased.movies; + const parentEnabled = legacyHomeScreen.recentlyReleased.enabled !== false; + const merged = mergeSectionConfig(found.section, { + ...moviesConfig, + enabled: moviesConfig.enabled !== undefined ? moviesConfig.enabled : parentEnabled + }); + Object.assign(found.section, merged); + LOG(' Migrated recentlyReleased.movies'); + } + } + + // Migrate recentlyReleased.episodes + if (legacyHomeScreen.recentlyReleased?.episodes) { + const found = findSectionInDefaults('recentlyReleased.episodes', newConfig.HOME_SECTION_GROUPS); + if (found) { + const episodesConfig = legacyHomeScreen.recentlyReleased.episodes; + const parentEnabled = legacyHomeScreen.recentlyReleased.enabled !== false; + const merged = mergeSectionConfig(found.section, { + ...episodesConfig, + enabled: episodesConfig.enabled !== undefined ? episodesConfig.enabled : parentEnabled + }); + Object.assign(found.section, merged); + LOG(' Migrated recentlyReleased.episodes'); + } + } + + // Migrate recentlyAddedInLibrary + if (legacyHomeScreen.recentlyAddedInLibrary && typeof legacyHomeScreen.recentlyAddedInLibrary === 'object') { + const libraryConfigs = legacyHomeScreen.recentlyAddedInLibrary; + let recentlyAddedGroup = newConfig.HOME_SECTION_GROUPS.find(g => g.name === 'Recently Added' || g.id === 'home-recently-added'); + + if (!recentlyAddedGroup) { + recentlyAddedGroup = { + id: 'home-recently-added', + name: 'Recently Added', + sections: [] + }; + newConfig.HOME_SECTION_GROUPS.push(recentlyAddedGroup); + } + + Object.entries(libraryConfigs).forEach(([libraryId, libConfig]) => { + const sectionId = `recently-added-${libraryId}`; + let includeItemTypes = ['Movie', 'Episode']; + + if (libConfig.includeItemTypes) { + includeItemTypes = Array.isArray(libConfig.includeItemTypes) + ? libConfig.includeItemTypes + : libConfig.includeItemTypes.split(',').map(s => s.trim()).filter(Boolean); + } + + const newSection = { + id: sectionId, + name: libConfig.name || `Recently Added Media`, + enabled: libConfig.enabled !== false, + order: libConfig.order || 61, + cardFormat: libConfig.cardFormat || 'Poster', + jellyfinId: 'latestmedia', + ttl: 0, + queries: [{ + path: '/Items/Latest', + queryOptions: { + ParentId: libraryId, + IncludeItemTypes: includeItemTypes, + SortBy: 'DateCreated', + SortOrder: 'Descending', + Limit: libConfig.itemLimit || 16 + } + }] + }; + + recentlyAddedGroup.sections.push(newSection); + LOG(` Migrated recentlyAddedInLibrary[${libraryId}]`); + }); + } + + // Migrate other home sections + const sectionMappings = { + watchAgain: 'watchAgain', + watchlist: 'watchlist', + upcoming: 'upcoming', + imdbTop250: 'imdbTop250', + trending: 'trending', + popularTVNetworks: 'popularTVNetworks' + }; + + Object.entries(sectionMappings).forEach(([legacyKey, sectionId]) => { + if (legacyHomeScreen[legacyKey]) { + const found = findSectionInDefaults(sectionId, newConfig.HOME_SECTION_GROUPS); + if (found) { + const merged = mergeSectionConfig(found.section, legacyHomeScreen[legacyKey]); + Object.assign(found.section, merged); + LOG(` Migrated ${legacyKey}`); + } + } + }); + } + + /** + * Migrate SEASONAL sections + */ + function migrateSeasonalSections(legacyHomeScreen, newConfig) { + if (!legacyHomeScreen.seasonal?.seasons || !Array.isArray(legacyHomeScreen.seasonal.seasons)) { + return; + } + + LOG('Migrating SEASONAL sections...'); + + legacyHomeScreen.seasonal.seasons.forEach(legacySeason => { + const seasonId = legacySeason.id || legacySeason.name?.toLowerCase().replace(/\s+/g, '-'); + if (!seasonId) { + WARN(' Skipping season without id or name'); + return; + } + + // Find matching seasonal group + const seasonalGroup = newConfig.SEASONAL_SECTION_GROUPS.find(g => { + const groupId = g.id || g.name?.toLowerCase().replace(/\s+/g, '-'); + return groupId === `seasonal-${seasonId}` || groupId === seasonId; + }); + + if (!seasonalGroup) { + WARN(` No matching seasonal group found for season: ${seasonId}`); + return; + } + + LOG(` Processing season: ${seasonId}`); + + if (legacySeason.sections && Array.isArray(legacySeason.sections)) { + legacySeason.sections.forEach(legacySection => { + // Try to find matching section + const matchedSection = findSeasonalSectionByMatch(legacySection, seasonalGroup, seasonId); + + if (matchedSection) { + // Merge legacy config into matched section + const merged = mergeSectionConfig(matchedSection, { + ...legacySection, + startDate: legacySeason.startDate || matchedSection.startDate, + endDate: legacySeason.endDate || matchedSection.endDate + }); + Object.assign(matchedSection, merged); + LOG(` Migrated section: ${legacySection.id || legacySection.name} → ${matchedSection.id}`); + } else { + LOG(`Converting generic section to custom: ${legacySection.id}`); + convertSeasonalSectionToCustom(legacySection, legacySeason, newConfig); + } + }); + } + }); + } + + /** + * Convert seasonal section with generic ID to custom section + */ + function convertSeasonalSectionToCustom(legacySection, legacySeason, newConfig) { + const query = convertLegacyTypeToQuery(legacySection.type, legacySection.source, legacySection); + + const customSection = { + id: `custom-${Date.now()}-${Math.random()}`, + name: legacySection.name || 'Custom Section', + enabled: legacySection.enabled !== false, + order: legacySection.order, + cardFormat: legacySection.cardFormat || 'Poster', + queries: [query] + }; + + // Map _parentItemType based on type + if (legacySection.type === 'Playlist') { + customSection._parentItemType = 'Playlist'; + } else if (legacySection.type === 'Collection') { + customSection._parentItemType = 'Collection'; + } else if (legacySection.type === 'Parent') { + customSection._parentItemType = 'Generic Parent'; + } + // Tag and Genre don't need _parentItemType + + // Map additional fields + if (legacySection.renderMode !== undefined) { + customSection.renderMode = legacySection.renderMode; + } else if (legacySection.spotlight === true) { + customSection.renderMode = 'Spotlight'; + } + if (legacySection.discoveryEnabled !== undefined) customSection.discoveryEnabled = legacySection.discoveryEnabled; + if (legacySection.spotlightConfig !== undefined) customSection.spotlightConfig = legacySection.spotlightConfig; + if (legacySection.overflowCard !== undefined) customSection.overflowCard = legacySection.overflowCard; + + const startDate = legacySection.startDate || legacySeason.startDate; + const endDate = legacySection.endDate || legacySeason.endDate; + if (startDate !== undefined) customSection.startDate = startDate; + if (endDate !== undefined) customSection.endDate = endDate; + + // Add to custom sections group + let customGroup = newConfig.CUSTOM_SECTION_GROUPS.find(g => g.name === 'Custom Sections' || g.id === 'custom-sections'); + if (!customGroup) { + customGroup = { + id: 'custom-sections', + name: 'Custom Sections', + sections: [] + }; + newConfig.CUSTOM_SECTION_GROUPS.push(customGroup); + } + + customGroup.sections.push(customSection); + LOG(` Added as custom section: ${customSection.id}`); + } + + const RECENTLY_WATCHED_PERSON_IDS = new Set([ + 'starringActorRecentlyWatched', + 'directedByDirectorRecentlyWatched', + 'writtenByWriterRecentlyWatched' + ]); + + const RECENTLY_WATCHED_CAPTION_DEFAULT = 'Because you recently watched {Title}'; + + const RECENTLY_WATCHED_NAME_DEFAULTS = { + starringActorRecentlyWatched: 'Starring {Person}', + directedByDirectorRecentlyWatched: 'Directed by {Person}', + writtenByWriterRecentlyWatched: 'Written by {Person}' + }; + + function normalizeDiscoveryTemplatePlaceholders(text) { + if (!text || typeof text !== 'string') return text; + return text + .replace(/\[Actor\]/gi, '{Person}') + .replace(/\[Director\]/gi, '{Person}') + .replace(/\[Writer\]/gi, '{Person}') + .replace(/\[Person\]/gi, '{Person}') + .replace(/\[Movie\]/gi, '{Title}') + .replace(/\[Title\]/gi, '{Title}'); + } + + /** + * Split legacy combined "Starring X because you recently watched Y" into name + caption. + */ + function splitLegacyRecentlyWatchedName(legacyName) { + if (!legacyName || typeof legacyName !== 'string') return null; + const match = legacyName.match(/^(.*?)\s+because you recently watched\s+(.*)$/i); + if (!match) return null; + let name = normalizeDiscoveryTemplatePlaceholders(match[1].trim()); + let captionRight = normalizeDiscoveryTemplatePlaceholders(match[2].trim()); + const caption = captionRight + ? `Because you recently watched ${captionRight}` + : RECENTLY_WATCHED_CAPTION_DEFAULT; + // Prefer canonical caption placeholder when right side is Movie/Title token only + if (/^\{Title\}$/i.test(captionRight) || /^\[Movie\]$/i.test(match[2].trim())) { + return { name, caption: RECENTLY_WATCHED_CAPTION_DEFAULT }; + } + return { name, caption }; + } + + /** + * Migrate DISCOVERY sections + */ + function migrateDiscoverySections(legacyHomeScreen, newConfig) { + if (!legacyHomeScreen.discovery?.sectionTypes) { + return; + } + + LOG('Migrating DISCOVERY sections...'); + + const discoverySectionTypes = legacyHomeScreen.discovery.sectionTypes; + Object.entries(discoverySectionTypes).forEach(([sectionKey, legacySectionConfig]) => { + const found = findDiscoverySectionById(sectionKey, newConfig.DISCOVERY_SECTION_GROUPS); + + if (found) { + found.section.enabled = legacySectionConfig.enabled !== false; + if (legacySectionConfig.itemLimit !== undefined) found.section.itemLimit = legacySectionConfig.itemLimit; + if (legacySectionConfig.cardFormat !== undefined) found.section.cardFormat = legacySectionConfig.cardFormat; + if (legacySectionConfig.isPlayed !== undefined && legacySectionConfig.isPlayed !== null) { + found.section.isPlayed = legacySectionConfig.isPlayed; + } + if (legacySectionConfig.minimumItems !== undefined) found.section.minimumItems = legacySectionConfig.minimumItems; + if (legacySectionConfig.sortOrder !== undefined) found.section.sortOrder = legacySectionConfig.sortOrder; + if (legacySectionConfig.sortOrderDirection !== undefined) found.section.sortOrderDirection = legacySectionConfig.sortOrderDirection; + + const isRecentlyWatchedPerson = RECENTLY_WATCHED_PERSON_IDS.has(sectionKey); + if (legacySectionConfig.name !== undefined) { + const split = splitLegacyRecentlyWatchedName(legacySectionConfig.name); + if (split) { + found.section.name = split.name; + found.section.caption = split.caption; + } else if (isRecentlyWatchedPerson) { + found.section.name = RECENTLY_WATCHED_NAME_DEFAULTS[sectionKey] + || normalizeDiscoveryTemplatePlaceholders(legacySectionConfig.name); + if (!found.section.caption) { + found.section.caption = RECENTLY_WATCHED_CAPTION_DEFAULT; + } + } else { + found.section.name = legacySectionConfig.name; + } + } else if (isRecentlyWatchedPerson && !found.section.caption) { + found.section.caption = RECENTLY_WATCHED_CAPTION_DEFAULT; + } + + if (legacySectionConfig.caption !== undefined) { + found.section.caption = normalizeDiscoveryTemplatePlaceholders(legacySectionConfig.caption); + } + + LOG(` Migrated discovery section: ${sectionKey}`); + } else { + WARN(` Discovery section not found in defaults: ${sectionKey}`); + } + }); + } + + /** + * Migrate CUSTOM sections + */ + function migrateCustomSections(legacyHomeScreen, newConfig) { + if (!legacyHomeScreen.customSections || !Array.isArray(legacyHomeScreen.customSections)) { + return; + } + + LOG('Migrating CUSTOM sections...'); + + let customGroup = newConfig.CUSTOM_SECTION_GROUPS.find(g => g.name === 'Custom Sections' || g.id === 'custom-sections'); + if (!customGroup) { + customGroup = { + id: 'custom-sections', + name: 'Custom Sections', + sections: [] + }; + newConfig.CUSTOM_SECTION_GROUPS.push(customGroup); + } + + legacyHomeScreen.customSections.forEach(legacySection => { + const query = convertLegacyTypeToQuery(legacySection.type, legacySection.source, legacySection); + + const customSection = { + id: `custom-${Date.now()}-${Math.random()}`, + name: legacySection.name || 'Custom Section', + enabled: legacySection.enabled !== false, + order: legacySection.order, + cardFormat: legacySection.cardFormat || 'Poster', + queries: [query] + }; + + // Map _parentItemType based on type + if (legacySection.type === 'Playlist') { + customSection._parentItemType = 'Playlist'; + } else if (legacySection.type === 'Collection') { + customSection._parentItemType = 'Collection'; + } else if (legacySection.type === 'Parent') { + customSection._parentItemType = 'Generic Parent'; + } + // Tag and Genre don't need _parentItemType + + // Map additional fields + if (legacySection.renderMode !== undefined) { + customSection.renderMode = legacySection.renderMode; + } else if (legacySection.spotlight === true) { + customSection.renderMode = 'Spotlight'; + } + if (legacySection.discoveryEnabled !== undefined) customSection.discoveryEnabled = legacySection.discoveryEnabled; + if (legacySection.spotlightConfig !== undefined) customSection.spotlightConfig = legacySection.spotlightConfig; + if (legacySection.overflowCard !== undefined) customSection.overflowCard = legacySection.overflowCard; + + customGroup.sections.push(customSection); + LOG(` Migrated custom section: ${customSection.id}`); + }); + } + + // Expose migration function + window.migrateHomeScreenConfig = migrateHomeScreenConfig; + + LOG('HomeScreen migration module loaded'); + +})(); diff --git a/configuration/homeScreen-user-configuration.js b/configuration/homeScreen-user-configuration.js new file mode 100644 index 0000000..8dd9bdc --- /dev/null +++ b/configuration/homeScreen-user-configuration.js @@ -0,0 +1,2866 @@ +// KefinTweaks User Home Screen Configuration +// Allows users to customize their home screen by re-ordering and enabling/disabling sections + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks User HomeScreen Config]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks User HomeScreen Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks User HomeScreen Config]', ...args); + + // All Jellyfin home section base IDs (homesection0-8) + const JELLYFIN_HOME_SECTIONS = [ + 'smalllibrarytiles', + 'librarybuttons', + 'resume', + 'resumeaudio', + 'resumebook', + 'nextup', + 'latestmedia', + 'livetv', + 'activerecordings' + ]; + + // Jellyfin native defaults when CustomPrefs has no/empty homesectionN + // (jellyfin-web DEFAULT_SECTIONS / DisplayPreferencesController) + const DEFAULT_NATIVE_HOME_SECTIONS = [ + 'smalllibrarytiles', + 'resume', + 'resumeaudio', + 'resumebook', + 'livetv', + 'nextup', + 'latestmedia', + 'none', + 'none', + 'none' + ]; + + /** + * Resolve a single native homesection slot the same way Jellyfin web does: + * present non-empty value wins; otherwise use DEFAULT_NATIVE_HOME_SECTIONS[index]. + * Explicit "none" is kept (truthy string). + */ + function resolveHomesectionValue(customPrefs, index) { + const raw = customPrefs?.[`homesection${index}`]; + if (raw != null && String(raw).trim() !== '') { + return String(raw).toLowerCase(); + } + return DEFAULT_NATIVE_HOME_SECTIONS[index] || 'none'; + } + + // Map Jellyfin sections to their KefinTweaks equivalents + // When a KefinTweaks section exists, it replaces the Jellyfin section in the UI + const JELLYFIN_HOME_SECTIONS_MAP = { + 'smalllibrarytiles': 'my-media', + 'librarybuttons': 'my-media-small', + 'resume': 'continueWatching', + 'nextup': 'nextUp', + 'latestmedia': 'recently-added*' // Pattern match - any section starting with 'recently-added-' + }; + + /** + * User whose Home prefs are being edited on mypreferenceshome / userpreferences. + * Prefers ?userId= from the hash or query (admin editing another user); else session user. + */ + /** + * Resolve whose display prefs to load/save (URL userId or current user). + * Always waits for login first so ApiClient has a current user before peeking. + * Callers must await. + */ + async function resolvePreferencesTargetUserId() { + if (window.userHelper?.waitForLogin) { + await window.userHelper.waitForLogin(); + } + try { + const hash = String(window.location.hash || ''); + const search = String(window.location.search || ''); + const tryParse = (src) => { + if (!src) return null; + const qIndex = src.indexOf('?'); + const queryPart = qIndex >= 0 ? src.slice(qIndex + 1) : src.replace(/^[?#]/, ''); + if (!queryPart) return null; + try { + const params = new URLSearchParams(queryPart); + const id = params.get('userId'); + if (id) return decodeURIComponent(String(id).replace(/\+/g, ' ')); + } catch (_) { /* fall through */ } + const match = src.match(/[?&]userId=([^&]+)/i); + if (match?.[1]) { + return decodeURIComponent(match[1].replace(/\+/g, ' ')); + } + return null; + }; + return tryParse(hash) || tryParse(search) || window.ApiClient?.getCurrentUserId?.() || null; + } catch (_) { + return window.ApiClient?.getCurrentUserId?.() || null; + } + } + + async function getEditorDisplayPreferences() { + const userId = await resolvePreferencesTargetUserId(); + if (!userId) return null; + const currentId = window.ApiClient?.getCurrentUserId?.(); + if (userId !== currentId && window.userHelper?.getUserDisplayPreferencesForUser) { + return window.userHelper.getUserDisplayPreferencesForUser(userId); + } + if (!window.userHelper?.getUserDisplayPreferences) return null; + const { promise } = await window.userHelper.getUserDisplayPreferences(); + return promise; + } + + async function saveEditorDisplayPreferences(displayPrefs) { + const userId = await resolvePreferencesTargetUserId(); + if (!userId || !displayPrefs) return false; + if (window.userHelper?.updateDisplayPreferencesForUser) { + return window.userHelper.updateDisplayPreferencesForUser(userId, displayPrefs, { + updateCache: userId === window.ApiClient?.getCurrentUserId?.() + }); + } + return !!(await window.userHelper?.updateDisplayPreferences?.(displayPrefs)); + } + + /** + * Flatten section groups into a flat array + */ + function flattenSectionGroups(groups) { + if (!Array.isArray(groups)) return []; + const flattened = []; + groups.forEach(group => { + if (group && Array.isArray(group.sections)) { + flattened.push(...group.sections); + } + }); + return flattened; + } + + const SECTION_PREF_FIELD_COUNT = 21; + const DEFAULT_PINNED_LIST_NAME = 'Pinned'; + const PINNED_LIST_ID_PREFIX = 'pinned-list-'; + const PINNED_PARENT_ID_PREFIX = 'pinned-parent-'; + /** Flex order slot for newly created pinned sections (admins can tune later). */ + const PINNED_DEFAULT_ORDER = 5; + + /** Live editor sections for native Save outbound merge (User > Home). */ + let activeUserHomeEditorSections = null; + + /** One-shot ApiClient.updateDisplayPreferences arm state. */ + let nativeHomeSaveMergeState = null; + + function createEmptyHomeScreen() { + return { + sections: [], + pinnedLists: [], + pinnedParents: [] + // pairNativeHomeSections omitted until user dirties the field + }; + } + + function getServerPairNativeHomeSectionsDefault() { + const settings = window.KefinHomeScreen?.getConfig?.()?.USER_HOME_SCREEN_SETTINGS + || window.KefinHomeConfig2?.USER_HOME_SCREEN_SETTINGS + || {}; + return settings.pairNativeHomeSections !== false; + } + + /** + * Effective pair-native setting: user override if present, else server default. + */ + function resolvePairNativeHomeSections(homeScreen) { + if (homeScreen && typeof homeScreen.pairNativeHomeSections === 'boolean') { + return homeScreen.pairNativeHomeSections; + } + return getServerPairNativeHomeSectionsDefault(); + } + + /** + * Disable all native Jellyfin home section slots (homesection0–9 = none). + */ + function disableAllNativeHomeSections(customPrefs, { syncUi = false } = {}) { + const prefs = customPrefs || {}; + let changed = false; + for (let i = 0; i <= 9; i++) { + const key = `homesection${i}`; + const prev = prefs[key]; + const prevNorm = (!prev || prev === '') ? 'none' : String(prev).toLowerCase(); + if (prevNorm !== 'none' || prefs[key] !== 'none') { + prefs[key] = 'none'; + if (prevNorm !== 'none' || prev !== 'none') changed = true; + } + } + if (syncUi) { + for (let i = 0; i <= 9; i++) { + setNativeHomeSectionSelect(findNativeHomeSectionSelect(i + 1), 'none'); + } + } + if (changed) { + LOG('Disabled all Jellyfin homesectionN (pair native off)'); + } + return { slots: Array(10).fill('none'), changed }; + } + + function syncNativeHomeSectionsFromKefin(sections, customPrefs, homeScreen, { syncUi = false } = {}) { + if (resolvePairNativeHomeSections(homeScreen)) { + return enableJellyfinSectionsFromKefin(sections, customPrefs, { syncUi }); + } + return disableAllNativeHomeSections(customPrefs, { syncUi }); + } + + function slugifyPinnedName(name) { + return String(name || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'list'; + } + + function getPinnedListSectionId(listName) { + return `${PINNED_LIST_ID_PREFIX}${slugifyPinnedName(listName)}`; + } + + function getPinnedParentSectionId(parentId) { + return `${PINNED_PARENT_ID_PREFIX}${parentId}`; + } + + function parseSectionPrefString(str) { + if (!str || typeof str !== 'string') return null; + const parts = str.split(';'); + while (parts.length < SECTION_PREF_FIELD_COUNT) parts.push(''); + const id = parts[0]; + if (!id) return null; + const enabledRaw = parts[1]; + const enabled = enabledRaw === '' ? true : enabledRaw === 'true'; + const result = { id, enabled }; + if (parts[2] !== '') { + const order = parseInt(parts[2], 10); + if (!Number.isNaN(order)) result.order = order; + } + if (parts[3] !== '') { + const ttl = parseInt(parts[3], 10); + if (!Number.isNaN(ttl)) result.ttl = ttl; + } + if (parts[4] !== '') result.cardFormat = parts[4]; + if (parts[5] !== '') { + if (parts[5] === 'true') result.animationEnabled = true; + else if (parts[5] === 'false') result.animationEnabled = false; + } + if (parts[6] === 'true') result.hideName = true; + else if (parts[6] === 'false') result.hideName = false; + if (parts[7] === 'true') result.hideCardTitles = true; + else if (parts[7] === 'false') result.hideCardTitles = false; + if (parts[8] !== '') result.cardTitlePosition = parts[8]; + if (parts[9] !== '') result.borderStyle = parts[9]; + if (parts[10] !== '') result.spotlightLayout = parts[10]; + if (parts[11] !== '') result.spotlightSize = parts[11]; + if (parts[12] !== '') { + const tileCount = parseInt(parts[12], 10); + if (!Number.isNaN(tileCount)) result.spotlightTileCount = tileCount; + } + // Field 13: itemsLayout row|grid (legacy true→grid, false/empty→row) + if (parts[13] === 'true' || parts[13] === 'grid') result.itemsLayout = 'grid'; + else if (parts[13] === 'row' || parts[13] === 'false') result.itemsLayout = 'row'; + if (parts[14] !== '') result.cardTitleCapitalization = parts[14]; + if (parts[15] !== '') result.cardTitleFontFamily = parts[15]; + if (parts[16] !== '') result.cardTitleFontSize = parts[16]; + // Field 17: borderColor CSS color string + if (parts[17] !== '') result.borderColor = parts[17]; + // Field 18: cardTitleColor CSS color string + if (parts[18] !== '') result.cardTitleColor = parts[18]; + // Field 19: useGaplessCards + if (parts[19] === 'true') result.useGaplessCards = true; + else if (parts[19] === 'false') result.useGaplessCards = false; + // Field 20: renderMode Normal|Spotlight|Random + if (parts[20] !== '') { + const renderMode = normalizeRenderMode(parts[20]); + if (renderMode) result.renderMode = renderMode; + } + return result; + } + + const RENDER_MODES = ['Normal', 'Spotlight', 'Random']; + + function normalizeRenderMode(value) { + if (value == null || value === '') return null; + const lower = String(value).trim().toLowerCase(); + return RENDER_MODES.find(mode => mode.toLowerCase() === lower) || null; + } + + function resolveRenderMode(section) { + return normalizeRenderMode(section?.renderMode) + || (section?.spotlight === true ? 'Spotlight' : 'Normal'); + } + + function normalizeItemsLayout(value) { + if (value === true || value === 'true' || value === 'grid') return 'grid'; + return 'row'; + } + + function appendBoolPrefField(fields, value, serverValue) { + // Persist defined bools even when equal to server default so user choice survives reload. + if (value === undefined) { + fields.push(''); + return; + } + fields.push(value === true ? 'true' : 'false'); + } + + function appendPrefField(fields, value, serverValue) { + // Persist defined overrides even when equal to server default so user choice survives reload. + if (value == null || value === '') { + fields.push(''); + return; + } + fields.push(String(value)); + } + + function isCustomSectionForPref(section) { + if (!section) return false; + if (section.isCustom === true) return true; + if (section.dataset?.customSection === 'true') return true; + const type = String(section.type || '').toLowerCase(); + if (type === 'custom' || type === 'custom-discovery') return true; + return String(section.id || section.sectionId || '').startsWith('custom'); + } + + function isDiscoverySectionForPref(section) { + if (!section) return false; + if (section.dataset?.discoverySection === 'true') return true; + if (section.discoveryEnabled === true || section.discoverySection === true) return true; + if (section.discoveryType) return true; + const type = String(section.type || '').toLowerCase(); + return type === 'discovery' || type === 'custom-discovery'; + } + + /** Home-surface sections persist layout in kefinTweaks.homeScreen; everything else uses sectionState. */ + function isHomeScreenSection(sectionOrType) { + const t = typeof sectionOrType === 'string' + ? sectionOrType + : (sectionOrType?.type || ''); + const normalized = String(t).toLowerCase(); + // Unspecified type defaults to home surface (legacy sections / missing attrs) + if (!normalized) return true; + return ['home', 'seasonal', 'discovery'].includes(normalized); + } + + function createEmptySectionState() { + return { sections: [] }; + } + + function parseKefinTweaksSectionState(customPrefs) { + if (!customPrefs) return createEmptySectionState(); + try { + const parsed = typeof window.userHelper?.parseKefinTweaks === 'function' + ? window.userHelper.parseKefinTweaks(customPrefs) + : (() => { + const raw = customPrefs.kefinTweaks; + if (!raw) return null; + try { + return typeof raw === 'string' ? JSON.parse(raw) : raw; + } catch (e) { + return null; + } + })(); + const ss = parsed?.sectionState; + if (!ss || typeof ss !== 'object') return createEmptySectionState(); + return { + sections: Array.isArray(ss.sections) ? ss.sections.filter((s) => typeof s === 'string') : [] + }; + } catch (e) { + WARN('Failed to parse kefinTweaks sectionState:', e); + return createEmptySectionState(); + } + } + + async function saveKefinTweaksSectionState(sectionState, displayPrefs) { + try { + if (displayPrefs?.CustomPrefs && window.userHelper?.parseKefinTweaks) { + const kefin = window.userHelper.parseKefinTweaks(displayPrefs); + kefin.sectionState = sectionState || createEmptySectionState(); + displayPrefs.CustomPrefs.kefinTweaks = JSON.stringify(kefin); + const ok = await saveEditorDisplayPreferences(displayPrefs); + if (ok) LOG('kefinTweaks sectionState saved'); + return ok; + } + if (!window.userHelper?.setKefinTweaksFeature) { + ERR('userHelper.setKefinTweaksFeature not available'); + return false; + } + const ok = await window.userHelper.setKefinTweaksFeature('sectionState', sectionState || createEmptySectionState()); + if (ok) LOG('kefinTweaks sectionState saved'); + return ok; + } catch (e) { + ERR('Error saving kefinTweaks sectionState:', e); + return false; + } + } + + function findPrefStringInSections(sections, sectionId) { + const storedId = getStoredSectionPrefId({ id: sectionId }); + if (!storedId) return null; + return (sections || []).find((s) => { + const pref = parseSectionPrefString(s); + return pref?.id && getPrefDedupeKey(pref) === storedId; + }) || null; + } + + /** + * Pref storage key for a section. + * Non-custom discovery sections share one bucket keyed by the ID prefix + * before the first hyphen (e.g. genreMovies-123-abc → genreMovies). + * Custom discovery and all other sections keep the full ID. + */ + function getStoredSectionPrefId(section) { + const id = section?.id || section?.sectionId || ''; + if (!id) return id; + if (isDiscoverySectionForPref(section) && !isCustomSectionForPref(section)) { + const hyphen = id.indexOf('-'); + return hyphen === -1 ? id : id.slice(0, hyphen); + } + return id; + } + + const DISCOVERY_INSTANCE_PREF_PATTERN = /^(.+)-(\d{10,})-([a-zA-Z0-9]+)$/; + + function isDiscoveryInstancePrefId(id) { + if (!id || isCustomSectionForPref({ id })) return false; + if (String(id).startsWith('pinned-')) return false; + if (String(id).startsWith('recently-added-')) return false; + if (String(id).startsWith('popular-genres-')) return false; + return DISCOVERY_INSTANCE_PREF_PATTERN.test(String(id)); + } + + function getPrefDedupeKey(prefOrId) { + const id = typeof prefOrId === 'string' ? prefOrId : prefOrId?.id; + if (!id) return ''; + if (isDiscoveryInstancePrefId(id)) { + return id.slice(0, id.indexOf('-')); + } + return id; + } + + function prefStringRichness(prefString) { + if (!prefString) return 0; + const parts = String(prefString).split(';'); + let score = 0; + for (let i = 2; i < parts.length; i++) { + if (parts[i] !== '') score += 1; + } + return score; + } + + function normalizePrefStringToDedupeKey(prefString) { + const pref = parseSectionPrefString(prefString); + if (!pref?.id) return prefString; + const key = getPrefDedupeKey(pref); + if (!key || pref.id === key) return prefString; + const parts = prefString.split(';'); + parts[0] = key; + return parts.join(';'); + } + + function sanitizeHomeScreenSections(homeScreen) { + const home = homeScreen || createEmptyHomeScreen(); + const input = Array.isArray(home.sections) ? home.sections : []; + const bestByKey = new Map(); + + input.forEach((prefString, index) => { + const pref = parseSectionPrefString(prefString); + if (!pref?.id) return; + const key = getPrefDedupeKey(pref); + if (!key) return; + const score = prefStringRichness(prefString); + const isInstance = isDiscoveryInstancePrefId(pref.id); + const existing = bestByKey.get(key); + if (!existing) { + bestByKey.set(key, { prefString, score, index, isInstance }); + return; + } + const better = score > existing.score + || (score === existing.score && index > existing.index) + || (!isInstance && existing.isInstance); + if (better) { + bestByKey.set(key, { prefString, score, index, isInstance }); + } + }); + + const ordered = [...bestByKey.entries()] + .sort((a, b) => a[1].index - b[1].index) + .map(([key, entry]) => normalizePrefStringToDedupeKey(entry.prefString)); + + const removedCount = input.length - ordered.length; + return { + homeScreen: { + ...home, + sections: ordered + }, + removedCount, + changed: removedCount > 0 + }; + } + + function sectionConfigRichness(section) { + if (!section) return 0; + let score = 0; + if (section.name) score += 1; + if (section.queries?.length || section.items?.length) score += 2; + if (section.jellyfinId || section.renderMode) score += 1; + return score; + } + + function deduplicateHomeScreenSections(sections) { + if (!Array.isArray(sections)) return []; + const result = []; + const byId = new Map(); + sections.forEach((section) => { + const id = section?.id; + if (!id) return; + const existing = byId.get(id); + if (!existing) { + byId.set(id, section); + result.push(section); + return; + } + if (sectionConfigRichness(section) > sectionConfigRichness(existing)) { + const idx = result.indexOf(existing); + if (idx >= 0) result[idx] = section; + byId.set(id, section); + } + }); + return result; + } + + let sanitizePersistInFlight = false; + + function serializeSectionPref(sectionId, enabled, overrides = {}, serverDefaults = {}) { + const fields = [sectionId, enabled === false ? 'false' : 'true']; + appendPrefField(fields, overrides.order, serverDefaults.order); + appendPrefField(fields, overrides.ttl, serverDefaults.ttl); + appendPrefField(fields, overrides.cardFormat, serverDefaults.cardFormat); + let anim = overrides.animationEnabled; + if (anim === undefined && overrides.panAnimation !== undefined) anim = overrides.panAnimation; + const serverAnim = serverDefaults.animationEnabled ?? serverDefaults.panAnimation; + appendBoolPrefField(fields, anim, serverAnim); + appendBoolPrefField(fields, overrides.hideName, serverDefaults.hideName); + appendBoolPrefField(fields, overrides.hideCardTitles, serverDefaults.hideCardTitles); + appendPrefField(fields, overrides.cardTitlePosition, serverDefaults.cardTitlePosition); + appendPrefField(fields, overrides.borderStyle, serverDefaults.borderStyle); + appendPrefField(fields, overrides.spotlightLayout, serverDefaults.spotlightLayout); + appendPrefField(fields, overrides.spotlightSize, serverDefaults.spotlightSize); + appendPrefField(fields, overrides.spotlightTileCount, serverDefaults.spotlightTileCount); + { + const layout = normalizeItemsLayout( + overrides.itemsLayout ?? (overrides.gridExpanded === true ? 'grid' : 'row') + ); + const serverLayout = normalizeItemsLayout( + serverDefaults.itemsLayout ?? (serverDefaults.gridExpanded === true ? 'grid' : 'row') + ); + // Empty = default row; persist 'grid' as 'true' for legacy readers + if (layout === serverLayout || layout === 'row') fields.push(''); + else if (layout === 'grid') fields.push('true'); + else fields.push(''); + } + appendPrefField(fields, overrides.cardTitleCapitalization, serverDefaults.cardTitleCapitalization); + appendPrefField(fields, overrides.cardTitleFontFamily, serverDefaults.cardTitleFontFamily); + appendPrefField(fields, overrides.cardTitleFontSize, serverDefaults.cardTitleFontSize); + appendPrefField(fields, overrides.borderColor, serverDefaults.borderColor); + appendPrefField(fields, overrides.cardTitleColor, serverDefaults.cardTitleColor); + appendBoolPrefField(fields, overrides.useGaplessCards, serverDefaults.useGaplessCards); + appendPrefField( + fields, + normalizeRenderMode(overrides.renderMode), + normalizeRenderMode(serverDefaults.renderMode) || 'Normal' + ); + return fields.join(';'); + } + + function parsePinnedListString(str) { + if (!str || typeof str !== 'string') return null; + const parts = str.split(';'); + const name = parts[0]; + if (!name) return null; + return { name, ids: parts.slice(1).filter(Boolean) }; + } + + function serializePinnedList(name, ids) { + const uniqueIds = [...new Set((ids || []).filter(Boolean))]; + return [name, ...uniqueIds].join(';'); + } + + function parsePinnedParentString(str) { + if (!str || typeof str !== 'string') return null; + const parts = str.split(';'); + const name = parts[0]; + const parentId = parts[1]; + const parentType = parts[2] || null; + if (!name || !parentId) return null; + return { name, parentId, parentType }; + } + + function serializePinnedParent(name, parentId, parentType) { + if (parentType) return `${name};${parentId};${parentType}`; + return `${name};${parentId}`; + } + + function buildPinnedParentQueryOptions(parent) { + const parentId = parent?.parentId; + const parentType = parent?.parentType; + const base = { + Limit: 16, + SortBy: 'Random' + }; + if (!parentId) return base; + + if (parentType === 'Genre' || parentType === 'MusicGenre') { + const opts = { ...base, GenreIds: [parentId], Recursive: true }; + if (parentType === 'Genre') { + opts.IncludeItemTypes = ['Movie', 'Series']; + } + return opts; + } + if (parentType === 'Studio') { + return { + ...base, + StudioIds: [parentId], + Recursive: true, + IncludeItemTypes: ['Movie', 'Series'] + }; + } + if (parentType === 'Person') { + return { + ...base, + PersonIds: [parentId], + Recursive: true, + IncludeItemTypes: ['Movie', 'Series'] + }; + } + if (parentType === 'Tag') { + return { + ...base, + Tags: [parent.name].filter(Boolean), + Recursive: true, + IncludeItemTypes: ['Movie', 'Series'] + }; + } + if (parentType === 'Series') { + return { ...base, ParentId: parentId, ExcludeItemTypes: ['Season'] }; + } + if (parentType === 'MusicArtist') { + return { ...base, ParentId: parentId, ExcludeItemTypes: ['Audio'] }; + } + return { ...base, ParentId: parentId }; + } + + function buildPinnedParentViewMoreUrl(parent, serverId) { + const id = parent?.parentId; + if (!id) return null; + const sid = serverId != null ? String(serverId) : ''; + const type = parent?.parentType; + if (type === 'Genre' || type === 'MusicGenre') { + return `#/list.html?genreId=${encodeURIComponent(id)}&serverId=${encodeURIComponent(sid)}`; + } + if (type === 'Studio') { + return `#/list.html?studioId=${encodeURIComponent(id)}&serverId=${encodeURIComponent(sid)}`; + } + return `#/details?id=${encodeURIComponent(id)}&serverId=${encodeURIComponent(sid)}`; + } + + function migrateLegacyKefinHomeScreen(customPrefs) { + const homeScreen = createEmptyHomeScreen(); + let legacy = []; + if (customPrefs.kefinHomeScreen) { + try { + legacy = typeof customPrefs.kefinHomeScreen === 'string' + ? JSON.parse(customPrefs.kefinHomeScreen) + : (Array.isArray(customPrefs.kefinHomeScreen) ? customPrefs.kefinHomeScreen : []); + } catch (e) { + legacy = []; + } + } + if (Array.isArray(legacy)) { + legacy.forEach(entry => { + if (!entry?.id) return; + homeScreen.sections.push(serializeSectionPref( + entry.id, + entry.enabled !== false, + { order: entry.order }, + {} + )); + }); + } + return homeScreen; + } + + function parseKefinTweaksHomeScreenRaw(customPrefs) { + if (!customPrefs) return createEmptyHomeScreen(); + const parsed = typeof window.userHelper?.parseKefinTweaks === 'function' + ? window.userHelper.parseKefinTweaks(customPrefs) + : (() => { + const raw = customPrefs.kefinTweaks; + if (!raw) return null; + try { + return typeof raw === 'string' ? JSON.parse(raw) : raw; + } catch (e) { + WARN('Failed to parse kefinTweaks homeScreen:', e); + return null; + } + })(); + if (parsed) { + const hs = parsed?.homeScreen || (Array.isArray(parsed?.sections) ? parsed : null); + if (hs) { + const result = { + sections: Array.isArray(hs.sections) ? [...hs.sections] : [], + pinnedLists: Array.isArray(hs.pinnedLists) ? [...hs.pinnedLists] : [], + pinnedParents: Array.isArray(hs.pinnedParents) ? [...hs.pinnedParents] : [] + }; + if (typeof hs.pairNativeHomeSections === 'boolean') { + result.pairNativeHomeSections = hs.pairNativeHomeSections; + } + return result; + } + } + return migrateLegacyKefinHomeScreen(customPrefs); + } + + function parseAndSanitizeKefinTweaksHomeScreen(customPrefs) { + return sanitizeHomeScreenSections(parseKefinTweaksHomeScreenRaw(customPrefs)); + } + + function parseKefinTweaksHomeScreen(customPrefs) { + return parseAndSanitizeKefinTweaksHomeScreen(customPrefs).homeScreen; + } + + function getSectionPrefMap(homeScreen) { + const map = new Map(); + (homeScreen?.sections || []).forEach(str => { + const pref = parseSectionPrefString(str); + if (!pref?.id) return; + map.set(getPrefDedupeKey(pref), pref); + }); + return map; + } + + function upsertSectionPref(homeScreen, prefInput) { + const home = homeScreen || createEmptyHomeScreen(); + const pref = typeof prefInput === 'string' ? parseSectionPrefString(prefInput) : prefInput; + if (!pref?.id) return home; + const storedKey = getPrefDedupeKey(pref); + let serialized = typeof prefInput === 'string' + ? prefInput + : serializeSectionPref(storedKey, pref.enabled !== false, pref, pref.serverDefaults || {}); + serialized = normalizePrefStringToDedupeKey(serialized); + const idx = home.sections.findIndex(s => { + const existing = parseSectionPrefString(s); + return existing?.id && getPrefDedupeKey(existing) === storedKey; + }); + if (idx >= 0) home.sections[idx] = serialized; + else home.sections.push(serialized); + return home; + } + + function removeSectionPref(homeScreen, sectionId) { + const home = homeScreen || createEmptyHomeScreen(); + if (!sectionId) return home; + const targetKey = getPrefDedupeKey(sectionId); + home.sections = (home.sections || []).filter(s => { + const pref = parseSectionPrefString(s); + return !pref?.id || getPrefDedupeKey(pref) !== targetKey; + }); + return home; + } + + function setToastMessage(handle, message) { + if (!handle?.element) return; + const richMessage = handle.element.querySelector('.kefin-toast-message'); + if (richMessage) { + richMessage.textContent = message; + } else { + handle.element.textContent = message; + } + } + + function createProgressToast(total) { + const initialMessage = `Updating... (0/${total})`; + if (!window.KefinTweaksToaster?.toast) { + LOG(initialMessage); + return { + update(done) { + LOG(`Updating... (${done}/${total})`); + }, + finish(done, failed) { + LOG(failed + ? `Updated ${done - failed}/${total} users (${failed} failed)` + : `Updated ${total} users`); + } + }; + } + + const handle = window.KefinTweaksToaster.toast(initialMessage, null, false); + return { + update(done) { + setToastMessage(handle, `Updating... (${done}/${total})`); + }, + finish(done, failed) { + setToastMessage( + handle, + failed + ? `Updated ${done - failed}/${total} users (${failed} failed)` + : `Updated ${total} users` + ); + setTimeout(() => handle?.dismiss?.(), 2500); + } + }; + } + + async function listServerUsers() { + if (typeof ApiClient?.getUsers === 'function') { + return ApiClient.getUsers(); + } + const response = await fetch(`${ApiClient.serverAddress()}/Users`, { + headers: { 'Authorization': window.apiHelper.getAuthHeader() } + }); + if (!response.ok) throw new Error(`Failed to list users: ${response.status}`); + return response.json(); + } + + async function saveHomeScreenForUser(userId, homeScreen, displayPrefs) { + if (!displayPrefs) { + displayPrefs = await window.userHelper.getUserDisplayPreferencesForUser(userId); + } + if (!displayPrefs.CustomPrefs) displayPrefs.CustomPrefs = {}; + const kefin = window.userHelper.parseKefinTweaks(displayPrefs); + kefin.homeScreen = homeScreen; + displayPrefs.CustomPrefs.kefinTweaks = JSON.stringify(kefin); + const updateCache = userId === ApiClient.getCurrentUserId(); + return window.userHelper.updateDisplayPreferencesForUser(userId, displayPrefs, { updateCache }); + } + + /** + * Remove one section override row from every user's homeScreen.sections. + * @param {string} sectionId - Pref storage id (use getStoredSectionPrefId for discovery templates) + * @param {{ onProgress?: Function }} [options] + */ + async function updateUserHomeScreenSectionConfiguration(sectionId, options = {}) { + if (!sectionId) return { done: 0, total: 0, failed: 0 }; + const users = await listServerUsers(); + const total = users.length; + let done = 0; + let failed = 0; + const progressToast = createProgressToast(total); + for (const user of users) { + const userId = user.Id || user.id; + const userName = user.Name || user.name || userId; + try { + const prefs = await window.userHelper.getUserDisplayPreferencesForUser(userId); + let homeScreen = parseKefinTweaksHomeScreen(prefs?.CustomPrefs); + const before = homeScreen.sections.length; + homeScreen = removeSectionPref(homeScreen, sectionId); + if (homeScreen.sections.length !== before) { + await saveHomeScreenForUser(userId, homeScreen, prefs); + } + } catch (e) { + failed += 1; + WARN(`Failed updating homeScreen section for ${userName}:`, e); + } + done += 1; + options.onProgress?.({ done, total, failed, userName }); + progressToast.update(done); + } + progressToast.finish(done, failed); + return { done, total, failed }; + } + + /** + * Resolve admin-enabled server catalog sections used to pack native homesectionN on Update. + */ + function getServerEnabledSectionsForNativeSync() { + const config = window.KefinHomeScreen?.getConfig?.(); + if (Array.isArray(config?.ENABLED_NORMAL_SECTIONS)) { + return config.ENABLED_NORMAL_SECTIONS.filter((s) => s?.enabled === true); + } + const sectionsApi = window.KefinHomeScreen?.getSections; + if (typeof sectionsApi === 'function') { + const { enabledHomeSections = [] } = sectionsApi() || {}; + return (enabledHomeSections || []).filter((s) => s?.enabled === true); + } + return []; + } + + /** + * Clear all section overrides for every user (pins preserved) and rewrite homesection0–9 + * from the server-enabled catalog (honors each user's pairNativeHomeSections). + * @param {{ onProgress?: Function }} [options] + */ + async function updateUserHomeScreenConfiguration(options = {}) { + const serverEnabledSections = getServerEnabledSectionsForNativeSync(); + const users = await listServerUsers(); + const total = users.length; + let done = 0; + let failed = 0; + const progressToast = createProgressToast(total); + for (const user of users) { + const userId = user.Id || user.id; + const userName = user.Name || user.name || userId; + try { + const prefs = await window.userHelper.getUserDisplayPreferencesForUser(userId); + if (!prefs.CustomPrefs) prefs.CustomPrefs = {}; + const homeScreen = parseKefinTweaksHomeScreen(prefs.CustomPrefs); + const hadOverrides = (homeScreen.sections || []).length > 0; + homeScreen.sections = []; + + const jellyfinSync = syncNativeHomeSectionsFromKefin( + serverEnabledSections, + prefs.CustomPrefs, + homeScreen + ); + + if (hadOverrides || jellyfinSync.changed) { + await saveHomeScreenForUser(userId, homeScreen, prefs); + } + } catch (e) { + failed += 1; + WARN(`Failed updating homeScreen / native sections for ${userName}:`, e); + } + done += 1; + options.onProgress?.({ done, total, failed, userName }); + progressToast.update(done); + } + progressToast.finish(done, failed); + return { done, total, failed }; + } + + /** + * All Jellyfin pairing ids for a catalog section (jellyfinId + jellyfinId2). + * Falls back to map / recently-added-* when neither is set. + * @returns {string[]} lowercased unique non-empty ids (excludes 'none') + */ + function getSectionJellyfinIds(section) { + if (!section) return []; + const ids = []; + const push = (value) => { + if (value == null || value === '') return; + const normalized = String(value).toLowerCase(); + if (!normalized || normalized === 'none') return; + if (!ids.includes(normalized)) ids.push(normalized); + }; + + push(section.jellyfinId); + push(section.jellyfinId2); + + if (!ids.length) { + const id = section.id || ''; + if (id.startsWith('recently-added-')) { + push('latestmedia'); + } else { + for (const [jellyfinId, kefinId] of Object.entries(JELLYFIN_HOME_SECTIONS_MAP)) { + if (jellyfinId === 'latestmedia' && id.startsWith('recently-added-')) { + push('latestmedia'); + break; + } + if (kefinId === id) { + push(jellyfinId); + break; + } + } + } + } + return ids; + } + + function sectionMatchesNativeType(section, nativeType) { + const type = String(nativeType || '').toLowerCase(); + if (!type || type === 'none') return false; + return getSectionJellyfinIds(section).includes(type); + } + + /** + * Primary Jellyfin pairing id for a catalog section (first of getSectionJellyfinIds). + */ + function resolveSectionJellyfinId(section) { + const ids = getSectionJellyfinIds(section); + return ids[0] || null; + } + + /** + * Pack enabled Kefin sections into 10 homesection slots via jellyfinId mapping. + * kefinTweaks.homeScreen is the sole enable/order authority. + * @returns {string[]} length 10, unused slots are 'none' + */ + function buildHomesectionSlotsFromKefin(sections) { + const collected = []; + (sections || []).forEach((section) => { + if (section.enabled !== true) return; + const order = section.order || 0; + getSectionJellyfinIds(section).forEach((jellyfinId) => { + collected.push({ jellyfinId, order }); + }); + }); + + collected.sort((a, b) => (a.order || 0) - (b.order || 0)); + const unique = collected.filter((entry, index, self) => + index === self.findIndex(s => s.jellyfinId === entry.jellyfinId) + ); + + const slots = []; + for (let i = 0; i <= 9; i++) { + slots.push(i < unique.length ? unique[i].jellyfinId : 'none'); + } + return slots; + } + + /** + * Write homesection0–9 from enabled Kefin sections. Never enables/disables Kefin prefs. + * @returns {{ slots: string[], changed: boolean }} + */ + function enableJellyfinSectionsFromKefin(sections, customPrefs, { syncUi = false } = {}) { + const prefs = customPrefs || {}; + const slots = buildHomesectionSlotsFromKefin(sections); + let changed = false; + + for (let i = 0; i <= 9; i++) { + const key = `homesection${i}`; + const next = slots[i] || 'none'; + const prev = prefs[key]; + const prevNorm = (!prev || prev === '') ? 'none' : String(prev).toLowerCase(); + if (prevNorm !== next || prefs[key] !== next) { + prefs[key] = next; + if (prevNorm !== next || prev !== next) changed = true; + } + } + + if (syncUi) { + for (let i = 0; i <= 9; i++) { + setNativeHomeSectionSelect(findNativeHomeSectionSelect(i + 1), slots[i]); + } + } + + if (changed) { + LOG('Synced Jellyfin homesectionN from Kefin homeScreen:', slots.filter(s => s !== 'none')); + } + return { slots, changed }; + } + + /** + * Candidate Kefin sections for a native homesection type (inbound enable sync). + * Matches jellyfinId or jellyfinId2 (e.g. resume/nextup both hit continueWatchingAndNextUp). + * latestmedia also includes recently-added-* rows. + */ + function resolveKefinCandidatesForNative(nativeType, catalogSections) { + const type = String(nativeType || '').toLowerCase(); + if (!type || type === 'none') return []; + const catalog = catalogSections || []; + + if (type === 'latestmedia') { + return catalog.filter((s) => { + const id = s?.id || ''; + return id.startsWith('recently-added-') || sectionMatchesNativeType(s, 'latestmedia'); + }); + } + return catalog.filter((s) => sectionMatchesNativeType(s, type)); + } + + const RECENTLY_ADDED_PREFIX = 'recently-added-'; + const LATEST_EXCLUDE_VIEW_TYPES = new Set(['playlists', 'livetv', 'boxsets', 'channels', 'folders']); + + function cloneRecentlyAddedSection(section) { + const next = { ...section }; + if (Array.isArray(section.queries)) { + next.queries = section.queries.map((q) => ({ + ...q, + queryOptions: q?.queryOptions ? { ...q.queryOptions } : {} + })); + } + return next; + } + + function applyHidePlayedInLatestToSection(section) { + if (!Array.isArray(section.queries)) return section; + section.queries = section.queries.map((q) => ({ + ...q, + queryOptions: { + ...(q.queryOptions || {}), + IsPlayed: false + } + })); + return section; + } + + /** Template for a combined UserView recently-added row (id overwritten at bind). */ + function buildCombinedRecentlyAddedTemplate(collectionType) { + const isMovies = collectionType === 'movies'; + return { + id: '', + name: isMovies ? 'Recently Added Movies' : 'Recently Added TV Shows', + // Native Library Folders grouping produced this view ⇒ default on until user pref says otherwise + enabled: true, + order: 61, + cardFormat: 'Poster', + jellyfinId: 'latestmedia', + userConfigurable: true, + queries: [{ + path: '/Items/Latest', + queryOptions: { + Fields: 'PrimaryImageAspectRatio,Path', + Limit: 16, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb' + } + }] + }; + } + + async function fetchUserViewsItems() { + const userId = await resolvePreferencesTargetUserId(); + if (!userId || !window.ApiClient) return []; + if (window.ApiClient.getUserViews) { + const result = await window.ApiClient.getUserViews({}, userId); + if (Array.isArray(result)) return result; + return result?.Items || []; + } + return []; + } + + /** + * Reshape recently-added-* for the viewing user to match native Latest: + * UserViews membership, LatestItemsExcludes, combined UserView rows, HidePlayedInLatest. + * Combined views use recently-added-{viewId}; defaults enabled when the view exists. + * Returns in-memory clones only — does not persist queryOptions. + */ + async function resolveRecentlyAddedSectionsForUser(sections) { + const list = Array.isArray(sections) ? sections : []; + let user = null; + try { + const targetId = await resolvePreferencesTargetUserId(); + const currentId = window.ApiClient?.getCurrentUserId?.(); + if (targetId && currentId && targetId !== currentId && typeof window.ApiClient?.getUser === 'function') { + user = await window.ApiClient.getUser(targetId); + } else { + user = await window.ApiClient?.getCurrentUser?.(); + } + } catch (e) { + WARN('getCurrentUser failed during recently-added reshape:', e); + } + + const hidePlayed = user?.Configuration?.HidePlayedInLatest === true; + const excludes = new Set( + (user?.Configuration?.LatestItemsExcludes || []).map((id) => String(id)) + ); + + let userViews = []; + try { + userViews = await fetchUserViewsItems(); + } catch (e) { + WARN('UserViews unavailable; applying HidePlayedInLatest only:', e); + if (!hidePlayed) return list; + return list.map((section) => { + if (!String(section?.id || '').startsWith(RECENTLY_ADDED_PREFIX)) return section; + return applyHidePlayedInLatestToSection(cloneRecentlyAddedSection(section)); + }); + } + + const physicalLibraryIds = new Set(); + try { + if (window.dataHelper?.getLibraries) { + const libs = await window.dataHelper.getLibraries(); + (libs || []).forEach((lib) => { + if (lib?.Id) physicalLibraryIds.add(String(lib.Id)); + }); + } + } catch (_) { /* fall through */ } + if (physicalLibraryIds.size === 0) { + list.forEach((section) => { + const id = String(section?.id || ''); + if (!id.startsWith(RECENTLY_ADDED_PREFIX)) return; + const suffix = id.slice(RECENTLY_ADDED_PREFIX.length); + // Skip legacy catalog shells (recently-added-grouped-*) + if (!suffix || suffix.startsWith('grouped-')) return; + physicalLibraryIds.add(suffix); + }); + } + + const eligibleViews = (userViews || []).filter((view) => { + if (!view?.Id) return false; + if (excludes.has(String(view.Id))) return false; + const ct = String(view.CollectionType || '').toLowerCase(); + if (ct && LATEST_EXCLUDE_VIEW_TYPES.has(ct)) return false; + return true; + }); + const eligibleViewIds = new Set(eligibleViews.map((view) => String(view.Id))); + + // Combined Movies/TV UserViews from native Library Folders grouping + const combinedByType = new Map(); + eligibleViews.forEach((view) => { + const ct = String(view.CollectionType || '').toLowerCase(); + if ((ct === 'movies' || ct === 'tvshows') && !physicalLibraryIds.has(String(view.Id))) { + combinedByType.set(ct, view); + } + }); + + const result = []; + + for (const section of list) { + const id = String(section?.id || ''); + if (!id.startsWith(RECENTLY_ADDED_PREFIX)) { + result.push(section); + continue; + } + + const viewId = id.slice(RECENTLY_ADDED_PREFIX.length); + // Drop legacy grouped-* catalog shells; keep only eligible UserView ids + if (!viewId || viewId.startsWith('grouped-') || !eligibleViewIds.has(viewId)) continue; + + const next = cloneRecentlyAddedSection(section); + if (hidePlayed) applyHidePlayedInLatestToSection(next); + result.push(next); + } + + // Always synthesize combined UserView rows from UserViews (source of truth) + for (const [ct, view] of combinedByType) { + const sectionId = `${RECENTLY_ADDED_PREFIX}${view.Id}`; + const next = cloneRecentlyAddedSection(buildCombinedRecentlyAddedTemplate(ct)); + next.id = sectionId; + if (view.Name) next.name = `Recently Added ${view.Name}`; + if (Array.isArray(next.queries)) { + next.queries = next.queries.map((q) => ({ + ...q, + queryOptions: { + ...(q.queryOptions || {}), + ParentId: view.Id + } + })); + } + if (hidePlayed) applyHidePlayedInLatestToSection(next); + result.push(next); + } + + return result; + } + + /** + * Enable Kefin sections from native Jellyfin homesectionN (enable-only, no order changes). + * If any candidate for a native type is already enabled, skip that type. + * @returns {{ changed: boolean, enabledIds: string[] }} + */ + function syncKefinEnabledFromNativeHomeSections(homeScreen, customPrefs, catalogSections) { + const home = homeScreen || createEmptyHomeScreen(); + const prefs = customPrefs || {}; + const catalog = catalogSections || []; + const prefMap = getSectionPrefMap(home); + + const nativeTypes = []; + for (let i = 0; i <= 9; i++) { + const raw = resolveHomesectionValue(prefs, i); + if (!raw || String(raw).toLowerCase() === 'none') continue; + nativeTypes.push(String(raw).toLowerCase()); + } + const uniqueNative = [...new Set(nativeTypes)]; + if (!uniqueNative.length) return { changed: false, enabledIds: [] }; + + function sectionIsEnabled(section) { + if (!section?.id) return false; + const storedId = getStoredSectionPrefId(section); + const pref = prefMap.get(getPrefDedupeKey({ id: section.id })) + || prefMap.get(getPrefDedupeKey({ id: storedId })); + if (pref && pref.enabled !== undefined) return pref.enabled === true; + return section.enabled === true; + } + + const enabledIds = []; + let changed = false; + + uniqueNative.forEach((nativeType) => { + const candidates = resolveKefinCandidatesForNative(nativeType, catalog); + if (!candidates.length) return; + // Covered if any candidate is already on (includes continueWatchingAndNextUp) + if (candidates.some(sectionIsEnabled)) return; + + candidates.forEach((section) => { + // Never auto-enable the combined Continue Watching + Next Up row + if (section.id === 'continueWatchingAndNextUp') return; + + const storedId = getStoredSectionPrefId(section); + const existing = prefMap.get(getPrefDedupeKey({ id: section.id })) + || prefMap.get(getPrefDedupeKey({ id: storedId })) + || {}; + upsertSectionPref(home, { + ...existing, + id: storedId || section.id, + enabled: true + }); + // Refresh map entry for subsequent checks in this pass + const updated = parseSectionPrefString( + home.sections.find((s) => { + const p = parseSectionPrefString(s); + return p?.id && getPrefDedupeKey(p) === getPrefDedupeKey({ id: storedId || section.id }); + }) + ); + if (updated) prefMap.set(getPrefDedupeKey(updated), updated); + enabledIds.push(section.id); + changed = true; + }); + }); + + if (changed) { + LOG('Enabled Kefin sections from native homesectionN:', enabledIds); + } + return { changed, enabledIds }; + } + + /** + * Full home screen config for the current user (server sections + pins + overrides). + * kefinTweaks.homeScreen is sole enable source; Jellyfin homesectionN is rewritten to match. + * @returns {Promise<{ sections: Array, homeScreen: Object, serverSections: Array, pinnedSections: Array }>} + */ + async function getConfig() { + const sectionsApi = window.KefinHomeScreen?.getSections; + if (!sectionsApi) { + throw new Error('KefinHomeScreen.getSections is not available'); + } + const { enabledHomeSections = [], enabledDiscoverySections = [] } = await Promise.resolve(sectionsApi()); + const serverSections = [...enabledHomeSections]; + + let customPrefs = {}; + let displayPrefs = null; + if (window.userHelper?.getUserDisplayPreferences || window.userHelper?.getUserDisplayPreferencesForUser) { + displayPrefs = await getEditorDisplayPreferences(); + customPrefs = displayPrefs?.CustomPrefs || {}; + } + + const sanitizeResult = parseAndSanitizeKefinTweaksHomeScreen(customPrefs); + const homeScreen = sanitizeResult.homeScreen; + + if (sanitizeResult.changed && displayPrefs && !sanitizePersistInFlight) { + sanitizePersistInFlight = true; + try { + await saveKefinTweaksHomeScreen(homeScreen, displayPrefs); + LOG(`Sanitized homeScreen prefs: removed ${sanitizeResult.removedCount} duplicate/stale rows`); + } finally { + sanitizePersistInFlight = false; + } + } + + const pinnedSections = buildPinnedSectionConfigs(homeScreen); + const serverSectionsById = new Map(); + serverSections.forEach((section) => { + if (section?.id) serverSectionsById.set(section.id, section); + }); + + let sections = deduplicateHomeScreenSections([...serverSections, ...pinnedSections]); + sections = applyUserSectionOverrides(sections, homeScreen, { serverSectionsById }); + + // Inbound: enable Kefin sections from native homesectionN (enable-only; skip if any match already on) + const inboundSync = syncKefinEnabledFromNativeHomeSections(homeScreen, customPrefs, sections); + if (inboundSync.changed) { + sections = applyUserSectionOverrides( + deduplicateHomeScreenSections([...serverSections, ...pinnedSections]), + homeScreen, + { serverSectionsById } + ); + if (displayPrefs && !sanitizePersistInFlight) { + sanitizePersistInFlight = true; + try { + await saveKefinTweaksHomeScreen(homeScreen, displayPrefs); + LOG('Persisted Kefin enable flags from native homesectionN'); + } finally { + sanitizePersistInFlight = false; + } + } + } + + // Align recently-added-* with native Latest (UserViews, excludes, combined views, HidePlayedInLatest) + sections = await resolveRecentlyAddedSectionsForUser(sections); + // Prefs must win over synthesized defaults (combined rows default enabled:true) + sections = applyUserSectionOverrides(sections, homeScreen, { serverSectionsById }); + + sections = sections.filter((section) => section.enabled || section.userConfigurable === true || section.userConfigurable === undefined); + sections = deduplicateHomeScreenSections(sections); + + const jellyfinSync = syncNativeHomeSectionsFromKefin(sections, customPrefs, homeScreen, { syncUi: true }); + if (jellyfinSync.changed && displayPrefs && !sanitizePersistInFlight) { + sanitizePersistInFlight = true; + try { + if (!displayPrefs.CustomPrefs) displayPrefs.CustomPrefs = customPrefs; + const ok = await saveEditorDisplayPreferences(displayPrefs); + if (ok) { + LOG('Persisted Jellyfin homesectionN from Kefin homeScreen'); + } else { + WARN('Failed to persist Jellyfin homesectionN sync'); + } + } finally { + sanitizePersistInFlight = false; + } + } + + return { + sections, + homeScreen, + serverSections, + pinnedSections, + enabledDiscoverySections + }; + } + + function resolveSpotlightPresentationLocal(options = {}) { + if (typeof window !== 'undefined' && window.cardBuilder?.resolveSpotlightPresentation) { + return window.cardBuilder.resolveSpotlightPresentation({ + ...options, + capForViewport: false + }); + } + const fullScreen = options.fullScreen === true; + const layout = options.spotlightLayout ?? (fullScreen ? 'Borderless' : 'Border'); + const size = options.spotlightSize ?? (fullScreen ? 'full' : 'normal'); + let tileCount = options.tileCount; + if (tileCount == null || tileCount < 1 || tileCount > 3) { + tileCount = size === 'full' ? 1 : size === 'large' ? 2 : 3; + } + tileCount = Math.max(1, Math.min(3, Math.floor(tileCount))); + return { layout, size, tileCount }; + } + + function getServerSectionDefaults(section) { + if (!section) return {}; + const spotlight = section.spotlightConfig || {}; + const resolved = resolveSpotlightPresentationLocal(spotlight); + const id = String(section.id || ''); + const isPinned = id.startsWith(PINNED_LIST_ID_PREFIX) || id.startsWith(PINNED_PARENT_ID_PREFIX); + return { + renderMode: resolveRenderMode(section), + order: isPinned ? PINNED_DEFAULT_ORDER : (section.order ?? 0), + ttl: section.ttl, + cardFormat: section.cardFormat, + animationEnabled: spotlight.panAnimation !== false, + panAnimation: spotlight.panAnimation !== false, + hideName: section.hideName === true, + hideCardTitles: section.cardTitleVisibility === 'hidden' || section.hideCardTitles === true, + cardTitlePosition: section.cardTitlePosition || '', + borderStyle: section.borderStyle || '', + borderColor: section.borderColor || '', + spotlightLayout: resolved.layout, + spotlightSize: resolved.size, + spotlightTileCount: resolved.tileCount, + itemsLayout: 'row', + useGaplessCards: false, + cardTitleCapitalization: section.cardTitleCapitalization || 'normal', + cardTitleFontFamily: section.cardTitleFontFamily || 'default', + cardTitleFontSize: section.cardTitleFontSize || 'normal', + cardTitleColor: section.cardTitleColor || '' + }; + } + + function applyUserSectionOverrides(sections, homeScreen, options = {}) { + const prefMap = getSectionPrefMap(homeScreen); + const serverById = options.serverSectionsById || new Map(); + + return sections.map(section => { + const storedId = getStoredSectionPrefId(section); + const pref = prefMap.get(getPrefDedupeKey({ id: section.id })) + || prefMap.get(storedId); + let next = { ...section }; + + // UI pref: apply for all sections (not gated by userConfigurable) + if (pref?.itemsLayout && pref.itemsLayout !== 'row') next.itemsLayout = pref.itemsLayout; + else if (pref?.gridExpanded === true) next.itemsLayout = 'grid'; + else delete next.itemsLayout; + delete next.gridExpanded; + if (pref?.useGaplessCards === true) next.useGaplessCards = true; + else delete next.useGaplessCards; + + if (section.userConfigurable === false) return next; + if (!pref) return next; + const serverDefaults = getServerSectionDefaults( + serverById.get(section.id) || serverById.get(storedId) || section + ); + if (pref.enabled !== undefined) next.enabled = pref.enabled; + if (pref.renderMode !== undefined) { + next.renderMode = pref.renderMode; + next.spotlight = pref.renderMode === 'Spotlight'; + } + if (pref.order !== undefined) next.order = pref.order; + if (pref.ttl !== undefined) next.ttl = pref.ttl; + if (pref.cardFormat !== undefined) next.cardFormat = pref.cardFormat; + if (pref.animationEnabled !== undefined) { + next.spotlightConfig = { + ...(next.spotlightConfig || {}), + panAnimation: pref.animationEnabled + }; + } + if (pref.hideName !== undefined) { + if (pref.hideName) next.hideName = true; + else delete next.hideName; + } + if (pref.hideCardTitles !== undefined) { + if (pref.hideCardTitles) { + next.cardTitleVisibility = 'hidden'; + delete next.hideCardTitles; + } else { + delete next.cardTitleVisibility; + delete next.hideCardTitles; + } + } + if (pref.cardTitlePosition !== undefined) { + if (pref.cardTitlePosition && pref.cardTitlePosition !== 'default') { + next.cardTitlePosition = pref.cardTitlePosition; + } else { + delete next.cardTitlePosition; + } + } + if (pref.cardTitleCapitalization !== undefined) { + if (pref.cardTitleCapitalization && pref.cardTitleCapitalization !== 'normal') { + next.cardTitleCapitalization = pref.cardTitleCapitalization; + } else { + delete next.cardTitleCapitalization; + } + } + if (pref.cardTitleFontFamily !== undefined) { + if (pref.cardTitleFontFamily && pref.cardTitleFontFamily !== 'default') { + next.cardTitleFontFamily = pref.cardTitleFontFamily; + } else { + delete next.cardTitleFontFamily; + } + } + if (pref.cardTitleFontSize !== undefined) { + if (pref.cardTitleFontSize && pref.cardTitleFontSize !== 'normal') { + next.cardTitleFontSize = pref.cardTitleFontSize; + } else { + delete next.cardTitleFontSize; + } + } + if (pref.borderStyle !== undefined) { + if (pref.borderStyle) next.borderStyle = pref.borderStyle; + else delete next.borderStyle; + } + if (pref.borderColor !== undefined) { + if (pref.borderColor) next.borderColor = pref.borderColor; + else delete next.borderColor; + } + if (pref.cardTitleColor !== undefined) { + if (pref.cardTitleColor) next.cardTitleColor = pref.cardTitleColor; + else delete next.cardTitleColor; + } + if (pref.spotlightLayout !== undefined || pref.spotlightSize !== undefined || pref.spotlightTileCount !== undefined) { + next.spotlightConfig = { ...(next.spotlightConfig || {}) }; + if (pref.spotlightLayout !== undefined) { + next.spotlightConfig.spotlightLayout = pref.spotlightLayout; + } + if (pref.spotlightSize !== undefined) { + next.spotlightConfig.spotlightSize = pref.spotlightSize; + } + if (pref.spotlightTileCount !== undefined) { + next.spotlightConfig.tileCount = pref.spotlightTileCount; + } + } + next._userPrefDefaults = serverDefaults; + return next; + }); + } + + function buildPinnedSectionConfigs(homeScreen) { + const configs = []; + + (homeScreen?.pinnedLists || []).forEach(listStr => { + const list = parsePinnedListString(listStr); + if (!list || !list.ids.length) return; + const sectionId = getPinnedListSectionId(list.name); + configs.push({ + id: sectionId, + name: list.name, + enabled: true, + order: PINNED_DEFAULT_ORDER, + userConfigurable: true, + renderMode: 'Normal', + cardFormat: 'Poster', + queries: [{ + _sourceType: 'static', + queryOptions: { Ids: [...list.ids] } + }] + }); + }); + + (homeScreen?.pinnedParents || []).forEach(parentStr => { + const parent = parsePinnedParentString(parentStr); + if (!parent) return; + const sectionId = getPinnedParentSectionId(parent.parentId); + configs.push({ + id: sectionId, + name: parent.name, + enabled: true, + order: PINNED_DEFAULT_ORDER, + userConfigurable: true, + renderMode: 'Normal', + cardFormat: 'Poster', + queries: [{ + queryOptions: buildPinnedParentQueryOptions(parent) + }] + }); + }); + + return configs; + } + + async function loadKefinTweaksHomeScreenFromDisplayPrefs(customPrefs) { + return parseKefinTweaksHomeScreen(customPrefs); + } + + async function saveKefinTweaksHomeScreen(homeScreen, displayPrefs = null) { + if (!window.userHelper?.setKefinTweaksFeature) { + ERR('userHelper.setKefinTweaksFeature not available'); + return false; + } + try { + // If caller passed displayPrefs, still use merge-safe set so siblings are preserved. + // Refresh cache first when prefs were already loaded to avoid stale overwrites. + if (displayPrefs?.CustomPrefs) { + const sanitized = sanitizeHomeScreenSections(homeScreen).homeScreen; + const kefin = window.userHelper.parseKefinTweaks(displayPrefs); + kefin.homeScreen = sanitized; + displayPrefs.CustomPrefs.kefinTweaks = JSON.stringify(kefin); + displayPrefs.CustomPrefs.kefinHomeScreen = JSON.stringify(homeScreenToLegacyArray(sanitized)); + const ok = await saveEditorDisplayPreferences(displayPrefs); + if (ok) LOG('kefinTweaks homeScreen saved'); + return ok; + } + const ok = await window.userHelper.setKefinTweaksFeature('homeScreen', homeScreen); + if (ok) LOG('kefinTweaks homeScreen saved'); + return ok; + } catch (e) { + ERR('Error saving kefinTweaks homeScreen:', e); + return false; + } + } + + /** + * Persist kefinTweaks.homeScreen and rewrite Jellyfin homesection0–9 to match + * (same outbound pairing as saveUserPreferences / getConfig). Single displayPrefs write. + * Used by the home section configure popover after order/enable changes. + * @param {Object} homeScreen + * @returns {Promise} + */ + async function saveKefinTweaksHomeScreenAndSyncNative(homeScreen) { + if (!window.userHelper?.getUserDisplayPreferences && !window.userHelper?.getUserDisplayPreferencesForUser) { + ERR('userHelper not available for homeScreen + native sync save'); + return false; + } + try { + const displayPrefs = await getEditorDisplayPreferences(); + if (!displayPrefs) { + ERR('Could not load display preferences for homeScreen + native sync'); + return false; + } + if (!displayPrefs.CustomPrefs) displayPrefs.CustomPrefs = {}; + + const sanitized = sanitizeHomeScreenSections(homeScreen).homeScreen; + const kefin = typeof window.userHelper?.parseKefinTweaks === 'function' + ? window.userHelper.parseKefinTweaks(displayPrefs) + : {}; + kefin.homeScreen = sanitized; + displayPrefs.CustomPrefs.kefinTweaks = JSON.stringify(kefin); + displayPrefs.CustomPrefs.kefinHomeScreen = JSON.stringify(homeScreenToLegacyArray(sanitized)); + + // Build effective sections for slot packing (no inbound native→Kefin enable pass) + let sections = []; + const sectionsApi = window.KefinHomeScreen?.getSections; + if (typeof sectionsApi === 'function') { + const sectionsResult = await Promise.resolve(sectionsApi()); + const { enabledHomeSections = [] } = sectionsResult || {}; + const serverSections = [...enabledHomeSections]; + const pinnedSections = buildPinnedSectionConfigs(sanitized); + const serverSectionsById = await buildServerSectionsById(); + serverSections.forEach((section) => { + if (section?.id && !serverSectionsById.has(section.id)) { + serverSectionsById.set(section.id, section); + } + }); + sections = deduplicateHomeScreenSections([...serverSections, ...pinnedSections]); + sections = applyUserSectionOverrides(sections, sanitized, { serverSectionsById }); + try { + sections = await resolveRecentlyAddedSectionsForUser(sections); + sections = applyUserSectionOverrides(sections, sanitized, { serverSectionsById }); + } catch (e) { + WARN('resolveRecentlyAddedSectionsForUser failed during native sync save:', e); + } + sections = sections.filter((section) => ( + section.enabled + || section.userConfigurable === true + || section.userConfigurable === undefined + )); + sections = deduplicateHomeScreenSections(sections); + } else { + WARN('KefinHomeScreen.getSections unavailable; saving homeScreen without native slot rebuild'); + } + + if (sections.length) { + syncNativeHomeSectionsFromKefin( + sections, + displayPrefs.CustomPrefs, + sanitized, + { syncUi: false } + ); + } + + const ok = await saveEditorDisplayPreferences(displayPrefs); + if (ok) LOG('kefinTweaks homeScreen saved (+ native homesectionN sync)'); + else ERR('Failed to save homeScreen + native sync'); + return ok; + } catch (e) { + ERR('Error saving homeScreen + syncing native homesectionN:', e); + return false; + } + } + + /** + * Persist items layout UI preference for a section (row|grid). + * Home / seasonal / discovery → kefinTweaks.homeScreen; otherwise → kefinTweaks.sectionState. + * Patches field 13 of the existing pref string so other overrides are preserved. + * @param {string} sectionId + * @param {string|boolean} layout - 'row'|'grid', or legacy true→grid + * @param {{ type?: string }} [options] + * @returns {Promise} + */ + async function saveSectionItemsLayout(sectionId, layout, options = {}) { + if (!sectionId) return false; + try { + if (!window.userHelper?.getUserDisplayPreferences && !window.userHelper?.getUserDisplayPreferencesForUser) { + ERR('userHelper not available'); + return false; + } + const sectionEl = typeof document !== 'undefined' + ? [...document.querySelectorAll('[data-section-id]')].find((el) => el.dataset.sectionId === String(sectionId)) + : null; + const typeHint = options.type + || sectionEl?.getAttribute?.('data-section-type') + || sectionEl?.dataset?.sectionType + || ''; + // series-episodes (and other non-home) must not fall through empty type → homeScreen + const resolvedType = typeHint + || (String(sectionId).startsWith('series-episodes-') ? 'series-episodes' : 'home'); + const storedId = getStoredSectionPrefId(sectionEl + ? { id: sectionId, dataset: sectionEl.dataset, type: resolvedType } + : { id: sectionId, type: resolvedType }); + const displayPrefs = await getEditorDisplayPreferences(); + const useHomeScreen = isHomeScreenSection(resolvedType); + let bucket = useHomeScreen + ? parseKefinTweaksHomeScreen(displayPrefs?.CustomPrefs) + : parseKefinTweaksSectionState(displayPrefs?.CustomPrefs); + const raw = findPrefStringInSections(bucket.sections, storedId) + || findPrefStringInSections(bucket.sections, sectionId); + const parts = (raw || `${storedId};true`).split(';'); + while (parts.length < SECTION_PREF_FIELD_COUNT) parts.push(''); + parts[0] = storedId; + if (parts[1] === '') parts[1] = 'true'; + const normalized = normalizeItemsLayout(layout); + // Empty = row; 'true' for grid (legacy) + parts[13] = normalized === 'grid' ? 'true' : ''; + if (useHomeScreen) { + bucket = upsertSectionPref(bucket, parts.join(';')); + return await saveKefinTweaksHomeScreen(bucket, displayPrefs); + } + bucket = upsertSectionPref(bucket, parts.join(';')); + return await saveKefinTweaksSectionState({ sections: bucket.sections || [] }, displayPrefs); + } catch (e) { + ERR('Error saving itemsLayout preference:', e); + return false; + } + } + + /** + * Persist useGaplessCards UI preference for a section (field 19). + * @param {string} sectionId + * @param {boolean} useGaplessCards + * @param {{ type?: string }} [options] + * @returns {Promise} + */ + async function saveSectionUseGaplessCards(sectionId, useGaplessCards, options = {}) { + if (!sectionId) return false; + try { + if (!window.userHelper?.getUserDisplayPreferences && !window.userHelper?.getUserDisplayPreferencesForUser) { + ERR('userHelper not available'); + return false; + } + const sectionEl = typeof document !== 'undefined' + ? [...document.querySelectorAll('[data-section-id]')].find((el) => el.dataset.sectionId === String(sectionId)) + : null; + const typeHint = options.type + || sectionEl?.getAttribute?.('data-section-type') + || sectionEl?.dataset?.sectionType + || ''; + const resolvedType = typeHint + || (String(sectionId).startsWith('series-episodes-') ? 'series-episodes' : 'home'); + const storedId = getStoredSectionPrefId(sectionEl + ? { id: sectionId, dataset: sectionEl.dataset, type: resolvedType } + : { id: sectionId, type: resolvedType }); + const displayPrefs = await getEditorDisplayPreferences(); + const useHomeScreen = isHomeScreenSection(resolvedType); + let bucket = useHomeScreen + ? parseKefinTweaksHomeScreen(displayPrefs?.CustomPrefs) + : parseKefinTweaksSectionState(displayPrefs?.CustomPrefs); + const raw = findPrefStringInSections(bucket.sections, storedId) + || findPrefStringInSections(bucket.sections, sectionId); + const parts = (raw || `${storedId};true`).split(';'); + while (parts.length < SECTION_PREF_FIELD_COUNT) parts.push(''); + parts[0] = storedId; + if (parts[1] === '') parts[1] = 'true'; + parts[19] = useGaplessCards === true ? 'true' : (useGaplessCards === false ? 'false' : ''); + if (useHomeScreen) { + bucket = upsertSectionPref(bucket, parts.join(';')); + return await saveKefinTweaksHomeScreen(bucket, displayPrefs); + } + bucket = upsertSectionPref(bucket, parts.join(';')); + return await saveKefinTweaksSectionState({ sections: bucket.sections || [] }, displayPrefs); + } catch (e) { + ERR('Error saving useGaplessCards preference:', e); + return false; + } + } + + /** + * Read itemsLayout from homeScreen or sectionState for a section id. + * @param {string} sectionId + * @param {{ type?: string }} [options] + * @returns {Promise<'row'|'grid'|null>} + */ + async function loadSectionItemsLayout(sectionId, options = {}) { + if (!sectionId) return null; + try { + if (!window.userHelper?.getUserDisplayPreferences && !window.userHelper?.getUserDisplayPreferencesForUser) return null; + const displayPrefs = await getEditorDisplayPreferences(); + const typeHint = options.type + || (String(sectionId).startsWith('series-episodes-') ? 'series-episodes' : 'home'); + const bucket = isHomeScreenSection(typeHint) + ? parseKefinTweaksHomeScreen(displayPrefs?.CustomPrefs) + : parseKefinTweaksSectionState(displayPrefs?.CustomPrefs); + const storedId = getStoredSectionPrefId({ id: sectionId, type: typeHint }); + const raw = findPrefStringInSections(bucket.sections, storedId) + || findPrefStringInSections(bucket.sections, sectionId); + if (!raw) return null; + const pref = parseSectionPrefString(raw); + return pref?.itemsLayout || null; + } catch (e) { + WARN('Error loading itemsLayout preference:', e); + return null; + } + } + + /** @deprecated Use saveSectionItemsLayout */ + async function saveSectionGridExpanded(sectionId, gridExpanded) { + if (gridExpanded === 'row' || gridExpanded === 'grid') { + return saveSectionItemsLayout(sectionId, gridExpanded); + } + return saveSectionItemsLayout(sectionId, gridExpanded === true ? 'grid' : 'row'); + } + + function homeScreenToLegacyArray(homeScreen) { + const seen = new Set(); + return (homeScreen?.sections || []).map(str => { + const pref = parseSectionPrefString(str); + if (!pref) return null; + const key = getPrefDedupeKey(pref); + if (!key || seen.has(key)) return null; + seen.add(key); + return { + id: key, + enabled: pref.enabled !== false, + order: pref.order ?? 0 + }; + }).filter(Boolean); + } + + /** + * Get Jellyfin default sections from display preferences + */ + function getJellyfinSectionsFromPrefs(displayPrefs) { + const sections = []; + const customPrefs = displayPrefs?.CustomPrefs || {}; + + // Build a set of all base IDs that appear in any homesectionN field (0-9) + const enabledBaseIds = new Set(); + for (let i = 0; i <= 9; i++) { + const homesectionValue = resolveHomesectionValue(customPrefs, i); + if (homesectionValue && homesectionValue !== 'none') { + enabledBaseIds.add(homesectionValue); + } + } + + // Parse user home screen section preferences + const homeScreen = parseKefinTweaksHomeScreen(customPrefs); + const kefinHomeScreen = homeScreenToLegacyArray(homeScreen); + + // Process each Jellyfin section + JELLYFIN_HOME_SECTIONS.forEach((baseId, index) => { + const homesectionKey = `homesection${index}`; + + // Section is enabled if its base ID appears in ANY homesectionN field (0-8) + const isEnabled = enabledBaseIds.has(baseId); + + // Get order and enabled status from kefinHomeScreen if available + let order = 0; + let enabledFromPrefs = isEnabled; // Default to homesectionN value + + if (Array.isArray(kefinHomeScreen) && kefinHomeScreen.length > 0) { + // Look for section by base ID (new format) or homesectionKey (old format for backward compatibility) + const sectionPref = kefinHomeScreen.find(s => + s.id === baseId || s.id === homesectionKey + ); + if (sectionPref) { + if (sectionPref.order !== undefined) { + order = sectionPref.order; + } + if (sectionPref.enabled !== undefined) { + enabledFromPrefs = sectionPref.enabled; + } + } + } + + sections.push({ + id: baseId, // Use base ID directly, no -jellyfin suffix + name: getJellyfinSectionName(baseId), + enabled: enabledFromPrefs, + order: order, + hidden: false, + isJellyfin: true, + jellyfinBaseId: baseId, // Track the Jellyfin base ID + homesectionKey: homesectionKey // Keep for backward compatibility + }); + }); + + return sections; + } + + /** + * Get display name for Jellyfin section + */ + function getJellyfinSectionName(baseId) { + const names = { + 'smalllibrarytiles': 'My Media', + 'librarybuttons': 'My Media (small)', + 'resume': 'Continue Watching', + 'resumeaudio': 'Continue Listening', + 'resumebook': 'Continue Reading', + 'nextup': 'Next Up', + 'latestmedia': 'Recently Added Media', + 'livetv': 'Live TV', + 'activerecordings': 'Active Recordings', + 'none': 'None' + }; + return names[baseId] || baseId; + } + + /** Label aliases for matching native select options across Jellyfin versions. */ + function getJellyfinSectionLabelAliases(baseId) { + const primary = getJellyfinSectionName(baseId); + const aliases = { + resume: ['Continue Watching', 'Resume'], + resumeaudio: ['Continue Listening', 'Resume Audio'], + resumebook: ['Continue Reading', 'Resume Book'], + latestmedia: ['Recently Added Media', 'Latest Media'], + none: ['None'] + }; + const list = aliases[baseId] || (primary ? [primary] : []); + if (primary && !list.includes(primary)) list.unshift(primary); + return list; + } + + function findNativeHomeSectionSelect(index1Based) { + const id = `selectHomeSection${index1Based}`; + const scoped = document.querySelector(`.homeScreenSettingsContainer #${id}`); + if (scoped) return scoped; + const page = document.querySelector('.libraryPage:not(.hide)'); + if (page) { + const inPage = page.querySelector(`#${id}`); + if (inPage) return inPage; + } + return document.getElementById(id); + } + + /** + * Resolve the option value to set on a native home-section select. + * v12 often uses empty value="" for the default/selected option; match by label when needed. + */ + function resolveNativeHomeSectionOptionValue(select, jellyfinId) { + if (!select) return null; + const target = (!jellyfinId || jellyfinId === 'none') ? 'none' : String(jellyfinId).toLowerCase(); + const options = Array.from(select.options || []); + + if (target !== 'none') { + const byValue = options.find(o => o.value === target); + if (byValue) return byValue.value; + } else { + const noneByValue = options.find(o => o.value === 'none'); + if (noneByValue) return noneByValue.value; + } + + const labels = getJellyfinSectionLabelAliases(target).map(l => l.toLowerCase()); + const byLabel = options.find(o => labels.includes(String(o.textContent || '').trim().toLowerCase())); + if (byLabel) return byLabel.value; + + if (target === 'none') return 'none'; + return null; + } + + function setNativeHomeSectionSelect(select, jellyfinId) { + if (!select) return; + const value = resolveNativeHomeSectionOptionValue(select, jellyfinId); + if (value === null) return; + try { + select.value = value; + select.dispatchEvent(new Event('change', { bubbles: true, cancelable: true })); + } catch (e) { + WARN('Failed to sync native home section select UI:', e); + } + } + + /** + * Load user preferences from display preferences + * @param {Object} displayPrefs - Optional display preferences object to use instead of fetching + */ + async function loadUserPreferences(displayPrefs = null) { + try { + if (!displayPrefs) { + if (!window.userHelper || (!window.userHelper.getUserDisplayPreferences && !window.userHelper.getUserDisplayPreferencesForUser)) { + WARN('userHelper not available'); + return { kefinHomeScreen: [], homesections: {} }; + } + + displayPrefs = await getEditorDisplayPreferences(); + } + + if (!displayPrefs || !displayPrefs.CustomPrefs) { + return { kefinHomeScreen: [], homesections: {} }; + } + + const customPrefs = displayPrefs.CustomPrefs; + const homeScreen = parseKefinTweaksHomeScreen(customPrefs); + const kefinHomeScreen = homeScreenToLegacyArray(homeScreen); + + // Extract homesectionN values (Jellyfin defaults when keys absent/empty) + const homesections = {}; + for (let i = 0; i <= 9; i++) { + const key = `homesection${i}`; + homesections[key] = resolveHomesectionValue(customPrefs, i); + } + + return { + homeScreen, + kefinHomeScreen: Array.isArray(kefinHomeScreen) ? kefinHomeScreen : [], + homesections: homesections + }; + } catch (error) { + ERR('Error loading user preferences:', error); + return { homeScreen: createEmptyHomeScreen(), kefinHomeScreen: [], homesections: {} }; + } + } + + function mergeEditorSectionsIntoHomeScreen(homeScreen, editorSections, serverSectionsById) { + const home = homeScreen || createEmptyHomeScreen(); + const editorIds = new Set(editorSections.map(s => getStoredSectionPrefId(s))); + const kept = (home.sections || []).filter(str => { + const pref = parseSectionPrefString(str); + if (!pref?.id) return false; + return !editorIds.has(getPrefDedupeKey(pref)); + }); + const updated = editorSections.map(section => { + const storedId = getStoredSectionPrefId(section); + const serverSection = serverSectionsById.get(section.id) || section; + const existingRaw = (home.sections || []).find(s => { + const pref = parseSectionPrefString(s); + return pref?.id && getPrefDedupeKey(pref) === storedId; + }); + const existing = parseSectionPrefString(existingRaw) || {}; + const serverDefaults = section._userPrefDefaults + || getServerSectionDefaults(serverSection); + return serializeSectionPref( + storedId, + section.enabled !== false, + { ...existing, order: section.order }, + serverDefaults + ); + }); + return sanitizeHomeScreenSections({ + ...home, + sections: [...kept, ...updated] + }).homeScreen; + } + + /** + * Sync patch for outbound DisplayPreferences CustomPrefs from live editor sections. + * Uses the outbound prefs shell when provided; otherwise userHelper/localStorage cache. + * @returns {{ kefinTweaks: string, kefinHomeScreen: string }|null} + */ + function buildKefinCustomPrefsPatch(sections, customPrefsShell) { + if (!Array.isArray(sections) || sections.length === 0) return null; + + let customPrefs = customPrefsShell && typeof customPrefsShell === 'object' + ? customPrefsShell + : null; + if (!customPrefs) { + try { + if (window.LocalStorageCache) { + const cache = new window.LocalStorageCache(); + const cached = cache.get('userDisplayPreferences'); + if (cached?.CustomPrefs) customPrefs = { ...cached.CustomPrefs }; + } + } catch (_) { /* ignore */ } + } + if (!customPrefs) customPrefs = {}; + + const existingHomeScreen = parseKefinTweaksHomeScreen(customPrefs); + const serverMap = new Map(); + sections.forEach((s) => { + if (s?.id) serverMap.set(s.id, s); + }); + const homeScreen = mergeEditorSectionsIntoHomeScreen(existingHomeScreen, sections, serverMap); + const kefin = typeof window.userHelper?.parseKefinTweaks === 'function' + ? window.userHelper.parseKefinTweaks(customPrefs) + : {}; + kefin.homeScreen = homeScreen; + return { + kefinTweaks: JSON.stringify(kefin), + kefinHomeScreen: JSON.stringify(homeScreenToLegacyArray(homeScreen)) + }; + } + + function findDisplayPrefsArg(args) { + if (!args || !args.length) return null; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a && typeof a === 'object' && !Array.isArray(a) && ('CustomPrefs' in a || 'Id' in a || 'Client' in a)) { + return a; + } + } + // jellyfin-apiclient: (id, prefs, userId, client) — prefs often at [1] + if (args[1] && typeof args[1] === 'object' && !Array.isArray(args[1])) return args[1]; + return null; + } + + function applyKefinPatchToDisplayPrefs(prefs, sections) { + if (!prefs || !Array.isArray(sections) || !sections.length) return false; + if (!prefs.CustomPrefs || typeof prefs.CustomPrefs !== 'object') { + prefs.CustomPrefs = {}; + } + const patch = buildKefinCustomPrefsPatch(sections, prefs.CustomPrefs); + if (!patch) return false; + prefs.CustomPrefs.kefinTweaks = patch.kefinTweaks; + prefs.CustomPrefs.kefinHomeScreen = patch.kefinHomeScreen; + return true; + } + + function disarmNativeHomeSaveKefinMerge() { + const state = nativeHomeSaveMergeState; + nativeHomeSaveMergeState = null; + if (!state) return; + if (state.timeoutId != null) { + clearTimeout(state.timeoutId); + state.timeoutId = null; + } + const apiClient = state.apiClient; + if (apiClient && state.original + && apiClient.updateDisplayPreferences !== state.original + && typeof state.original === 'function') { + apiClient.updateDisplayPreferences = state.original; + } + } + + /** + * One-shot: next ApiClient.updateDisplayPreferences merges live Kefin CustomPrefs, then disarms. + */ + function armNativeHomeSaveKefinMerge({ getSections, timeoutMs = 2000 } = {}) { + const apiClient = window.ApiClient; + if (!apiClient || typeof apiClient.updateDisplayPreferences !== 'function') { + WARN('armNativeHomeSaveKefinMerge: ApiClient.updateDisplayPreferences unavailable'); + return false; + } + + // Already armed: refresh callback / reset timeout; do not stack wrappers + if (nativeHomeSaveMergeState) { + nativeHomeSaveMergeState.getSections = getSections; + if (nativeHomeSaveMergeState.timeoutId != null) { + clearTimeout(nativeHomeSaveMergeState.timeoutId); + } + nativeHomeSaveMergeState.timeoutId = setTimeout(() => { + if (nativeHomeSaveMergeState) { + LOG('Native Save Kefin merge: timeout disarm (no DisplayPreferences flush)'); + disarmNativeHomeSaveKefinMerge(); + } + }, timeoutMs); + return true; + } + + const original = apiClient.updateDisplayPreferences.bind(apiClient); + const state = { + apiClient, + original, + getSections, + timeoutId: null + }; + nativeHomeSaveMergeState = state; + + apiClient.updateDisplayPreferences = function (...args) { + try { + const sections = typeof state.getSections === 'function' ? state.getSections() : null; + const prefs = findDisplayPrefsArg(args); + if (prefs && Array.isArray(sections) && sections.length) { + const ok = applyKefinPatchToDisplayPrefs(prefs, sections); + if (ok) LOG('Merged live kefinTweaks into outbound DisplayPreferences'); + } + } catch (e) { + ERR('Failed to merge kefinTweaks into outbound DisplayPreferences:', e); + } + disarmNativeHomeSaveKefinMerge(); + return original(...args); + }; + + state.timeoutId = setTimeout(() => { + if (nativeHomeSaveMergeState === state) { + LOG('Native Save Kefin merge: timeout disarm (no DisplayPreferences flush)'); + disarmNativeHomeSaveKefinMerge(); + } + }, timeoutMs); + + LOG('Armed one-shot DisplayPreferences Kefin merge'); + return true; + } + + /** + * Map discovery section ids → parent group { groupId, groupName } for User > Home badges. + * Includes DISCOVERY_SECTION_GROUPS and discoveryEnabled sections in CUSTOM_SECTION_GROUPS. + * @param {Object} mergedConfig + * @returns {Map} + */ + function buildDiscoverySectionGroupMap(mergedConfig) { + const map = new Map(); + if (!mergedConfig) return map; + + const stampGroup = (group, discoveryOnly) => { + const groupId = String(group?.id || ''); + const groupName = String(group?.name || groupId || 'Unnamed Group'); + (group?.sections || []).forEach((section) => { + if (!section?.id) return; + if (discoveryOnly && section.discoveryEnabled !== true) return; + map.set(section.id, { groupId, groupName }); + }); + }; + + (mergedConfig.DISCOVERY_SECTION_GROUPS || []).forEach((group) => stampGroup(group, false)); + (mergedConfig.CUSTOM_SECTION_GROUPS || []).forEach((group) => stampGroup(group, true)); + return map; + } + + function buildServerSectionsByIdFromConfig(mergedConfig) { + const map = new Map(); + if (!mergedConfig) return map; + const allGroups = [ + ...(mergedConfig.HOME_SECTION_GROUPS || []), + ...(mergedConfig.SEASONAL_SECTION_GROUPS || []), + ...(mergedConfig.DISCOVERY_SECTION_GROUPS || []), + ...(mergedConfig.CUSTOM_SECTION_GROUPS || []) + ]; + flattenSectionGroups(allGroups).forEach(section => { + if (section?.id) map.set(section.id, section); + }); + return map; + } + + async function buildServerSectionsById() { + if (!window.KefinHomeScreen?.getConfig) return new Map(); + try { + const mergedConfig = await window.KefinHomeScreen.getConfig(); + return buildServerSectionsByIdFromConfig(mergedConfig); + } catch (e) { + WARN('Could not build server sections map:', e); + return new Map(); + } + } + + /** + * Merge user preferences with sections (editor helper for Jellyfin native rows). + * Kefin enable/disable comes from homeScreen prefs / catalog defaults only — + * Jellyfin enable/order for native rows comes from CustomPrefs.homesectionN; + * Kefin enable is owned by homeScreen prefs (see enableJellyfinSectionsFromKefin). + */ + function mergeUserPreferences(sections, userPrefs, mergedConfig = null, displayPrefs = null) { + const homeScreen = userPrefs.homeScreen || parseKefinTweaksHomeScreen(displayPrefs?.CustomPrefs); + const prefMap = getSectionPrefMap(homeScreen); + const customPrefs = displayPrefs?.CustomPrefs || {}; + + // Build set of base IDs that appear in ANY homesectionN field (0-9) for Jellyfin sections + const enabledJellyfinBaseIds = new Set(); + for (let i = 0; i <= 9; i++) { + const homesectionValue = resolveHomesectionValue(customPrefs, i); + if (homesectionValue && homesectionValue !== 'none') { + enabledJellyfinBaseIds.add(homesectionValue); + } + } + + // Helper to get default enabled status from config + const getDefaultEnabled = (sectionId) => { + if (!mergedConfig) return true; // Default to enabled if no config + + // Search through all section groups + const allGroups = [ + ...(mergedConfig.HOME_SECTION_GROUPS || []), + ...(mergedConfig.SEASONAL_SECTION_GROUPS || []), + ...(mergedConfig.DISCOVERY_SECTION_GROUPS || []), + ...(mergedConfig.CUSTOM_SECTION_GROUPS || []) + ]; + + for (const group of allGroups) { + if (group.sections) { + const section = group.sections.find(s => s.id === sectionId); + if (section) { + return section.enabled !== false; + } + } + } + + return true; // Default to enabled if not found + }; + + // Helper to check if a section ID is a Jellyfin section + const isJellyfinSection = (sectionId) => { + return JELLYFIN_HOME_SECTIONS.includes(sectionId); + }; + + // Update sections with user preferences + return sections.map(section => { + const sectionId = section.id; + let isEnabled; + + // For Jellyfin sections, check if base ID appears in any homesectionN field + if (section.isJellyfin || isJellyfinSection(sectionId)) { + const baseId = section.jellyfinBaseId || sectionId; + isEnabled = enabledJellyfinBaseIds.has(baseId); + } else { + const pref = prefMap.get(getPrefDedupeKey({ id: sectionId })) + || prefMap.get(sectionId); + const hasUserEnabledPref = pref && pref.enabled !== undefined; + isEnabled = hasUserEnabledPref + ? pref.enabled + : getDefaultEnabled(sectionId); + } + + const pref = prefMap.get(getPrefDedupeKey({ id: sectionId })) + || prefMap.get(sectionId); + const userOrder = pref?.order; + + return { + ...section, + enabled: isEnabled, + order: userOrder !== undefined ? userOrder : (section.order || 0) + }; + }); + } + + /** + * Save user preferences to display preferences + * @param {Array} sections + * @param {{ pairNativeHomeSections?: boolean|null }} [options] + * pairNativeHomeSections: true/false to set override; null/undefined to leave unchanged; + * pass 'default' symbol via omitOverride to clear dirty override + */ + async function saveUserPreferences(sections, options = {}) { + try { + if (!window.userHelper || (!window.userHelper.getUserDisplayPreferences && !window.userHelper.getUserDisplayPreferencesForUser)) { + ERR('userHelper not available'); + return false; + } + + // Get display preferences for the user being edited (URL userId or session) + const displayPrefs = await getEditorDisplayPreferences(); + + if (!displayPrefs) { + ERR('Could not load display preferences'); + return false; + } + + if (!displayPrefs.CustomPrefs) { + displayPrefs.CustomPrefs = {}; + } + + const customPrefs = displayPrefs.CustomPrefs; + + // Build kefinTweaks.homeScreen with sparse section strings + const existingHomeScreen = parseKefinTweaksHomeScreen(customPrefs); + const serverSectionsById = await buildServerSectionsById(); + const homeScreen = sanitizeHomeScreenSections( + mergeEditorSectionsIntoHomeScreen(existingHomeScreen, sections, serverSectionsById) + ).homeScreen; + + // Preserve or update dirty-only pairNativeHomeSections + if (Object.prototype.hasOwnProperty.call(options, 'pairNativeHomeSections')) { + const serverDefault = getServerPairNativeHomeSectionsDefault(); + const next = options.pairNativeHomeSections; + if (next === null || next === undefined) { + delete homeScreen.pairNativeHomeSections; + } else if (next === serverDefault) { + delete homeScreen.pairNativeHomeSections; + } else { + homeScreen.pairNativeHomeSections = next === true; + } + } else if (typeof existingHomeScreen.pairNativeHomeSections === 'boolean') { + homeScreen.pairNativeHomeSections = existingHomeScreen.pairNativeHomeSections; + } + + const kefin = typeof window.userHelper?.parseKefinTweaks === 'function' + ? window.userHelper.parseKefinTweaks(customPrefs) + : {}; + kefin.homeScreen = homeScreen; + customPrefs.kefinTweaks = JSON.stringify(kefin); + customPrefs.kefinHomeScreen = JSON.stringify(homeScreenToLegacyArray(homeScreen)); + + // Rewrite Jellyfin homesectionN (+ native selects) from enabled Kefin sections + syncNativeHomeSectionsFromKefin(sections, customPrefs, homeScreen, { syncUi: true }); + + // Save + const success = await saveEditorDisplayPreferences(displayPrefs); + if (success) { + LOG('User preferences saved successfully'); + window.homeScreen3?.notifyUserHomePreferencesChanged?.('userHomePrefsSaved'); + } else { + ERR('Failed to save user preferences'); + } + return success; + } catch (error) { + ERR('Error saving user preferences:', error); + return false; + } + } + + /** + * Wait until admin homeScreen-configuration has registered getSections/getConfig. + * Injector loads deps in parallel, so prefs can open before that API exists. + */ + function waitForHomeScreenSectionsApi(timeoutMs = 15000, intervalMs = 100) { + return new Promise((resolve) => { + if (typeof window.KefinHomeScreen?.getSections === 'function' + && typeof window.KefinHomeScreen?.getConfig === 'function') { + resolve(true); + return; + } + const start = Date.now(); + const timer = setInterval(() => { + if (typeof window.KefinHomeScreen?.getSections === 'function' + && typeof window.KefinHomeScreen?.getConfig === 'function') { + clearInterval(timer); + resolve(true); + return; + } + if (Date.now() - start >= timeoutMs) { + clearInterval(timer); + resolve(false); + } + }, intervalMs); + }); + } + + function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Render user home sections editor + */ + async function renderUserHomeSectionsEditor(container) { + if (!container) { + ERR('Container not provided'); + return; + } + + activeUserHomeEditorSections = null; + + try { + container.innerHTML = '
Loading home sections…
'; + + const apiReady = await waitForHomeScreenSectionsApi(); + if (!apiReady) { + ERR('KefinHomeScreen.getSections not available'); + container.innerHTML = '
Error loading editor. Please refresh the page.
'; + return; + } + + if (!window.KefinTweaksUI || !window.KefinTweaksUI.renderHomeSectionsOrderEditor) { + ERR('KefinTweaksUI.renderHomeSectionsOrderEditor not available'); + container.innerHTML = '
Error loading editor. Please refresh the page.
'; + return; + } + + // Resolved Kefin sections only (no Jellyfin-native duplicate rows) + const userConfig = await getConfig(); + let mergedHomeConfig = null; + try { + mergedHomeConfig = await window.KefinHomeScreen?.getConfig?.(); + } catch (e) { + WARN('Could not load merged home config for editor labels:', e); + } + const discoveryFeatureEnabled = mergedHomeConfig?.DISCOVERY_SETTINGS?.enabled !== false; + const serverSectionsById = buildServerSectionsByIdFromConfig(mergedHomeConfig); + const discoveryGroupMap = buildDiscoverySectionGroupMap(mergedHomeConfig); + + const filterEditorSection = (s) => { + if (s.userConfigurable === false) return false; + // Keep hidden sections only when currently enabled + if (s.hidden === true && s.enabled === false) return false; + return true; + }; + + const normalizeEditorSectionType = (section) => { + if (isDiscoverySectionForPref(section)) return 'discovery'; + const t = String(section.type || '').toLowerCase(); + if (t === 'seasonal') return 'seasonal'; + return 'home'; + }; + + let homeSections = (userConfig.sections || []).filter(filterEditorSection); + let discoverySections = []; + if (discoveryFeatureEnabled && Array.isArray(userConfig.enabledDiscoverySections)) { + discoverySections = applyUserSectionOverrides( + userConfig.enabledDiscoverySections.filter(filterEditorSection), + userConfig.homeScreen, + { serverSectionsById } + ).map((section) => { + const groupMeta = discoveryGroupMap.get(section.id); + if (!groupMeta) return section; + return { + ...section, + discoveryGroupId: groupMeta.groupId, + discoveryGroupName: groupMeta.groupName + }; + }); + } + + let allSections = [...homeSections, ...discoverySections].map((section) => { + const serverSection = serverSectionsById.get(section.id); + const caption = section.caption || serverSection?.caption; + const groupMeta = discoveryGroupMap.get(section.id); + const next = { + ...section, + type: normalizeEditorSectionType(section), + ...(caption != null && caption !== '' ? { caption } : {}) + }; + if (groupMeta) { + next.discoveryGroupId = groupMeta.groupId; + next.discoveryGroupName = groupMeta.groupName; + } + return next; + }); + + // Sort by order + allSections.sort((a, b) => (a.order || 0) - (b.order || 0)); + + // Create editor HTML + const editorHTML = window.KefinTweaksUI.renderHomeSectionsOrderEditor(allSections, { + showUpDownButtons: true + }); + + const serverPairDefault = getServerPairNativeHomeSectionsDefault(); + const effectivePair = resolvePairNativeHomeSections(userConfig.homeScreen); + const pairDescOn = 'Do NOT enable this if you use the official Jellyfin for Roku or Android TV clients. When this is enabled, your Home Screen on those clients will be empty and you will need to go to your User Settings to re-enable sections.'; + const pairDescOff = 'Do NOT enable this if you use the official Jellyfin for Roku or Android TV clients. When this is enabled, your Home Screen on those clients will be empty and you will need to go to your User Settings to re-enable sections.'; + const pairToggleHTML = typeof window.KefinTweaksUI?.buildToggleCard === 'function' + ? window.KefinTweaksUI.buildToggleCard( + 'kefin-user-pair-native-home-sections', + !effectivePair, + 'Improve Home Screen Performance', + !effectivePair ? pairDescOn : pairDescOff, + { hintKey: 'pairNativeHomeSections' } + ) + : ''; + + container.innerHTML = ` +
+
+

Home Sections Order

+ +
+ ${editorHTML} + ${pairToggleHTML ? ` +
+ ${pairToggleHTML} +
+ ` : ''} +
+ `; + + // Pair-native toggle: dirty-only persist (INVERTED UI: on in UI means flag == false) + const pairCheckbox = container.querySelector('#kefin-user-pair-native-home-sections'); + const pairCard = pairCheckbox?.closest('.kefin-toggle-card'); + if (pairCheckbox && pairCard) { + const pairSwitch = pairCard.querySelector('.kefin-toggle-switch, .toggle-slider, button'); + // When the toggle appears "on" in the UI, the flag is actually false (inverse) + const updatePairDesc = (checked) => { + // checked (UI "on") means flag === false + const desc = pairCard.querySelector('.kefin-toggle-card-desc'); + if (desc) desc.textContent = checked ? pairDescOn : pairDescOff; + }; + const onPairToggle = async () => { + // checked (UI 'on') means flag should be false + const flagValue = !pairCheckbox.checked; + updatePairDesc(pairCheckbox.checked); + if (typeof window.KefinTweaksUI?.updateToggleSwitchUI === 'function' && pairSwitch) { + window.KefinTweaksUI.updateToggleSwitchUI(pairSwitch, pairCheckbox.checked); + } + // For persistence: (flagValue === serverPairDefault) ? null : flagValue + await saveUserPreferences(allSections, { + pairNativeHomeSections: flagValue === serverPairDefault ? null : flagValue + }); + }; + pairSwitch?.addEventListener('click', (e) => { + e.preventDefault(); + pairCheckbox.checked = !pairCheckbox.checked; + onPairToggle(); + }); + pairCheckbox.addEventListener('change', onPairToggle); + + // On initial render, enforce the correct description and checked value + // (in case render logic needs to set checked/unchecked based on flag) + // (Optional: If not already being handled by upstream render logic) + //pairCheckbox.checked = !effectivePair; + updatePairDesc(pairCheckbox.checked); + } + + // Restore Defaults: confirm then clear kefinHomeScreen and re-render + const restoreBtn = container.querySelector('.kefin-restore-defaults-btn'); + if (restoreBtn && window.ModalSystem) { + restoreBtn.addEventListener('click', () => { + const modalId = 'kefin-user-homescreen-restore-defaults'; + const content = '

Restoring the default Home Screen Settings will override your existing settings. Are you sure you want to proceed?

'; + const footer = ` + + + `; + window.ModalSystem.create({ + id: modalId, + title: 'Restore Defaults', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + onOpen: (modal) => { + const confirmBtn = modal.dialogFooter && modal.dialogFooter.querySelector('#kefin-restore-defaults-confirm'); + if (confirmBtn) { + confirmBtn.addEventListener('click', async () => { + try { + if (!window.userHelper || (!window.userHelper.getUserDisplayPreferences && !window.userHelper.getUserDisplayPreferencesForUser)) { + WARN('userHelper not available'); + return; + } + const displayPrefs = await getEditorDisplayPreferences(); + if (!displayPrefs) { + WARN('Could not load display preferences'); + return; + } + if (!displayPrefs.CustomPrefs) { + displayPrefs.CustomPrefs = {}; + } + const existing = parseKefinTweaksHomeScreen(displayPrefs.CustomPrefs); + existing.sections = []; + const kefin = typeof window.userHelper?.parseKefinTweaks === 'function' + ? window.userHelper.parseKefinTweaks(displayPrefs) + : {}; + kefin.homeScreen = existing; + displayPrefs.CustomPrefs.kefinTweaks = JSON.stringify(kefin); + displayPrefs.CustomPrefs.kefinHomeScreen = '[]'; + const ok = await saveEditorDisplayPreferences(displayPrefs); + if (ok) { + window.homeScreen3?.notifyUserHomePreferencesChanged?.('userHomePrefsRestoreDefaults'); + window.ModalSystem.close(modalId); + await renderUserHomeSectionsEditor(container); + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Home Screen settings restored to defaults.'); + } + } else { + WARN('Failed to restore defaults'); + } + } catch (e) { + ERR('Error restoring defaults:', e); + } + }); + } + } + }); + }); + } + + // Setup event listeners + // Find the sections-container which wraps both enabled and disabled lists + const editorContainer = container.querySelector('.sections-container') || container.querySelector('.group-sections-list'); + if (editorContainer && window.KefinTweaksUI.setupOrderEditorListeners) { + // Track current section state + let currentSections = [...allSections]; + activeUserHomeEditorSections = currentSections; + let isSaving = false; + + // Extract save logic into reusable function + const performSave = async () => { + if (isSaving) return; // Prevent concurrent saves + isSaving = true; + + try { + // Read DOM order and update sections array + // Only read order from enabled sections list (disabled sections preserve their order) + const enabledList = container.querySelector('.enabled-sections-list') || editorContainer.querySelector('.enabled-sections-list'); + const sectionIdToOrder = new Map(); + + if (enabledList) { + // Get all section rows in enabled list in their current DOM order + const sectionRows = Array.from(enabledList.querySelectorAll('.section-row')); + + // Assign order values starting from 1, incrementing by 1 for each section in DOM order + sectionRows.forEach((row, index) => { + const sectionId = row.dataset.sectionId; + if (sectionId) { + sectionIdToOrder.set(sectionId, index + 1); + } + }); + } + + // Update order values in currentSections based on DOM order (only for enabled sections) + currentSections.forEach(section => { + const domOrder = sectionIdToOrder.get(section.id); + if (domOrder !== undefined) { + // Only update order if section is in enabled list + section.order = domOrder; + } + // Disabled sections keep their existing order value (not updated) + }); + + await saveUserPreferences(currentSections); + } finally { + isSaving = false; + } + }; + + const handleToggleChange = async (sectionId, enabled) => { + const section = currentSections.find(s => (s.id || '') === sectionId); + if (section) { + section.enabled = enabled; + } + // Auto-save when toggle changes + await performSave(); + }; + + // Setup listeners with auto-save on order changes + window.KefinTweaksUI.setupOrderEditorListeners(editorContainer, { + onOrderChange: null, // Don't update order values during drag/drop, we'll read from DOM + onToggleChange: handleToggleChange, + getSectionOrder: null, // Not needed since we're not calculating orders + sections: currentSections, + onSave: performSave // Pass save function to be called on drag/drop and up/down + }); + } + + LOG('User home sections editor rendered'); + } catch (error) { + ERR('Error rendering user home sections editor:', error); + container.innerHTML = '
Error loading editor. Please refresh the page.
'; + } + } + + // Register onViewPage handler for mypreferenceshome page + function registerPreferencesPageHandler() { + if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { + setTimeout(registerPreferencesPageHandler, 1000); + return; + } + + window.KefinTweaksUtils.onViewPage(async (view, element, hash) => { + LOG('Preferences home page detected'); + + let form = null; + for (let attempt = 0; attempt < 20; attempt++) { + form = document.querySelector('.libraryPage:not(.hide) .homeScreenSettingsContainer > form'); + if (form && form.firstChild) break; + await delay(150); + } + + if (!form || !form.firstChild) { + WARN('Home settings form not ready'); + return; + } + + if (form.dataset.kefinNativeSaveHook !== 'true') { + form.dataset.kefinNativeSaveHook = 'true'; + form.addEventListener('submit', () => { + armNativeHomeSaveKefinMerge({ + getSections: () => activeUserHomeEditorSections + }); + }); + } + + if (form.querySelector('#kefin-user-home-sections-editor')) { + return; + } + + const editorContainer = document.createElement('div'); + editorContainer.id = 'kefin-user-home-sections-editor'; + form.appendChild(editorContainer); + await renderUserHomeSectionsEditor(editorContainer); + }, { + pages: ['mypreferenceshome', 'userpreferences'] + }); + + LOG('Registered preferences page handler'); + } + + // Initialize + registerPreferencesPageHandler(); + + // Expose render function globally + window.KefinUserHomeScreenConfig = { + renderUserHomeSectionsEditor, + parseKefinTweaksHomeScreen, + parseSectionPrefString, + serializeSectionPref, + getStoredSectionPrefId, + upsertSectionPref, + removeSectionPref, + applyUserSectionOverrides, + buildPinnedSectionConfigs, + saveKefinTweaksHomeScreen, + saveKefinTweaksHomeScreenAndSyncNative, + loadKefinTweaksHomeScreenFromDisplayPrefs, + createEmptyHomeScreen, + parsePinnedListString, + serializePinnedList, + parsePinnedParentString, + serializePinnedParent, + buildPinnedParentQueryOptions, + buildPinnedParentViewMoreUrl, + getPinnedListSectionId, + getPinnedParentSectionId, + getServerSectionDefaults, + saveSectionItemsLayout, + saveSectionUseGaplessCards, + loadSectionItemsLayout, + saveSectionGridExpanded, + isHomeScreenSection, + parseKefinTweaksSectionState, + saveKefinTweaksSectionState, + createEmptySectionState, + normalizeItemsLayout, + normalizeRenderMode, + resolveRenderMode, + DEFAULT_PINNED_LIST_NAME, + PINNED_DEFAULT_ORDER, + homeScreenToLegacyArray, + getConfig, + resolvePreferencesTargetUserId, + enableJellyfinSectionsFromKefin, + syncNativeHomeSectionsFromKefin, + syncKefinEnabledFromNativeHomeSections, + resolvePairNativeHomeSections, + getServerPairNativeHomeSectionsDefault, + buildHomesectionSlotsFromKefin, + getSectionJellyfinIds, + sectionMatchesNativeType, + resolveSectionJellyfinId, + resolveHomesectionValue, + DEFAULT_NATIVE_HOME_SECTIONS, + updateUserHomeScreenConfiguration, + updateUserHomeScreenSectionConfiguration + }; + + LOG('User Home Screen Configuration loaded'); + +})(); diff --git a/configuration/homeScreenAdvancedEditor.css b/configuration/homeScreenAdvancedEditor.css new file mode 100644 index 0000000..a8e69b3 --- /dev/null +++ b/configuration/homeScreenAdvancedEditor.css @@ -0,0 +1,1836 @@ +/* Advanced section editor (custom sections) — kefin-homescreen-section-editor modal */ +.dialogContainer:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + [data-name="kefin-modal-content"] { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-root { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + margin: 0 auto; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-editor-body { + flex: 1; + padding: 0.25em 0; + min-height: 0; + margin: 0 auto; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-editor-body > * { + max-width: 800px; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-group:last-child { + padding-bottom: 1.25em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-main { + display: flex; + align-items: center; + gap: 0.35em; + flex: 1; + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-title-wrap { + display: flex; + align-items: center; + gap: 0.5em; + min-width: 0; + flex: 1; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-title-wrap + h2, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-title-wrap + .dialogTitle { + margin: 0; + text-align: left; + flex-shrink: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-section-name-badge { + display: inline-flex; + align-items: center; + max-width: 100%; + padding: 0.2em 0.65em; + border-radius: 999px; + border: 1px solid rgba(0, 164, 220, 0.45); + background: rgba(0, 164, 220, 0.12); + color: var(--theme-primary-color, #00a4dc); + font-size: 0.875rem; + font-weight: 500; + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-actions { + display: inline-flex; + align-items: center; + gap: 0.1em; + flex-shrink: 0; + margin-left: 0.15em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-action-btn { + flex-shrink: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-action-btn[hidden] { + display: none !important; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .formDialogHeader + .hsae-header-action-btn + .material-icons { + font-size: 1.35rem; +} + +.hsae-restore-diff-table { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + font-size: 0.85rem; +} + +.hsae-restore-diff-row { + display: grid; + grid-template-columns: minmax(5.5rem, 0.9fr) 1.1fr 1.1fr; + gap: 0.5em; + padding: 0.45em 0.65em; + border-top: 1px solid rgba(255, 255, 255, 0.08); + align-items: start; +} + +.hsae-restore-diff-row:first-child { + border-top: none; +} + +.hsae-restore-diff-head { + font-weight: 600; + opacity: 0.85; + background: rgba(255, 255, 255, 0.04); +} + +.hsae-restore-diff-row > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hsae-troubleshoot-summary-wrap { + margin-bottom: 0.85em; +} + +.hsae-troubleshoot-summary { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + overflow: hidden; +} + +.hsae-troubleshoot-summary th, +.hsae-troubleshoot-summary td { + padding: 0.45em 0.65em; + text-align: left; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.hsae-troubleshoot-summary thead th { + font-weight: 600; + opacity: 0.9; + background: rgba(255, 255, 255, 0.04); + border-top: none; +} + +.hsae-troubleshoot-panels { + display: flex; + flex-direction: column; + gap: 0.85em; +} + +.hsae-troubleshoot-panel { + min-width: 0; +} + +.hsae-troubleshoot-textarea { + width: 100%; + min-height: 180px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9em; + line-height: 1.5; + box-sizing: border-box; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-group { + margin-bottom: 1.25em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-group-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75em; + margin-bottom: 0.65em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-group-title { + font-weight: 600; + font-size: 1rem; + line-height: 1.35; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-group-desc { + font-size: 0.875rem; + margin-top: 0.2em; + line-height: 1.45; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-queries-title-wrap { + display: flex; + align-items: center; + gap: 0.45em; + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + padding: 1em 1.1em; + background: rgba(255, 255, 255, 0.03); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.85em 1.25em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-basic-group-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75em; + margin-top: 0.85em; + margin-bottom: 0.85em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-basic-group-row + .hsae-group-new { + grid-column: 1 / -1; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-grid-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-row-sort { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-seasonal-dates { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +@media (max-width: 899px) { + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-grid-4, + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-row-sort { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-grid-2, + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-grid-4, + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-row-sort { + grid-template-columns: 1fr; + } +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-label { + font-size: 0.875rem; + margin-bottom: 0.35em; + line-height: 1.35; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-hint { + font-size: 0.8125rem; + margin-top: 0.35em; + line-height: 1.45; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-toggle-with-tooltip { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-enabled-row { + margin-top: 0.85em; + padding-top: 0.85em; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75em; +} + +@media (max-width: 640px) { + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-grid { + grid-template-columns: 1fr; + } +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.35em; + width: 100%; + text-align: left; + padding: 1em 1.1em; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + cursor: pointer; + color: inherit; + font-family: inherit; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-card:hover { + border-color: rgba(0, 164, 220, 0.5); + background: rgba(0, 164, 220, 0.06); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-source-card.hsae-active { + border-color: rgba(0, 164, 220, 0.6); + background: rgba(0, 164, 220, 0.1); + box-shadow: inset 3px 0 0 var(--theme-primary-color, #00a4dc); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-card-text { + flex: 1; + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-title { + display: flex; + align-items: center; + gap: 0.4em; + font-weight: 600; + font-size: 0.9375rem; + line-height: 1.35; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-icon { + font-size: 1.125rem; + opacity: 0.85; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-desc { + margin-top: 0.3em; + font-size: 0.8125rem; + line-height: 1.45; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.5em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-field-row + .hsae-field-hint { + margin-top: 0.15em; + margin-bottom: 0.4em; +} + +/* .hsae-chip base + active come from shared .kefin-chip */ + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-cache-presets { + display: flex; + flex-wrap: wrap; + gap: 0.5em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-cache-section + .hsae-ttl-custom { + margin-top: 0.85em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-cache-section + .hsae-ttl-custom + .hsae-field-wrap { + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-cache-section + .hsae-ttl-custom + select, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-cache-section + .hsae-ttl-custom + input { + width: 100%; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-cache-preset { + padding: 0.4em 0.75em; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.04); + font-size: 0.8125rem; + cursor: pointer; + font-family: inherit; + color: inherit; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-cache-preset.hsae-active { + border-color: rgba(0, 164, 220, 0.6); + background: rgba(0, 164, 220, 0.15); + color: var(--theme-primary-color, #00a4dc); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-queries-list { + display: flex; + flex-direction: column; + gap: 0.75em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-card { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + overflow: hidden; + background: rgba(255, 255, 255, 0.02); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75em; + padding: 0.75em 1em; + cursor: pointer; + list-style: none; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-query-summary::-webkit-details-marker { + display: none; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-title-wrap { + display: flex; + align-items: center; + gap: 0.5em; + min-width: 0; + flex: 1; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-name-input { + flex: 1; + min-width: 0; + background: transparent; + border: none; + border-bottom: 1px solid transparent; + border-radius: 0; + padding: 0.1em 0; + color: inherit; + font: inherit; + cursor: text; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-name-input:hover, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-query-name-input:focus { + border-bottom-color: rgba(255, 255, 255, 0.25); + outline: none; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-multi-query-sort { + margin-bottom: 0.85em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-multi-query-toggle-row { + margin-bottom: 0.85em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-multi-query-picker-label-row { + margin-bottom: 0.85em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-badge { + font-size: 0.75rem; + padding: 0.15em 0.5em; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + white-space: nowrap; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-body { + padding: 0 1em 1em; + display: flex; + flex-direction: column; + gap: 0.85em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-divider { + border-top: 1px solid rgba(255, 255, 255, 0.08); + margin: 0.15em 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-query-divider-before-include-types { + margin-bottom: 0.85em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-query-divider-before-parent { + margin-top: 1.1em; + margin-bottom: 1.1em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-query-divider-parent-filters { + margin-top: 1.1em; + margin-bottom: 1.1em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-query-divider-before-additional { + margin-top: 1.25em; + margin-bottom: 1.1em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-header-divider { + margin-top: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-delete { + flex-shrink: 0; + opacity: 0.7; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-add-query-btn { + border: 1px dashed rgba(255, 255, 255, 0.2); + background: transparent; + margin: 0.75em auto; + display: flex; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-add-query-btn:hover { + border-color: rgba(0, 164, 220, 0.5); + background: rgba(0, 164, 220, 0.06); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-details-summary { + display: flex; + align-items: center; + gap: 0.35em; + cursor: pointer; + margin-bottom: 0.5em; + list-style: none; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-details-summary::-webkit-details-marker { + display: none; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-additional-details[open] + .hsae-advanced-chevron { + transform: rotate(180deg); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-additional-details { + margin-top: 0.35em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-additional-options-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 0.75em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-additional-row { + display: flex; + align-items: center; + gap: 0.5em; + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-additional-row-full { + grid-column: 1 / -1; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-additional-row + > .kefin-toggle-card, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-additional-row + > .hsae-additional-field, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-additional-row + > .hsae-custom-option-grid { + flex: 1; + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-additional-row + > .hsae-additional-field + .fld { + width: 100%; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-remove-additional-option { + flex-shrink: 0; + background: none; + border: none; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + padding: 0.25em; + display: inline-flex; + align-items: center; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-custom-option-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5em; + flex: 1; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-info-link { + color: inherit; + opacity: 0.75; + display: inline-flex; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-info-link:hover { + opacity: 1; +} + +/* Reuse wizard custom-items visuals inside the advanced editor modal */ +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-items-stage, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-items-grid, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-tile, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-add-tile, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsse-custom-item-editor-overlay, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsse-custom-item-editor-panel { + /* inherit from create-flow rules via duplicated selectors below */ +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-items-grid { + display: flex; + flex-wrap: wrap; + gap: 1em; + justify-content: start; + padding: 0.5em 0 1em; + align-items: center; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-tile, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-add-tile { + width: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35em; +} + +.hsse-custom-items-grid > * { + max-width: 120px; +} +.listItemBodyText.hsse-custom-item-tile-label { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; +} +.hsse-custom-item-edit-badge { + position: absolute; + bottom: 0.2em; + right: 0.2em; +} +.hsse-custom-item-actions > * { + height: 25px; + width: 25px; + display: flex; + justify-content: center; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsse-custom-item-tile.is-hidden + .hsse-custom-item-tile-image, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsse-custom-item-tile.is-hidden + .hsse-custom-item-tile-label { + opacity: 0.4; + filter: grayscale(0.35); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsse-custom-item-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 0.2em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsse-custom-item-action, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsse-custom-item-trash { + font-size: 0; + padding: 0.3em; + min-height: 0; + min-width: 0; + line-height: 1; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsse-custom-item-action:disabled { + opacity: 0.35; + cursor: default; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsse-custom-item-action-icon, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsse-custom-item-trash-icon { + font-size: 1.1rem; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-tile-image, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-add-tile { + width: auto; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + padding: 0; + cursor: pointer; + position: relative; + overflow: hidden; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-add-tile { + display: flex; + align-items: center; + justify-content: center; + border-style: dashed; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsse-custom-item-editor-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 1em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsse-custom-item-editor-backdrop { + position: absolute; + inset: 0; + border: none; + background: rgba(0, 0, 0, 0.55); + cursor: pointer; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsse-custom-item-editor-panel { + position: relative; + z-index: 1; + width: min(720px, 100%); + max-height: 90vh; + overflow: auto; + padding: 2em; + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.hsse-custom-item-fields { + display: flex; + flex-direction: column; + gap: 1em; + margin-bottom: 1em; +} + +/* Layout tokens and advanced editor layout refinements */ +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-root { + --hsae-gap-field: 0.85em; + --hsae-gap-section: 1.25em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-wrap { + margin: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card { + display: flex; + flex-direction: column; + gap: var(--hsae-gap-field); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-advanced-body { + display: flex; + flex-direction: column; + gap: var(--hsae-gap-section); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-advanced-summary { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75em; + cursor: pointer; + margin-bottom: 0.65em; + list-style: none; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-advanced-summary-left { + display: flex; + align-items: center; + gap: 0.35em; + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-advanced-summary + .hsae-group-desc { + text-align: right; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-advanced-summary::-webkit-details-marker { + display: none; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-advanced-chevron { + transition: transform 0.2s ease; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-advanced-options[open] + .hsae-advanced-chevron, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-appearance-advanced[open] + .hsae-advanced-chevron { + transform: rotate(180deg); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-advanced { + margin-top: 0.35em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-appearance-advanced + > summary { + list-style: none; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-appearance-advanced-body { + display: flex; + flex-direction: column; + gap: 0.85em; + padding-top: 0.75em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-color-input-row, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-border-color-row { + display: flex; + align-items: center; + gap: 0.5em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-color-pickr-host, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-border-color-pickr-host { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: var(--hsae-swatch-color, #e8dab2); + cursor: pointer; + box-sizing: border-box; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-color-pickr-host + .pcr-button, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-border-color-pickr-host + .pcr-button { + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; +} + +.pcr-app { + z-index: 100050 !important; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-inline-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--hsae-gap-field) 1.25em; + align-items: end; +} + +/* Query row A: query type + endpoint/cache in two equal columns */ +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-query-row-a + > .hsae-custom-path { + grid-column: 1 / -1; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-row-f { + margin-top: 1.1em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-endpoint-inline { + display: flex; + gap: var(--hsae-gap-field); + align-items: flex-end; + flex-wrap: nowrap; + flex: 2 1 320px; + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-endpoint-inline + .hsae-field-wrap { + flex: 1; + min-width: 140px; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75em; +} + +@media (max-width: 899px) { + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-grid { + grid-template-columns: 1fr; + } +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-card { + text-align: left; + padding: 1.15em 1.25em; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + cursor: pointer; + font-family: inherit; + color: inherit; + display: grid; + align-content: start; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-card:hover { + border-color: rgba(0, 164, 220, 0.5); + background: rgba(0, 164, 220, 0.06); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-appearance-card.hsae-active { + border-color: rgba(0, 164, 220, 0.6); + background: rgba(0, 164, 220, 0.1); + box-shadow: inset 3px 0 0 var(--theme-primary-color, #00a4dc); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-title { + font-weight: 600; + font-size: 1rem; + line-height: 1.3; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-desc { + font-size: 0.875rem; + margin-top: 0.35em; + line-height: 1.5; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card-format-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.5em; +} + +@media (max-width: 640px) { + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card-format-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card-format-btn { + text-align: left; + padding: 0.75em; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + cursor: pointer; + font-size: 0.875rem; + font-family: inherit; + color: inherit; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card-format-btn:hover { + border-color: rgba(0, 164, 220, 0.5); + background: rgba(0, 164, 220, 0.06); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-card-format-btn.hsae-active { + border-color: rgba(0, 164, 220, 0.6); + background: rgba(0, 164, 220, 0.1); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-normal-row { + display: flex; + flex-direction: column; + gap: 1.25em; + align-items: stretch; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-appearance-live-preview-row { + margin-top: 0.25em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card-format-grid-2col { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-appearance-preview-container { + margin-top: 0; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 0.75em; + background: rgba(0, 0, 0, 0.15); + overflow: auto; + min-height: 200px; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-preview-placeholder { + text-align: center; + padding: 1.5em 0; + margin: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-control { + display: flex; + flex-direction: column; + gap: 0.65em; + padding: 0.85em 1em; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-text { + flex: 1; + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-label { + font-weight: 600; + font-size: 0.9375rem; + line-height: 1.35; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-desc { + margin-top: 0.25em; + font-size: 0.8125rem; + line-height: 1.45; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-actions { + display: flex; + gap: 0.35em; + flex-shrink: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.75); + cursor: pointer; + font-family: inherit; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .kt-autocomplete-icon-btn:hover { + border-color: rgba(0, 164, 220, 0.45); + color: rgba(255, 255, 255, 0.95); + background: rgba(0, 164, 220, 0.08); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .kt-autocomplete-icon-btn + .material-icons { + font-size: 1.125rem; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-field { + position: relative; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-input-row { + display: flex; + align-items: stretch; + gap: 0.5em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .kt-autocomplete-input-row + .autocomplete-input { + flex: 1; + min-width: 0; + margin: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-badges { + display: flex; + flex-wrap: wrap; + gap: 0.35em; + align-items: flex-start; + min-height: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-badge { + display: inline-flex; + align-items: center; + gap: 0.25em; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 6px; + padding: 0.2em 0.45em; + font-size: 0.875rem; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .kt-autocomplete-badge-remove { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + padding: 0; + margin: 0; + line-height: 1; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .kt-autocomplete-badge-remove + .material-icons { + font-size: 1rem; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .autocomplete-suggestions { + display: none; + position: absolute; + z-index: 10000; + top: calc(100% + 2px); + left: 0; + right: 0; + background: #1a1a1a; + color: rgba(255, 255, 255, 0.87); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + max-height: 200px; + overflow-y: auto; + box-sizing: border-box; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .autocomplete-suggestion { + padding: 0.5em 0.75em; + cursor: pointer; + border-left: 3px solid transparent; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .autocomplete-suggestion:hover, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .autocomplete-suggestion.autocomplete-suggestion-active { + background: rgba(255, 255, 255, 0.08); + border-left-color: var(--theme-primary-color, #00a4dc); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-basic-dropdowns + .hsae-field-wrap { + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-basic-dropdowns select, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-basic-dropdowns input { + width: 100%; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + #hsae-card-title-options-row + .hsae-field-wrap { + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + #hsae-card-title-options-row + select { + width: 100%; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-field-grid + .hsae-field-with-hint + select { + width: 100%; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-toggle-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-appearance-advanced-body + .hsae-toggle-card-grid + .kefin-toggle-card { + grid-column: 1 / -1; +} + +@media (max-width: 640px) { + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-toggle-card-grid { + grid-template-columns: 1fr; + } +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-additional-toolbar { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5em; + flex-wrap: wrap; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-additional-toolbar + .additional-option-select { + flex: 1; + min-width: 180px; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-subsection-label { + font-weight: 600; + font-size: 0.9375rem; + margin-top: 0.5em; + margin-bottom: 0.5em; + line-height: 1.35; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-spotlight-inner { + display: flex; + flex-direction: column; + gap: var(--hsae-gap-section); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-spotlight-row-1 + .hsae-field-wrap { + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-spotlight-row-1 + select { + width: 100%; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-spotlight-backdrops-row + .hsae-field-wrap { + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-spotlight-backdrops-row + .fld { + width: 100%; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-spotlight-animations { + display: grid; + gap: var(--hsae-gap-field) 1.25em; + grid-template-columns: minmax(0, 1fr); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-spotlight-animations--1 { + grid-template-columns: minmax(0, 1fr); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-spotlight-animations--2 { + grid-template-columns: minmax(0, 1fr); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-spotlight-animations--3 { + grid-template-columns: minmax(0, 1fr); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-spotlight-anim-group { + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-spotlight-anim-pair + .hsae-field-wrap { + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-spotlight-anim-pair + select { + width: 100%; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-spotlight-row-autoplay + > .kefin-toggle-card, +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-spotlight-row-autoplay + > .hsae-field-wrap { + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-spotlight-row-autoplay + .fld { + width: 100%; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-multi-query-settings { + margin-top: 0.25em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-multi-query-settings + .hsae-query-mode-grid { + margin-top: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-multi-query-sort + .hsae-field-wrap { + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-multi-query-sort + select { + width: 100%; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-mode-grid { + margin-top: 0.75em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-query-mode-card.hsae-active { + border-color: rgba(0, 164, 220, 0.6); + background: rgba(0, 164, 220, 0.1); + box-shadow: inset 3px 0 0 var(--theme-primary-color, #00a4dc); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75em; +} + +@media (max-width: 700px) { + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-grid { + grid-template-columns: 1fr; + } +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card { + display: flex; + flex-direction: column; + gap: 0.65em; + padding: 0.85em 1em; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + min-width: 0; + cursor: pointer; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card:hover { + border-color: rgba(0, 164, 220, 0.4); + background: rgba(0, 164, 220, 0.05); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-select-card.hsae-active { + border-color: rgba(0, 164, 220, 0.45); + background: rgba(0, 164, 220, 0.06); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-text { + flex: 1; + min-width: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-label { + display: flex; + align-items: center; + gap: 0.4em; + font-weight: 600; + font-size: 0.9375rem; + line-height: 1.35; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-icon { + font-size: 1.125rem; + opacity: 0.85; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-desc { + margin-top: 0.3em; + font-size: 0.8125rem; + line-height: 1.45; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-add-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.85); + cursor: pointer; + flex-shrink: 0; + font-family: inherit; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-add-btn:hover { + border-color: rgba(0, 164, 220, 0.5); + background: rgba(0, 164, 220, 0.1); + color: #fff; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-select-add-btn + .material-icons { + font-size: 1.25rem; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-chips { + display: flex; + flex-wrap: wrap; + gap: 0.35em; + min-height: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-chip { + display: inline-flex; + align-items: center; + gap: 0.25em; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 6px; + padding: 0.2em 0.45em; + font-size: 0.875rem; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + padding: 0; + margin: 0; + line-height: 1; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-select-chip-remove + .material-icons { + font-size: 1rem; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-include-types { + margin-bottom: 0.25em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-include-types-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-include-types-toggle { + flex-shrink: 0; + min-width: auto; + padding: 0.4em 0.75em; + font-size: 0.8125rem; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-include-type-chips { + margin-top: 0.5em; + overflow-y: auto; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-selection-popover, +[data-modal-id="kefin-selection-popover"] .hsse-selection-popover, +[data-modal-id="kefin-library-item-picker"] .hsse-selection-popover { + min-width: min(520px, 90vw); +} + +[data-modal-id="kefin-selection-popover"] .hsse-parent-list, +[data-modal-id="kefin-library-item-picker"] .hsse-parent-list { + max-height: min(50vh, 420px); + overflow-y: auto; +} + +[data-modal-id="kefin-library-item-picker"] .hsse-section-block { + margin-bottom: 0.75em; +} + +[data-modal-id="kefin-library-item-picker"] .hsse-section-block .hsae-field-hint { + margin-bottom: 0.5em; +} + +[data-modal-id="kefin-library-item-picker"] .hsse-chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.5em; +} + +/* .hsse-chip base + active come from shared .kefin-chip */ + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-row-c { + margin-top: 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-query-row-c + .hsae-select-card-grid { + margin-top: 0.75em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-query-row-c + .hsae-field-hint { + margin-top: 0.25em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-items-section { + margin-top: 0.25em; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-items-list { + display: flex; + flex-direction: column; + gap: 0.5em; + margin: 0.75em 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-items-empty { + margin: 0; + padding: 0.75em 0; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-item-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75em; + padding: 0.65em 0.85em; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-item-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-item-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-static-item-remove:hover { + background: rgba(255, 80, 80, 0.15); + color: #ff8a8a; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-static-item-remove + .material-icons { + font-size: 1.125rem; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-item-add-btn { + display: inline-flex; + align-items: center; + gap: 0.4em; + padding: 0.5em 0.85em; + border: 1px dashed rgba(255, 255, 255, 0.2); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + color: inherit; + cursor: pointer; + font-family: inherit; +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-static-item-add-btn:hover { + border-color: rgba(0, 164, 220, 0.45); + background: rgba(0, 164, 220, 0.08); + color: var(--theme-primary-color, #00a4dc); +} + +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-endpoint-wrap + .fld[disabled] { + opacity: 0.75; + cursor: not-allowed; +} + +.hsse-selection-search { + margin-bottom: 1em; +} + +.hsse-custom-item-tile-image.hsse-image-preview.hsse-item-tile-preview img, +.hsse-image-preview.hsse-item-main-preview img { + width: 100%; +} + +/* Profile-driven field visibility — data-hsae-profiles must match EDITOR_PROFILES in homeScreenEditorProfiles.js */ +.hsae-root[data-editor-profile] [data-hsae-profiles] { + display: none !important; +} + +.hsae-root[data-editor-profile="full"] [data-hsae-profiles~="full"], +.hsae-root[data-editor-profile="minimal"] [data-hsae-profiles~="minimal"], +.hsae-root[data-editor-profile="postProcessing"] [data-hsae-profiles~="postProcessing"], +.hsae-root[data-editor-profile="normal"] [data-hsae-profiles~="normal"], +.hsae-root[data-editor-profile="dateCutoffs"] [data-hsae-profiles~="dateCutoffs"] { + display: revert !important; +} + +.hsae-root[data-editor-profile] .hsae-field-grid[data-hsae-profiles], +.hsae-root[data-editor-profile] .hsae-basic-group-row[data-hsae-profiles], +.hsae-root[data-editor-profile] .hsae-advanced-section-sort[data-hsae-profiles], +.hsae-root[data-editor-profile] .hsae-advanced-min-max-age[data-hsae-profiles], +.hsae-root[data-editor-profile] .hsae-advanced-limits[data-hsae-profiles] { + display: none !important; +} + +.hsae-root[data-editor-profile="full"] .hsae-field-grid[data-hsae-profiles~="full"], +.hsae-root[data-editor-profile="minimal"] .hsae-field-grid[data-hsae-profiles~="minimal"], +.hsae-root[data-editor-profile="postProcessing"] .hsae-field-grid[data-hsae-profiles~="postProcessing"], +.hsae-root[data-editor-profile="normal"] .hsae-field-grid[data-hsae-profiles~="normal"], +.hsae-root[data-editor-profile="dateCutoffs"] .hsae-field-grid[data-hsae-profiles~="dateCutoffs"], +.hsae-root[data-editor-profile="full"] .hsae-basic-group-row[data-hsae-profiles~="full"], +.hsae-root[data-editor-profile="full"] .hsae-advanced-section-sort[data-hsae-profiles~="full"], +.hsae-root[data-editor-profile="postProcessing"] .hsae-advanced-section-sort[data-hsae-profiles~="postProcessing"], +.hsae-root[data-editor-profile="normal"] .hsae-advanced-section-sort[data-hsae-profiles~="normal"], +.hsae-root[data-editor-profile="dateCutoffs"] .hsae-advanced-section-sort[data-hsae-profiles~="dateCutoffs"], +.hsae-root[data-editor-profile="full"] .hsae-advanced-min-max-age[data-hsae-profiles~="full"], +.hsae-root[data-editor-profile="dateCutoffs"] .hsae-advanced-min-max-age[data-hsae-profiles~="dateCutoffs"], +.hsae-root[data-editor-profile="full"] .hsae-advanced-limits[data-hsae-profiles~="full"], +.hsae-root[data-editor-profile="postProcessing"] .hsae-advanced-limits[data-hsae-profiles~="postProcessing"], +.hsae-root[data-editor-profile="normal"] .hsae-advanced-limits[data-hsae-profiles~="normal"], +.hsae-root[data-editor-profile="dateCutoffs"] .hsae-advanced-limits[data-hsae-profiles~="dateCutoffs"] { + display: grid !important; +} + +.hsae-root[data-editor-profile] .hsae-toggle-card-grid > [data-hsae-profiles] { + display: none !important; +} + +.hsae-root[data-editor-profile="full"] .hsae-toggle-card-grid > [data-hsae-profiles~="full"], +.hsae-root[data-editor-profile="normal"] .hsae-toggle-card-grid > [data-hsae-profiles~="normal"], +.hsae-root[data-editor-profile="dateCutoffs"] .hsae-toggle-card-grid > [data-hsae-profiles~="dateCutoffs"], +.hsae-root[data-editor-profile="postProcessing"] .hsae-toggle-card-grid > [data-hsae-profiles~="postProcessing"] { + display: grid !important; +} + +/* Item source — show only the panel matching the active source */ +.hsae-root[data-item-source="jellyfin"] .hsae-custom-items-group, +.hsae-root[data-item-source="jellyfin"] .hsae-external-list-group { + display: none !important; +} + +.hsae-root[data-item-source="custom"] .hsae-queries-group, +.hsae-root[data-item-source="custom"] .hsae-external-list-group { + display: none !important; +} + +.hsae-root[data-item-source="external"] .hsae-queries-group, +.hsae-root[data-item-source="external"] .hsae-custom-items-group { + display: none !important; +} + +.hsae-root[data-editor-profile="full"] .hsae-add-query-btn[data-hsae-profiles~="full"] { + display: flex !important; + margin-left: auto !important; + margin-right: auto !important; +} + +.hsae-root[data-editor-profile] .hsae-query-row-sort .hsae-query-sort-by, +.hsae-root[data-editor-profile] .hsae-query-row-sort .hsae-query-sort-order, +.hsae-root[data-editor-profile] .hsae-query-row-sort .hsae-query-limit { + display: grid !important; +} + +.hsae-root[data-editor-profile="minimal"] .hsae-query-row-sort .hsae-query-sort-by, +.hsae-root[data-editor-profile="minimal"] .hsae-query-row-sort .hsae-query-sort-order, +.hsae-root[data-editor-profile="postProcessing"] .hsae-query-row-sort .hsae-query-sort-by, +.hsae-root[data-editor-profile="postProcessing"] .hsae-query-row-sort .hsae-query-sort-order { + pointer-events: none; + opacity: 0.5; +} + +.hsae-root[data-editor-profile="minimal"] .hsae-query-row-sort, +.hsae-root[data-editor-profile="postProcessing"] .hsae-query-row-sort, +.hsae-root[data-editor-profile="dateCutoffs"] .hsae-query-row-sort, +.hsae-root[data-editor-profile="normal"] .hsae-query-row-sort { + display: flex; + flex-wrap: wrap; +} + +.hsae-root[data-editor-profile="minimal"] .hsae-query-row-sort > *, +.hsae-root[data-editor-profile="postProcessing"] .hsae-query-row-sort > *, +.hsae-root[data-editor-profile="dateCutoffs"] .hsae-query-row-sort > *, +.hsae-root[data-editor-profile="normal"] .hsae-query-row-sort > * { + flex: 1; +} + +/* Full-width only when Flatten Series is hidden for the profile (solo toggle in the row). */ +.hsae-root[data-editor-profile="minimal"] .hsae-advanced-limit-before-sort, +.hsae-root[data-editor-profile="postProcessing"] .hsae-advanced-limit-before-sort { + grid-column: -1 / 1; +} + +.layout-mobile .hsae-appearance-preview-col { + display: none !important; +} + +.hsse-image-preview img { + height: 120px; + max-width: 120px; + width: auto !important; +} \ No newline at end of file diff --git a/configuration/homeScreenAdvancedEditor.js b/configuration/homeScreenAdvancedEditor.js new file mode 100644 index 0000000..b167aa3 --- /dev/null +++ b/configuration/homeScreenAdvancedEditor.js @@ -0,0 +1,4926 @@ +// KefinTweaks Home Screen Advanced Section Editor (custom sections) + +(function() { + 'use strict'; + + const SECTION_VISIBILITY_OPTIONS = [ + { value: 'normal', label: 'Normal' }, + { value: 'seasonal', label: 'Seasonal' }, + { value: 'discovery', label: 'Discovery' } + ]; + + + const ENDPOINT_OPTIONS = [ + { value: 'Items', label: 'Items', path: '' }, + { value: 'Genres', label: 'Genres', path: '/Genres' }, + { value: 'Persons', label: 'Persons', path: '/Persons' }, + { value: 'Studios', label: 'Studios', path: '/Studios' }, + { value: 'Next Up', label: 'Next Up', path: '/Shows/NextUp' }, + { value: 'Latest', label: 'Latest', path: '/Items/Latest' }, + { value: 'Custom', label: 'Custom', path: null } + ]; + + const PEOPLE_CACHE_ITEM_TYPE_VALUES = [ + { value: '', label: 'All' }, + { value: 'Movie', label: 'Movie' }, + { value: 'Series', label: 'Series' }, + { value: 'Episode', label: 'Episode' } + ]; + + const PEOPLE_CACHE_ARGS = [ + { + key: 'ItemType', + type: 'select', + label: 'Item Type', + possibleValues: PEOPLE_CACHE_ITEM_TYPE_VALUES + }, + { key: 'MinCount', type: 'number', label: 'Min Count' } + ]; + + const CACHE_SOURCE_OPTIONS = [ + { value: 'MoviesCache.getImdbTop250Movies', label: 'IMDb Top 250 Movies' }, + { + value: 'StudiosCache.getPopularTVNetworks', + label: 'Popular TV Networks', + args: [{ key: 'MinCount', type: 'number', label: 'Min Count' }] + }, + { value: 'PeopleCache.getTopActors', label: 'Top Actors', args: PEOPLE_CACHE_ARGS }, + { value: 'PeopleCache.getTopDirectors', label: 'Top Directors', args: PEOPLE_CACHE_ARGS }, + { value: 'PeopleCache.getTopWriters', label: 'Top Writers', args: PEOPLE_CACHE_ARGS } + ]; + + const DEFAULT_SECTION_QUERY_LIMIT = window.KefinHomeScreenEditorConstants?.DEFAULT_SECTION_QUERY_LIMIT ?? 16; + const LIMIT_ZERO_MODAL_ID = 'kefin-hsae-limit-zero'; + const LIMIT_ZERO_MESSAGE = 'Setting the limit to 0 can result in queries requesting a lot of items from the server and may result in slow response times. This is fine to use if you know there are only a few hundred results at maximum. If you expect thousands of results, this is probably not a good idea unless you use a fairly long cache time.'; + + /** Live editor state for collectData (which only receives a dialog element). */ + let activeEditorState = null; + + function getLiveEditorState(dialog) { + return dialog?._hsaeState + || dialog?.closest?.('.dialogContainer')?._hsaeState + || activeEditorState + || null; + } + + function createDefaultQuery() { + return { queryOptions: { Limit: DEFAULT_SECTION_QUERY_LIMIT } }; + } + + /** + * Confirm Limit = 0. Cancel / dismiss restores previous; OK keeps 0. + * @param {{ onConfirm?: () => void, onCancel?: () => void }} opts + */ + function openLimitZeroConfirm({ onConfirm, onCancel } = {}) { + let settled = false; + const finish = (ok) => { + if (settled) return; + settled = true; + if (ok) onConfirm?.(); + else onCancel?.(); + }; + + if (!window.ModalSystem?.create) { + if (window.confirm(LIMIT_ZERO_MESSAGE)) finish(true); + else finish(false); + return; + } + + if (window.ModalSystem.isOpen?.(LIMIT_ZERO_MODAL_ID)) { + window.ModalSystem.close(LIMIT_ZERO_MODAL_ID); + } + + const modalId = LIMIT_ZERO_MODAL_ID; + const content = `

${LIMIT_ZERO_MESSAGE}

`; + const footer = ` + + + `; + window.ModalSystem.create({ + id: modalId, + title: 'Limit of 0', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + onClose: () => finish(false), + onOpen: (modal) => { + modal.dialogFooter?.querySelector('#kefin-hsae-limit-zero-confirm')?.addEventListener('click', () => { + finish(true); + window.ModalSystem.close(modalId); + }); + modal.dialogFooter?.querySelector('#kefin-hsae-limit-zero-cancel')?.addEventListener('click', () => { + finish(false); + window.ModalSystem.close(modalId); + }); + } + }); + } + + function getLimitPrevValue(input) { + return input?.dataset?.prevLimit ?? ''; + } + + function setLimitPrevValue(input, value) { + if (!input) return; + input.dataset.prevLimit = value == null ? '' : String(value); + } + + /** + * Enforce non-negative Limit; confirm before accepting 0. + * @param {HTMLInputElement} input + */ + function handleLimitFieldChange(input) { + if (!input || input.dataset.limitConfirmOpen === 'true') return; + + const raw = String(input.value ?? '').trim(); + if (raw === '') { + setLimitPrevValue(input, ''); + delete input.dataset.limitZeroAccepted; + return; + } + + // Incomplete / invalid intermediate values (e.g. "-", "e") + if (raw === '-' || raw === '+' || raw === '.' || Number.isNaN(Number(raw))) { + input.value = getLimitPrevValue(input); + return; + } + + const n = parseInt(raw, 10); + if (!Number.isFinite(n)) { + input.value = getLimitPrevValue(input); + return; + } + + if (n < 0) { + input.value = getLimitPrevValue(input); + return; + } + + if (n === 0) { + const prev = getLimitPrevValue(input); + const prevN = prev === '' ? null : parseInt(prev, 10); + if (prevN === 0 || input.dataset.limitZeroAccepted === 'true') { + input.value = '0'; + setLimitPrevValue(input, '0'); + input.dataset.limitZeroAccepted = 'true'; + return; + } + + // Revert until the user confirms zero + input.value = prev; + input.dataset.limitConfirmOpen = 'true'; + openLimitZeroConfirm({ + onConfirm: () => { + input.value = '0'; + setLimitPrevValue(input, '0'); + input.dataset.limitZeroAccepted = 'true'; + delete input.dataset.limitConfirmOpen; + }, + onCancel: () => { + input.value = prev; + delete input.dataset.limitZeroAccepted; + delete input.dataset.limitConfirmOpen; + } + }); + return; + } + + input.value = String(n); + setLimitPrevValue(input, String(n)); + delete input.dataset.limitZeroAccepted; + } + + /** + * Wire min/step and Limit validation for one query card. + * @param {Element} editor + * @param {number} queryIndex + */ + function attachLimitFieldSafeguard(editor, queryIndex) { + const limitInput = editor?.querySelector?.(`#query-${queryIndex}-Limit`); + if (!limitInput || limitInput.dataset.limitSafeguardAttached === 'true') return; + + limitInput.dataset.limitSafeguardAttached = 'true'; + limitInput.min = '0'; + limitInput.step = '1'; + setLimitPrevValue(limitInput, limitInput.value ?? ''); + if (limitInput.value === '0' || parseInt(limitInput.value, 10) === 0) { + limitInput.dataset.limitZeroAccepted = 'true'; + } + + limitInput.addEventListener('input', () => handleLimitFieldChange(limitInput)); + limitInput.addEventListener('change', () => handleLimitFieldChange(limitInput)); + limitInput.addEventListener('blur', () => handleLimitFieldChange(limitInput)); + } + + const FILTER_CHIP_OPTIONS = [ + { value: 'IsUnplayed', label: 'Unplayed' }, + { value: 'IsPlayed', label: 'Played' }, + { value: 'IsFavorite', label: 'Favorite' }, + { value: 'IsResumable', label: 'Resumable' }, + { value: 'Likes', label: 'Watchlist' }, + { value: 'Dislikes', label: 'Not on Watchlist' }, + { value: 'IsFavoriteOrLikes', label: 'Favorite or Watchlist' }, + { value: 'IsFolder', label: 'Folder' }, + { value: 'IsNotFolder', label: 'Not a Folder' } + ]; + + const PARENT_SELECTION_CARD_TYPES = [ + { + type: 'Collection', + label: 'Collection', + description: 'Show items from a specific Collection in your library.', + icon: 'collections_bookmark' + }, + { + type: 'Playlist', + label: 'Playlist', + description: 'Show items from a specific Playlist in your library.', + icon: 'playlist_play' + }, + { + type: 'Library', + label: 'Library', + description: 'Show items from a specific Jellyfin Library.', + icon: 'video_library' + }, + { + type: 'Custom', + label: 'Custom', + description: 'Show items from any Folder in your library.', + icon: 'folder_open' + } + ]; + + const FACET_SELECTION_CARD_TYPES = [ + { + type: 'Genre', + label: 'Genres', + description: 'Show items that share a Genre, like Action or Comedy.', + icon: 'theater_comedy', + queryField: 'GenreIds' + }, + { + type: 'Tag', + label: 'Tags', + description: 'Show items that share a Tag, like Time Travel or Revenge.', + icon: 'sell', + queryField: 'Tags' + }, + { + type: 'Person', + label: 'Persons', + description: 'Show items that a specific Person has acted in or has directed, written, produced, etc.', + icon: 'person', + queryField: 'PersonIds' + }, + { + type: 'Studio', + label: 'Studios', + description: 'Show items from a specific Film Studio or TV Network.', + icon: 'apartment', + queryField: 'StudioIds' + } + ]; + + const WIZARD_ITEM_TYPE_OPTIONS = ['Movie', 'Series', 'Episode', 'Season', 'MusicAlbum', 'Audio', 'MusicVideo', 'Book']; + + const FALLBACK_ALL_ITEM_TYPES = [ + 'AggregateFolder', 'Audio', 'AudioBook', 'BasePluginFolder', 'Book', 'BoxSet', 'Channel', + 'ChannelFolderItem', 'CollectionFolder', 'Episode', 'Folder', 'Genre', 'ManualPlaylistsFolder', + 'Movie', 'LiveTvChannel', 'LiveTvProgram', 'MusicAlbum', 'MusicArtist', 'MusicGenre', 'MusicVideo', + 'Person', 'Photo', 'PhotoAlbum', 'Playlist', 'PlaylistsFolder', 'Program', 'Recording', 'Season', + 'Series', 'Studio', 'Trailer', 'TvChannel', 'TvProgram', 'UserRootFolder', 'UserView', 'Video', 'Year' + ]; + + const BORDER_STYLE_OPTIONS = [ + { value: '', label: 'Default' }, + { value: 'stairstep', label: 'Single' }, + { value: 'double', label: 'Double' }, + { value: 'triple', label: 'Triple' }, + { value: 'corner', label: 'Corner' }, + { value: 'square', label: 'Square' }, + { value: 'diamond', label: 'Diamond' }, + { value: 'retro-poster', label: 'Retro Poster' }, + { value: 'modern-poster', label: 'Modern Poster' }, + { value: 'picture-frame', label: 'Picture Frame' }, + { value: 'art-gallery', label: 'Art Gallery' }, + { value: 'vintage', label: 'Vintage' }, + { value: 'wood', label: 'Wood' }, + { value: 'film-reel', label: 'Film Reel' }, + { value: 'profile', label: 'Profile' } + ]; + + const CARD_TITLE_CAPITALIZATION_OPTIONS = [ + { value: 'normal', label: 'Normal' }, + { value: 'uppercase', label: 'Uppercase' }, + { value: 'lowercase', label: 'Lowercase' }, + { value: 'capitalize', label: 'Capitalize' } + ]; + + const CARD_TITLE_POSITION_OPTIONS = [ + { value: 'default', label: 'Default' }, + { value: 'left', label: 'Left' }, + { value: 'center', label: 'Center' }, + { value: 'right', label: 'Right' }, + { value: 'overlay-top-left', label: 'Overlay Top Left' }, + { value: 'overlay-top-center', label: 'Overlay Top Center' }, + { value: 'overlay-top-right', label: 'Overlay Top Right' }, + { value: 'overlay-center-left', label: 'Overlay Center Left' }, + { value: 'overlay-center', label: 'Overlay Center' }, + { value: 'overlay-center-right', label: 'Overlay Center Right' }, + { value: 'overlay-bottom-left', label: 'Overlay Bottom Left' }, + { value: 'overlay-bottom-center', label: 'Overlay Bottom Center' }, + { value: 'overlay-bottom-right', label: 'Overlay Bottom Right' } + ]; + + const CARD_TITLE_FONT_FAMILY_OPTIONS = [ + { value: 'default', label: 'Default' }, + { value: 'alfa-slab-one', label: 'Alfa Slab One' }, + { value: 'anton', label: 'Anton' }, + { value: 'changa-one', label: 'Changa One' }, + { value: 'exo', label: 'Exo' }, + { value: 'orbitron', label: 'Orbitron' }, + { value: 'oswald', label: 'Oswald' }, + { value: 'prata', label: 'Prata' }, + { value: 'roboto', label: 'Roboto' }, + { value: 'unbounded', label: 'Unbounded' }, + { value: 'vt323', label: 'VT323' }, + { value: 'verdana', label: 'Verdana' }, + { value: 'courier-new', label: 'Courier New' } + ]; + + const CARD_TITLE_FONT_SIZE_OPTIONS = [ + { value: 'normal', label: 'Normal' }, + { value: 'small', label: 'Small' }, + { value: 'large', label: 'Large' } + ]; + + const ITEMS_LAYOUT_OPTIONS = [ + { value: 'row', label: 'Row' }, + { value: 'grid', label: 'Grid' } + ]; + + const CARD_GAPS_OPTIONS = [ + { value: '', label: 'Default' }, + { value: 'none', label: 'No Gap' } + ]; + + const TTL_UNIT_OPTIONS = [ + { value: 'seconds', ms: 1000 }, + { value: 'minutes', ms: 60 * 1000 }, + { value: 'hours', ms: 60 * 60 * 1000 }, + { value: 'days', ms: 24 * 60 * 60 * 1000 } + ]; + + const CUSTOM_SOURCE_DESC = 'Show items with custom images, links and names.'; + + const HIDE_WATCHED_TOGGLE_DESCRIPTIONS = { + on: 'This section will not show watched items.', + off: 'This section may include watched items.' + }; + + const HIDE_NAME_TOGGLE_DESCRIPTIONS = { + on: 'The section name will not be visible.', + off: 'The section name will be visible.' + }; + + const USER_TOGGLE_TOOLTIPS = { + userEnabledByDefault: { + on: 'Users will see this section enabled in their home settings.', + off: 'Users will see this section disabled in their home settings.' + }, + userConfigurable: { + on: 'Users can enable or disable this section in their home settings.', + off: 'Users cannot configure, and will only see this section if the Default Status is enabled.' + } + }; + + const RENDER_MODE_CARDS = [ + { value: 'Normal', title: 'Normal', desc: 'Matches the default Jellyfin horizontal row of cards.' }, + { value: 'Spotlight', title: 'Spotlight', desc: 'A customizable, full-width, featured slideshow carousel.' }, + { value: 'Random', title: 'Random', desc: 'A random layout will be used when the section is loaded.' } + ]; + + const SPOTLIGHT_TOGGLE_DESCRIPTIONS = { + 'section-spotlight-panAnimation': 'Apply pan and zoom animations to each slide.', + 'section-spotlight-showNavButtons': 'Show previous and next navigation buttons on the carousel.', + 'section-spotlight-showClearArt': 'Display clear art logo overlay when available.', + 'section-spotlight-cycleBackdrops': 'Cycle through multiple backdrop images per slide.', + 'section-spotlight-autoPlay': 'Automatically advance slides after the timer interval.' + }; + + const ADVANCED_TOGGLE_DESCRIPTIONS = { + 'section-flattenSeries': { + on: 'Multiple episodes from the same series will be grouped into one item in the section.', + off: 'Episodes from the same series appear as separate items in the section.' + }, + 'hsae-limit-before-sort': { + on: 'When enabled, the item limit is applied before section-level sort.', + off: 'The item limit is applied after section-level sort.' + }, + 'hsae-hide-card-titles': { + on: 'Card titles for individual items in the section will not be shown.', + off: 'Card titles for individual items in the section will be shown.' + }, + 'hsae-use-parent-card': { + on: 'Items in this section will link to their Parent item where available. This means that an Episode or Season will link to the Series.', + off: 'Items in this section will link to the normal item.' + } + }; + + const RANDOM_QUERY_TOGGLE_DESCRIPTIONS = { + on: 'Randomly picks a query from the defined queries instead of merging all query results. If Query Picker is enabled, the section will initially show a random query from the defined queries.', + off: 'Merge results from all defined queries while respecting the sorting criteria specified in the Advanced Options section below.' + }; + + const QUERY_PICKER_TOGGLE_DESCRIPTIONS = { + on: 'Show a control to pick which query the section displays from the defined queries.', + off: 'No control to pick which query the section displays from the defined queries.' + }; + + const USE_QUERY_NAMES_TOGGLE_DESCRIPTIONS = { + on: 'Shows the name of the query in place of the section name.', + off: 'Does not use the name of the query in place of the section name.' + }; + + function humanizeSortOrderLabel(value) { + if (value == null || value === '') return ''; + return String(value) + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2'); + } + + function toSortBySelectOptions(sortOrders) { + return (sortOrders || []).map(value => ({ + value, + label: humanizeSortOrderLabel(value) + })); + } + + function getSectionSortOrderDirectionOptions(directions) { + return [ + { value: '', label: 'Default' }, + ...(directions || []).map((value) => ({ value, label: value })) + ]; + } + + function resolveEditorProfileKey(section, context = {}) { + if (context.editorProfile) return context.editorProfile; + return window.KefinHomeScreenEditorProfiles?.resolveEditorProfile(section, context) || 'full'; + } + + function isDiscoverySection(section) { + return !!(section?.discoveryType || section?.discoveryEnabled === true); + } + + function isProfileFieldActive(root, el) { + if (!el) return false; + const gated = el.closest('[data-hsae-profiles]'); + if (!gated) return true; + const profile = root?.dataset?.editorProfile || 'full'; + const allowed = (gated.dataset.hsaeProfiles || '').split(/\s+/).filter(Boolean); + return allowed.includes(profile); + } + + function applySectionLevelSort(section, sortBy, sortOrder) { + const by = sortBy == null ? '' : String(sortBy).trim(); + const order = sortOrder == null ? '' : String(sortOrder).trim(); + + if (!by || by === 'Default') delete section.sortBy; + else section.sortBy = by; + + if (!order || order === 'Default') delete section.sortOrder; + else section.sortOrder = order; + } + + function resolvePremiereAgeFields(section) { + const query = section?.queries?.[0] || {}; + const minAge = query.minAge !== undefined ? query.minAge : ''; + const maxAge = query.maxAge !== undefined ? query.maxAge : ''; + return { minAge, maxAge }; + } + + function applyDiscoveryAdvancedFields(section, dialog, root) { + const itemLimitEl = dialog.querySelector('#hsae-section-itemLimit'); + if (isProfileFieldActive(root, itemLimitEl)) { + const itemLimit = itemLimitEl?.value; + if (itemLimit !== '' && itemLimit != null) { + section.itemLimit = parseInt(itemLimit, 10); + } else { + delete section.itemLimit; + } + } + + const sortByEl = dialog.querySelector('#hsae-advanced-sortBy'); + if (isProfileFieldActive(root, sortByEl)) { + const sortVal = sortByEl?.value; + delete section.sortBy; + if (sortVal && sortVal !== 'Default') section.sortOrder = sortVal; + else delete section.sortOrder; + } + + const sortDirEl = dialog.querySelector('#hsae-advanced-sortOrder'); + if (isProfileFieldActive(root, sortDirEl)) { + const dirVal = sortDirEl?.value; + if (dirVal && dirVal !== 'Default') section.sortOrderDirection = dirVal; + else delete section.sortOrderDirection; + } + } + + function collectAdvancedOptionsFields(dialog, section, root) { + const ttlPreset = dialog.querySelector('.hsae-cache-preset.hsae-active')?.dataset.presetKey || 'DEFAULT'; + const ttlState = { + ttlPreset, + ttlCustomValue: dialog.querySelector('#hsae-ttl-custom-value')?.value, + ttlCustomUnit: dialog.querySelector('#hsae-ttl-custom-unit')?.value + }; + const cacheSection = dialog.querySelector('.hsae-advanced-cache'); + if (isProfileFieldActive(root, cacheSection)) { + const ttlMs = resolveTtlMs(ttlState); + if (ttlMs !== undefined) section.ttl = ttlMs; + else section.ttl = ''; + } + + const sortByEl = dialog.querySelector('#hsae-advanced-sortBy'); + const sortOrderEl = dialog.querySelector('#hsae-advanced-sortOrder'); + if (isProfileFieldActive(root, sortByEl) && !isDiscoverySection(section)) { + if (section.useRandomQuery) { + delete section.sortBy; + delete section.sortOrder; + } else { + applySectionLevelSort(section, sortByEl?.value, sortOrderEl?.value); + } + } + + if (isDiscoverySection(section)) { + applyDiscoveryAdvancedFields(section, dialog, root); + } + + const minAgeEl = dialog.querySelector('#hsae-query-minAge'); + const maxAgeEl = dialog.querySelector('#hsae-query-maxAge'); + if (isProfileFieldActive(root, minAgeEl) && section.queries?.length) { + const minAgeRaw = minAgeEl?.value?.trim(); + const maxAgeRaw = maxAgeEl?.value?.trim(); + if (minAgeRaw !== '') { + const minAge = parseInt(minAgeRaw, 10); + if (!Number.isNaN(minAge)) section.queries[0].minAge = minAge; + } else { + delete section.queries[0].minAge; + } + if (maxAgeRaw !== '') { + const maxAge = parseInt(maxAgeRaw, 10); + if (!Number.isNaN(maxAge)) section.queries[0].maxAge = maxAge; + } else { + delete section.queries[0].maxAge; + } + } + + const minimumItemsEl = dialog.querySelector('#section-minimumItems'); + if (isProfileFieldActive(root, minimumItemsEl)) { + const minimumItems = minimumItemsEl?.value; + if (minimumItems) section.minimumItems = parseInt(minimumItems, 10); + else section.minimumItems = ''; + } + + const itemLimitEl = dialog.querySelector('#hsae-section-itemLimit'); + if (isProfileFieldActive(root, itemLimitEl) && !isDiscoverySection(section)) { + const itemLimit = itemLimitEl?.value; + if (itemLimit !== '' && itemLimit != null) { + section.itemLimit = parseInt(itemLimit, 10); + } else { + delete section.itemLimit; + } + } + + const flattenEl = dialog.querySelector('#section-flattenSeries'); + if (isProfileFieldActive(root, flattenEl)) { + if (flattenEl?.checked === true) section.flattenSeries = true; + else delete section.flattenSeries; + } + + const limitBeforeSortEl = dialog.querySelector('#hsae-limit-before-sort'); + if (isProfileFieldActive(root, limitBeforeSortEl)) { + if (limitBeforeSortEl?.checked === true) section.limitBeforeSort = true; + else delete section.limitBeforeSort; + } + } + + function escapeHtml(str) { + if (str == null) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function getApi() { + const ui = window.KefinTweaksUI; + const se = window.KefinHomeScreenSectionEditor; + const constants = window.KefinHomeScreenEditorConstants; + + if (!ui?.buildSelect) { + throw new Error('KefinTweaksUI form builders not loaded'); + } + if (!constants) { + throw new Error('KefinHomeScreenEditorConstants not loaded'); + } + + return { + ...constants, + buildSelect: ui.buildSelect, + buildTextInput: ui.buildTextInput, + buildToggleCard: ui.buildToggleCard, + buildToggleSlider: ui.buildToggleSlider, + buildCheckbox: ui.buildCheckbox, + buildEnabledToggleButtons: ui.buildEnabledToggleButtons, + updateToggleSwitchUI: ui.updateToggleSwitchUI, + updateToggleSliderUI: ui.updateToggleSliderUI, + collectSpotlightConfigFromDialog: se?.collectSpotlightConfigFromDialog, + attachSpotlightSettingsListeners: se?.attachSpotlightSettingsListeners, + setupAdditionalOptionsListeners: se?.setupAdditionalOptionsListeners, + openSelectionPopover: se?.openSelectionPopover, + openLibraryItemPickerPopover: se?.openLibraryItemPickerPopover, + fetchSelectionItems: se?.fetchSelectionItems, + resolveSelectionItemLabels: se?.resolveSelectionItemLabels, + getSelectionTypeMeta: se?.getSelectionTypeMeta, + ALL_ITEM_TYPES: se?.ALL_ITEM_TYPES || FALLBACK_ALL_ITEM_TYPES, + PARENT_SELECTION_TYPES: se?.PARENT_SELECTION_TYPES, + FACET_SELECTION_TYPES: se?.FACET_SELECTION_TYPES + }; + } + + function getCustomItemsEditor() { + return window.KefinHomeScreenCustomItemsEditor; + } + + /** User-created / custom-group sections may delete items; default Kefin sections may only hide. */ + function shouldAllowCustomItemRemove(state) { + const id = state?.section?.id || ''; + if (state?.context?.isCustomSection === true) return true; + if (state?.section?.isCustom === true) return true; + if (id.startsWith('custom-')) return true; + return false; + } + + function getAdvancedCustomItemsOptions(state) { + const customEditor = getCustomItemsEditor(); + if (!customEditor?.getCustomItemsOptions) return {}; + return customEditor.getCustomItemsOptions({ + allowRemove: shouldAllowCustomItemRemove(state) + }); + } + + function getCachePresets() { + const cache = window.KefinHomeConfig2?.CACHE || window.KefinHomeConfig2?.CACHE_CONFIG || {}; + return [ + { key: 'FORCE_REFRESH', label: 'No Cache', ms: cache.FORCE_REFRESH_TTL ?? 0 }, + { key: 'VERY_SHORT', label: 'Very Short (1 min)', ms: cache.VERY_SHORT_TTL ?? 60000 }, + { key: 'SHORT', label: 'Short (5 min)', ms: cache.SHORT_TTL ?? 300000 }, + { key: 'DEFAULT', label: 'Default (1 hour)', ms: cache.DEFAULT_TTL ?? 3600000 }, + { key: 'LONG', label: 'Long (24 hours)', ms: cache.LONG_TTL ?? 86400000 }, + { key: 'STATIC', label: 'Static (1 week)', ms: cache.STATIC_TTL ?? 604800000 }, + { key: 'DISCOVERY', label: 'Discovery (6 hours)', ms: cache.DISCOVERY_TTL ?? 21600000 }, + { key: 'CUSTOM', label: 'Custom', ms: null } + ]; + } + + function inferItemSource(section) { + if (Array.isArray(section?.externalListUrls) && section.externalListUrls.length > 0) { + return 'external'; + } + if (section?.items?.length > 0 && (!section.queries?.length || section.queries.length === 0)) { + return 'custom'; + } + return 'jellyfin'; + } + + function sectionToVisibility(section) { + if (section?.discoveryType || section?.discoveryEnabled === true) return 'discovery'; + if (section?.startDate?.trim() && section?.endDate?.trim()) return 'seasonal'; + return 'normal'; + } + + function applyVisibilityToSection(section, editorState) { + const visibility = editorState.sectionVisibility || 'normal'; + const orderVal = editorState.order; + const parsedOrder = orderVal !== '' && orderVal != null ? parseInt(orderVal, 10) : undefined; + + if (visibility === 'discovery') { + section.discoveryEnabled = true; + section.type = 'discovery'; + delete section.startDate; + delete section.endDate; + if (parsedOrder == null || Number.isNaN(parsedOrder)) { + section.order = section.order || 100; + } else { + section.order = parsedOrder; + } + } else if (visibility === 'seasonal') { + section.discoveryEnabled = false; + section.type = 'seasonal'; + section.startDate = (editorState.startDate || '').trim(); + section.endDate = (editorState.endDate || '').trim(); + section.order = parsedOrder != null && !Number.isNaN(parsedOrder) ? parsedOrder : (section.order || 100); + } else { + section.discoveryEnabled = false; + section.type = 'home'; + delete section.startDate; + delete section.endDate; + section.order = parsedOrder != null && !Number.isNaN(parsedOrder) ? parsedOrder : (section.order || 100); + } + } + + function inferQuerySourceType(query) { + if (query?._sourceType === 'static') return 'static'; + if (query?.dataSource) return 'cache'; + return 'jellyfin'; + } + + function getQuerySourceBadgeLabel(sourceType) { + if (sourceType === 'cache') return 'Client Cache'; + if (sourceType === 'static') return 'Static Items'; + return 'Jellyfin Server'; + } + + function getQueryStaticItems(query) { + return normalizeSelectedItems(query?.queryOptions?.Ids); + } + + function inferEndpointValue(query) { + if (!query?.path) return 'Items'; + const match = ENDPOINT_OPTIONS.find(opt => opt.path && opt.path === query.path); + return match ? match.value : 'Custom'; + } + + function inferParentItemType(query) { + const queryOptions = query?.queryOptions || {}; + let parentItemType = query?._parentItemType || queryOptions._parentItemType || ''; + if (parentItemType === 'Generic Parent') parentItemType = 'Custom'; + if (!parentItemType && queryOptions.CollectionIds) parentItemType = 'Collection'; + if (!parentItemType && queryOptions.PlaylistIds) parentItemType = 'Playlist'; + if (!parentItemType && queryOptions.ParentId && queryOptions.Recursive === false) parentItemType = 'Library'; + if (!parentItemType && queryOptions.ParentId) parentItemType = 'Custom'; + return parentItemType; + } + + function humanizeLabel(value) { + if (!value || typeof value !== 'string') return value; + return value + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2'); + } + + function normalizeSelectedItems(values) { + if (!values) return []; + if (Array.isArray(values)) { + return values.map(v => { + if (v && typeof v === 'object') { + return { id: String(v.id ?? v.Id ?? ''), name: String(v.name ?? v.Name ?? v.id ?? v.Id ?? '') }; + } + return { id: String(v), name: String(v) }; + }).filter(v => v.id); + } + if (typeof values === 'string') { + return values.split(',').map(s => s.trim()).filter(Boolean).map(s => ({ id: s, name: s })); + } + return []; + } + + function getQueryParentSelection(query) { + const queryOptions = query?.queryOptions || {}; + const parentType = inferParentItemType(query); + if (!parentType) return { type: '', items: [] }; + + if (parentType === 'Collection' && queryOptions.CollectionIds && !queryOptions.ParentId) { + return { type: 'Collection', items: normalizeSelectedItems(queryOptions.CollectionIds) }; + } + if (parentType === 'Playlist' && queryOptions.PlaylistIds && !queryOptions.ParentId) { + return { type: 'Playlist', items: normalizeSelectedItems(queryOptions.PlaylistIds) }; + } + if (queryOptions.ParentId) { + return { type: parentType, items: [{ id: String(queryOptions.ParentId), name: String(queryOptions.ParentId) }] }; + } + return { type: parentType, items: [] }; + } + + function getQueryFacetSelection(query, facetType) { + const queryOptions = query?.queryOptions || {}; + if (facetType === 'Genre') { + if (queryOptions.GenreIds) return normalizeSelectedItems(queryOptions.GenreIds); + if (queryOptions.Genres) return normalizeSelectedItems(queryOptions.Genres); + return []; + } + if (facetType === 'Tag') return normalizeSelectedItems(queryOptions.Tags); + if (facetType === 'Person') return normalizeSelectedItems(queryOptions.PersonIds); + if (facetType === 'Studio') return normalizeSelectedItems(queryOptions.StudioIds); + return []; + } + + function buildSelectionChipsHTML(items) { + return (items || []).map(item => ` + + ${escapeHtml(item.name || item.id)} + + + `).join(''); + } + + function serializeSelectionItems(items) { + return JSON.stringify((items || []).map(i => ({ id: i.id, name: i.name || i.id }))); + } + + function parseSelectionItems(json) { + try { + const parsed = JSON.parse(json || '[]'); + return Array.isArray(parsed) ? normalizeSelectedItems(parsed) : []; + } catch { + return []; + } + } + + function buildSelectCardHTML({ + queryIndex, + kind, + type, + label, + description, + icon, + items = [], + active = false + }) { + const selectedJson = serializeSelectionItems(items); + return ` +
+
+
+
+ ${escapeHtml(icon || 'add')} + ${escapeHtml(label)} +
+
${escapeHtml(description || '')}
+
+ +
+
${buildSelectionChipsHTML(items)}
+ +
+ `; + } + + function buildIncludeItemTypesHTML(queryIndex, selectedTypes) { + const allTypes = getApi().ALL_ITEM_TYPES || FALLBACK_ALL_ITEM_TYPES; + const selected = new Set(Array.isArray(selectedTypes) ? selectedTypes : (selectedTypes ? String(selectedTypes).split(',').map(s => s.trim()).filter(Boolean) : [])); + const defaultSet = new Set(WIZARD_ITEM_TYPE_OPTIONS); + const hasExtraSelected = [...selected].some(type => !defaultSet.has(type)); + const showAll = hasExtraSelected; + + return ` +
+
+
+
Included Item Types
+
+ ${selected.size === 0 ? 'None selected - all item types will be included.' : 'Only the selected item types will be included.'} +
+
+ +
+
+ ${allTypes.map(type => { + const isDefault = defaultSet.has(type); + return ``; + }).join('')} +
+ +
+ `; + } + + function updateIncludeItemTypesHint(container) { + if (!container) return; + const hiddenInput = container.querySelector('input[id$="-IncludeItemTypes"]'); + const hint = container.querySelector('.hsae-field-hint'); + if (!hiddenInput || !hint) return; + + const values = hiddenInput.value + ? hiddenInput.value.split(',').map(s => s.trim()).filter(Boolean) + : []; + + hint.textContent = values.length === 0 + ? 'None selected - all item types will be included.' + : 'Only the selected item types will be included.'; + } + + function syncSelectCardUI(card) { + if (!card) return; + const hidden = card.querySelector('.hsae-select-card-value'); + const chips = card.querySelector('.hsae-select-card-chips'); + const items = parseSelectionItems(hidden?.value); + if (chips) chips.innerHTML = buildSelectionChipsHTML(items); + card.classList.toggle('hsae-active', items.length > 0); + } + + function setSelectCardItems(card, items) { + if (!card) return; + const hidden = card.querySelector('.hsae-select-card-value'); + if (hidden) hidden.value = serializeSelectionItems(items); + syncSelectCardUI(card); + } + + function clearSiblingParentCards(editor, keepType) { + editor.querySelectorAll('.hsae-select-card[data-select-kind="parent"]').forEach(card => { + if (card.dataset.selectType === keepType) return; + setSelectCardItems(card, []); + }); + } + + function inferHideWatched(section) { + const queries = section?.queries || []; + if (!queries.length) return false; + return queries.every(q => q?.queryOptions?.IsUnplayed === true); + } + + function parseFiltersString(filters) { + if (!filters) return []; + return String(filters).split(',').map(s => s.trim()).filter(Boolean); + } + + function matchTtlPreset(ttl) { + if (ttl === undefined || ttl === null || ttl === '') { + return { preset: 'DEFAULT', customValue: '', customUnit: 'minutes' }; + } + const ms = parseInt(ttl, 10); + const presets = getCachePresets().filter(p => p.key !== 'CUSTOM'); + const exact = presets.find(p => p.ms === ms); + if (exact) return { preset: exact.key, customValue: '', customUnit: 'minutes' }; + const minutes = Math.round(ms / 60000); + if (minutes > 0 && minutes * 60000 === ms) { + return { preset: 'CUSTOM', customValue: String(minutes), customUnit: 'minutes' }; + } + const seconds = Math.round(ms / 1000); + return { preset: 'CUSTOM', customValue: String(seconds), customUnit: 'seconds' }; + } + + function resolveGroupSelection(section, context = {}) { + const existingGroupNames = context.existingGroupNames || []; + const currentGroupName = context.currentGroupName || section._targetGroupName || ''; + if (currentGroupName && currentGroupName !== 'New...' && existingGroupNames.includes(currentGroupName)) { + return { groupSelect: currentGroupName, groupNew: '' }; + } + if (currentGroupName && currentGroupName !== 'New...' && !existingGroupNames.includes(currentGroupName)) { + return { groupSelect: 'New...', groupNew: currentGroupName }; + } + if (existingGroupNames.includes('Custom Sections')) { + return { groupSelect: 'Custom Sections', groupNew: '' }; + } + return { groupSelect: existingGroupNames.length ? existingGroupNames[0] : 'New...', groupNew: '' }; + } + + function buildEditorStateFromSection(section, context = {}) { + const { buildSelect, buildTextInput, CARD_FORMATS, RENDER_MODE_OPTIONS } = getApi(); + const customEditor = getCustomItemsEditor(); + const renderMode = section.renderMode || (section.spotlight ? 'Spotlight' : 'Normal'); + const ttlMatch = matchTtlPreset(section.ttl); + const groupSelection = resolveGroupSelection(section, context); + const isDiscovery = isDiscoverySection(section); + const sectionSortBy = section.sortBy || (isDiscovery ? section.sortOrder : '') || ''; + const sectionSortOrder = isDiscovery ? (section.sortOrderDirection || '') : (section.sortOrder || ''); + + return { + section, + context, + editorProfile: resolveEditorProfileKey(section, context), + lockVisibility: context.lockVisibility ?? window.KefinHomeScreenEditorProfiles?.getEditorProfileDefinition(resolveEditorProfileKey(section, context))?.lockVisibility === true, + itemSource: inferItemSource(section), + sectionVisibility: sectionToVisibility(section), + startDate: section.startDate || '', + endDate: section.endDate || '', + order: section.order != null ? String(section.order) : '', + caption: section.caption || '', + description: section.description || '', + renderMode, + cardFormat: section.cardFormat || 'Poster', + useRandomQuery: section.useRandomQuery === true, + useMultiQueryPicker: section.useMultiQueryPicker === true, + multiQueryPickerLabel: section.multiQueryPickerLabel || '', + useQueryNamesForSection: section.useQueryNamesForSection === true, + sortBy: sectionSortBy, + sortOrder: sectionSortOrder, + itemLimit: section.itemLimit != null ? String(section.itemLimit) : '', + hideWatched: inferHideWatched(section), + hideName: section.hideName === true, + userEnabledByDefault: section.enabled !== false, + userConfigurable: section.userConfigurable !== undefined + ? section.userConfigurable !== false + : section.hidden !== true, + groupSelect: groupSelection.groupSelect, + groupNew: groupSelection.groupNew, + queries: (section.queries?.length ? section.queries : [createDefaultQuery()]).map(q => ({ ...q })), + customItems: customEditor ? customEditor.staticItemsToDrafts(section.items) : [], + externalListUrls: Array.isArray(section.externalListUrls) && section.externalListUrls.length + ? section.externalListUrls.slice() + : [''], + customItemEditorIndex: null, + customItemAdditionalExpanded: {}, + ttlPreset: ttlMatch.preset, + ttlCustomValue: ttlMatch.customValue, + ttlCustomUnit: ttlMatch.customUnit, + livePreviewUpdates: section.livePreviewUpdates === true, + buildSelect, + buildTextInput, + CARD_FORMATS, + RENDER_MODE_OPTIONS + }; + } + + function collectStateFromForm(root, state) { + if (!root || !state) return; + + const nameEl = root.querySelector('#section-name'); + const captionEl = root.querySelector('#section-caption'); + const groupEl = root.querySelector('#section-group'); + const groupNewEl = root.querySelector('#section-group-new'); + const visibilityEl = root.querySelector('#hsae-section-visibility'); + const orderEl = root.querySelector('#section-order'); + const hideWatchedEl = root.querySelector('#hsae-hide-watched'); + const hideNameEl = root.querySelector('#hsae-hide-section-name'); + const startDateEl = root.querySelector('#section-startDate'); + const endDateEl = root.querySelector('#section-endDate'); + const userEnabledEl = root.querySelector('#hsae-user-enabled-by-default'); + const userConfigEl = root.querySelector('#hsae-user-configurable'); + const sortByEl = root.querySelector('#section-sortBy'); + const sortOrderEl = root.querySelector('#section-sortOrder'); + + if (nameEl) state.sectionName = nameEl.value; + if (captionEl) state.caption = captionEl.value; + const descriptionEl = root.querySelector('#section-description'); + if (descriptionEl) state.description = descriptionEl.value; + if (groupEl) state.groupSelect = groupEl.value; + if (groupNewEl) state.groupNew = groupNewEl.value; + if (visibilityEl) state.sectionVisibility = visibilityEl.value; + if (orderEl) state.order = orderEl.value; + + const categoryEl = root.querySelector('#section-category'); + if (categoryEl) { + const isDiscovery = state.sectionVisibility === 'discovery'; + state.category = isDiscovery ? 'discovery' : (categoryEl.value || 'none'); + state.section = state.section || {}; + state.section.category = state.category; + } + + if (hideWatchedEl) state.hideWatched = hideWatchedEl.checked === true; + if (hideNameEl) state.hideName = hideNameEl.checked === true; + if (startDateEl) state.startDate = startDateEl.value; + if (endDateEl) state.endDate = endDateEl.value; + if (sortByEl) state.sortBy = sortByEl.value; + if (sortOrderEl) state.sortOrder = sortOrderEl.value; + + const renderModeValue = root.querySelector('#hsae-render-mode-value')?.value; + if (renderModeValue) { + state.renderMode = renderModeValue; + } else { + const activeRenderMode = root.querySelector('.hsae-appearance-card.hsae-active[data-render-mode]'); + if (activeRenderMode) { + state.renderMode = activeRenderMode.dataset.renderMode || state.renderMode; + } + } + + const cardFormatValue = root.querySelector('#hsae-card-format-value')?.value; + if (cardFormatValue) { + state.cardFormat = cardFormatValue; + } else { + const activeCardFormat = root.querySelector('.hsae-card-format-btn.hsae-active[data-card-format]'); + if (activeCardFormat) { + state.cardFormat = activeCardFormat.dataset.cardFormat || state.cardFormat; + } + } + + const randomQueryEl = root.querySelector('#hsae-use-random-query'); + if (randomQueryEl) state.useRandomQuery = randomQueryEl.checked === true; + const pickerEl = root.querySelector('#hsae-use-multi-query-picker'); + if (pickerEl) state.useMultiQueryPicker = pickerEl.checked === true; + const pickerLabelEl = root.querySelector('#hsae-multi-query-picker-label'); + if (pickerLabelEl) state.multiQueryPickerLabel = pickerLabelEl.value; + const useQueryNamesEl = root.querySelector('#hsae-use-query-names-for-section'); + if (useQueryNamesEl) state.useQueryNamesForSection = useQueryNamesEl.checked === true; + + if (userEnabledEl) state.userEnabledByDefault = userEnabledEl.checked === true; + if (userConfigEl) state.userConfigurable = userConfigEl.checked === true; + + const activePreset = root.querySelector('.hsae-cache-preset.hsae-active'); + if (activePreset) { + state.ttlPreset = activePreset.dataset.presetKey || 'DEFAULT'; + } + const ttlCustomValueEl = root.querySelector('#hsae-ttl-custom-value'); + const ttlCustomUnitEl = root.querySelector('#hsae-ttl-custom-unit'); + if (ttlCustomValueEl) state.ttlCustomValue = ttlCustomValueEl.value; + if (ttlCustomUnitEl) state.ttlCustomUnit = ttlCustomUnitEl.value; + + const customEditor = getCustomItemsEditor(); + if (customEditor && state.itemSource === 'custom') { + customEditor.collectCustomItemsFromForm(root, state, getAdvancedCustomItemsOptions(state)); + } + + if (state.itemSource === 'external') { + const urlInputs = root.querySelectorAll('[id^="hsae-external-list-url-"]'); + state.externalListUrls = Array.from(urlInputs).map((el) => (el.value || '').trim()).filter(Boolean); + if (!state.externalListUrls.length) state.externalListUrls = ['']; + } + + if (state.itemSource === 'jellyfin') { + const hideWatched = root.querySelector('#hsae-hide-watched')?.checked === true; + const editors = root.querySelectorAll('.query-editor'); + if (editors.length) { + state.queryExpandedStates = Array.from(editors).map(editor => editor.open); + state.queries = Array.from(editors).map(editor => { + const queryIndex = parseInt(editor.dataset.queryIndex, 10); + const originalQuery = state.section?.queries?.[queryIndex]; + return collectQueryFromEditor(editor, queryIndex, hideWatched, root, originalQuery); + }); + } + } + + if (state.renderMode === 'Spotlight') { + const { collectSpotlightConfigFromDialog } = getApi(); + if (typeof collectSpotlightConfigFromDialog === 'function') { + state.section = state.section || {}; + state.section.spotlightConfig = collectSpotlightConfigFromDialog(root, 'section-spotlight-', { useIntervalSeconds: true }); + } + } + + const borderColorText = root.querySelector('#hsae-border-color-text'); + if (borderColorText) { + state.section = state.section || {}; + const borderColor = borderColorText.value.trim(); + if (borderColor) state.section.borderColor = borderColor; + else delete state.section.borderColor; + } + + const cardTitleColorText = root.querySelector('#hsae-card-title-color-text'); + if (cardTitleColorText) { + state.section = state.section || {}; + const cardTitleColor = cardTitleColorText.value.trim(); + if (cardTitleColor) state.section.cardTitleColor = cardTitleColor; + else delete state.section.cardTitleColor; + } + + const livePreviewEl = root.querySelector('#hsae-live-preview-updates'); + if (livePreviewEl) state.livePreviewUpdates = livePreviewEl.checked === true; + + const cssClassEl = root.querySelector('#hsae-section-css-class'); + if (cssClassEl) { + state.section = state.section || {}; + const cssClass = cssClassEl.value.trim(); + if (cssClass) state.section.sectionCssClass = cssClass; + else delete state.section.sectionCssClass; + } + + const borderStyleEl = root.querySelector('#hsae-border-style'); + if (borderStyleEl) { + state.section = state.section || {}; + const borderStyle = borderStyleEl.value.trim(); + if (borderStyle) state.section.borderStyle = borderStyle; + else delete state.section.borderStyle; + } + + const itemsLayoutEl = root.querySelector('#hsae-items-layout'); + if (itemsLayoutEl) { + state.section = state.section || {}; + const itemsLayout = itemsLayoutEl.value; + if (itemsLayout === 'grid') state.section.itemsLayout = itemsLayout; + else delete state.section.itemsLayout; + } + + const cardGapsEl = root.querySelector('#hsae-card-gaps'); + if (cardGapsEl) { + state.section = state.section || {}; + if (cardGapsEl.value === 'none') state.section.useGaplessCards = true; + else delete state.section.useGaplessCards; + } + + const hideCardTitlesEl = root.querySelector('#hsae-hide-card-titles'); + if (hideCardTitlesEl) { + state.section = state.section || {}; + if (hideCardTitlesEl.checked === true) { + state.section.cardTitleVisibility = 'hidden'; + state.section.hideCardTitles = true; + } else { + delete state.section.cardTitleVisibility; + delete state.section.hideCardTitles; + } + } + + const useParentCardEl = root.querySelector('#hsae-use-parent-card'); + if (useParentCardEl) { + state.section = state.section || {}; + if (useParentCardEl.checked === true) state.section.useParentCard = true; + else delete state.section.useParentCard; + } + + const cardTitlePositionEl = root.querySelector('#hsae-card-title-position'); + if (cardTitlePositionEl) { + state.section = state.section || {}; + const cardTitlePosition = cardTitlePositionEl.value; + if (cardTitlePosition && cardTitlePosition !== 'default') state.section.cardTitlePosition = cardTitlePosition; + else delete state.section.cardTitlePosition; + } + + const cardTitleCapEl = root.querySelector('#hsae-card-title-capitalization'); + if (cardTitleCapEl) { + state.section = state.section || {}; + const cap = cardTitleCapEl.value; + if (cap && cap !== 'normal') state.section.cardTitleCapitalization = cap; + else delete state.section.cardTitleCapitalization; + } + + const cardTitleFontFamilyEl = root.querySelector('#hsae-card-title-font-family'); + if (cardTitleFontFamilyEl) { + state.section = state.section || {}; + const fontFamily = cardTitleFontFamilyEl.value; + if (fontFamily && fontFamily !== 'default') state.section.cardTitleFontFamily = fontFamily; + else delete state.section.cardTitleFontFamily; + } + + const cardTitleFontSizeEl = root.querySelector('#hsae-card-title-font-size'); + if (cardTitleFontSizeEl) { + state.section = state.section || {}; + const fontSize = cardTitleFontSizeEl.value; + if (fontSize && fontSize !== 'normal') state.section.cardTitleFontSize = fontSize; + else delete state.section.cardTitleFontSize; + } + } + + function buildChipRow(options, activeValues, attrName) { + const active = new Set(Array.isArray(activeValues) ? activeValues : []); + return ` +
+ ${options.map(opt => ` + + `).join('')} +
+ `; + } + + function buildFilterChipsRow(queryIndex, queryOptions) { + const active = parseFiltersString(queryOptions?.Filters); + return ` +
+
Filters
+
Use these quick filters to narrow the query results.
+ ${buildChipRow(FILTER_CHIP_OPTIONS, active, `query-${queryIndex}-filters`)} + +
+ `; + } + + function buildAdditionalBooleanToggleHTML(inputId, key, label, checked) { + const { buildToggleCard } = getApi(); + return buildToggleCard(inputId, checked, label, '', { + checkboxClass: 'additional-option-value', + checkboxDataAttributes: { key, type: 'boolean' } + }); + } + + function getAdditionalOptionRowClass(type) { + const classes = ['additional-option-row', 'hsae-additional-row']; + if (type === 'custom') classes.push('hsae-additional-row-full'); + return classes.join(' '); + } + + function buildAdditionalOptionsControls(queryIndex) { + const { SUPPORTED_QUERY_OPTIONS } = getApi(); + const dropdownOptions = Object.entries(SUPPORTED_QUERY_OPTIONS || {}) + .map(([key, meta]) => ``) + .join(''); + + return ` +
+
+ + +
+
+ `; + } + + function buildAdditionalOptionsList(queryIndex, queryOptions) { + const { SUPPORTED_QUERY_OPTIONS } = getApi(); + const badgeKeys = new Set([ + 'IncludeItemTypes', 'Genres', 'Tags', 'GenreIds', 'PersonIds', 'StudioIds', + 'CollectionIds', 'PlaylistIds', 'ParentId', 'SortBy', 'SortOrder', 'Limit', + 'Filters', 'SearchTerm', 'IsUnplayed', 'Recursive' + ]); + const commonKeys = new Set(['SortBy', 'SortOrder', 'Limit', 'Filters', 'SearchTerm']); + const additionalOptions = []; + + Object.entries(queryOptions || {}).forEach(([key, value]) => { + if (key.startsWith('_') || badgeKeys.has(key) || commonKeys.has(key)) return; + additionalOptions.push({ key, value }); + }); + + const optionsHtml = additionalOptions.map((option, index) => { + const meta = SUPPORTED_QUERY_OPTIONS?.[option.key] || { label: option.key, type: 'string' }; + const inputId = `query-${queryIndex}-additionalOption-${index}`; + let inputField = ''; + + if (meta.type === 'boolean') { + const isChecked = option.value === true || option.value === 'true'; + inputField = buildAdditionalBooleanToggleHTML(inputId, option.key, meta.label, isChecked); + } else { + const displayValue = Array.isArray(option.value) ? option.value.join(',') : (option.value ?? ''); + inputField = ` +
+
${escapeHtml(meta.label)}
+ +
+ `; + } + + return ` +
+ ${inputField} + +
+ `; + }).join(''); + + return ` +
+
+ ${optionsHtml} +
+
+ `; + } + + function buildStaticItemsListHTML(items) { + if (!items.length) { + return '

No items selected yet.

'; + } + return items.map(item => ` +
+ ${escapeHtml(item.name || item.id)} + +
+ `).join(''); + } + + function buildStaticItemsSectionHTML(queryIndex, items) { + const selectedJson = serializeSelectionItems(items); + return ` +
+
Select Items
+
Pick specific items from your library to appear in this section.
+
+ ${buildStaticItemsListHTML(items)} +
+ + +
+ `; + } + + function buildEndpointSelectHTML(index, selectedValue, disabled = false) { + const { buildSelect } = getApi(); + const html = buildSelect( + `query-${index}-endpoint`, + ENDPOINT_OPTIONS.map(o => ({ value: o.value, label: o.label })), + selectedValue, + 'Endpoint' + ); + if (!disabled) return html; + return html.replace( + `id="query-${index}-endpoint" class="fld emby-select emby-select-withcolor"`, + `id="query-${index}-endpoint" class="fld emby-select emby-select-withcolor" disabled` + ); + } + + function setStaticItemsInEditor(editor, queryIndex, items) { + if (!editor) return; + const hidden = editor.querySelector(`#query-${queryIndex}-staticItems`); + if (hidden) hidden.value = serializeSelectionItems(items); + const list = editor.querySelector(`.hsae-static-items-list[data-query-index="${queryIndex}"]`); + if (list) list.innerHTML = buildStaticItemsListHTML(items); + } + + function getCacheSourceOption(dataSource) { + const value = dataSource || CACHE_SOURCE_OPTIONS[0]?.value; + return CACHE_SOURCE_OPTIONS.find(o => o.value === value) || CACHE_SOURCE_OPTIONS[0] || null; + } + + function buildCacheArgsHTML(queryIndex, dataSource, dataSourceOptions) { + const { buildSelect, buildTextInput } = getApi(); + const source = getCacheSourceOption(dataSource); + const args = Array.isArray(source?.args) ? source.args : []; + if (!args.length) return ''; + + const fields = args.map((arg) => { + const id = `query-${queryIndex}-dsArg-${arg.key}`; + const raw = dataSourceOptions?.[arg.key]; + const selected = raw != null ? String(raw) : (arg.default != null ? String(arg.default) : ''); + if (arg.type === 'select') { + const options = Array.isArray(arg.possibleValues) ? arg.possibleValues : []; + return buildSelect(id, options, selected, arg.label || arg.key); + } + if (arg.type === 'number') { + return buildTextInput(id, selected, arg.label || arg.key, 'number', '', { step: 1 }); + } + return buildTextInput(id, selected, arg.label || arg.key); + }).join(''); + + return `
${fields}
`; + } + + function collectCacheArgsIntoDataSourceOptions(editor, queryIndex, dataSource, dataSourceOptions) { + const source = getCacheSourceOption(dataSource); + const args = Array.isArray(source?.args) ? source.args : []; + args.forEach((arg) => { + const el = editor.querySelector(`#query-${queryIndex}-dsArg-${arg.key}`); + if (!el) return; + const raw = el.value; + if (raw === '' || raw == null) return; + if (arg.type === 'number') { + const n = parseFloat(raw); + if (Number.isFinite(n)) dataSourceOptions[arg.key] = n; + return; + } + dataSourceOptions[arg.key] = raw; + }); + } + + function buildQueryCard(query, index, canDelete, isExpanded = true) { + const { buildSelect, buildTextInput, SORT_ORDERS, SORT_ORDER_DIRECTIONS } = getApi(); + const queryOptions = query.queryOptions || {}; + // Prefer dataSourceOptions; fall back to legacy ItemType/MinCount stored in queryOptions + const dataSourceOptions = { + ...(query.queryOptions?.ItemType != null ? { ItemType: query.queryOptions.ItemType } : {}), + ...(query.queryOptions?.MinCount != null ? { MinCount: query.queryOptions.MinCount } : {}), + ...(query.dataSourceOptions || {}) + }; + const sourceType = inferQuerySourceType(query); + const endpointValue = sourceType === 'static' ? 'Items' : inferEndpointValue(query); + const customPathDisplay = endpointValue === 'Custom' && sourceType === 'jellyfin' ? 'block' : 'none'; + const cacheDisplay = sourceType === 'cache' ? 'block' : 'none'; + const endpointDisplay = sourceType === 'jellyfin' || sourceType === 'static' ? 'block' : 'none'; + const jellyfinDisplay = sourceType === 'jellyfin' ? 'block' : 'none'; + const staticDisplay = sourceType === 'static' ? 'block' : 'none'; + const limitDisplay = sourceType === 'jellyfin' || sourceType === 'static' || sourceType === 'cache' ? 'block' : 'none'; + const searchDisplay = sourceType === 'jellyfin' || sourceType === 'static' ? 'block' : 'none'; + const staticItems = getQueryStaticItems(query); + const selectedDataSource = query.dataSource || CACHE_SOURCE_OPTIONS[0]?.value; + + const parentSelection = getQueryParentSelection(query); + const includeTypes = Array.isArray(queryOptions.IncludeItemTypes) + ? queryOptions.IncludeItemTypes + : (queryOptions.IncludeItemTypes ? String(queryOptions.IncludeItemTypes).split(',').map(s => s.trim()).filter(Boolean) : []); + + return ` +
+ + + + + ${escapeHtml(getQuerySourceBadgeLabel(sourceType))} + ${canDelete ? `` : ''} + +
+ +
+ ${buildSelect(`query-${index}-sourceType`, [ + { value: 'jellyfin', label: 'Jellyfin Server' }, + { value: 'cache', label: 'Client Cache' }, + { value: 'static', label: 'Static Items' } + ], sourceType, 'Query Type')} +
+ ${buildEndpointSelectHTML(index, endpointValue, sourceType === 'static')} +
+
+ ${buildSelect(`query-${index}-dataSource`, CACHE_SOURCE_OPTIONS, selectedDataSource, 'Cache Source')} +
+
+ ${buildTextInput(`query-${index}-path`, query.path || '', 'Custom Endpoint Path', 'text', '/Shows/Upcoming, etc.')} +
+
+
+ ${sourceType === 'cache' ? buildCacheArgsHTML(index, selectedDataSource, dataSourceOptions) : ''} +
+ +
+
+ ${buildSelect(`query-${index}-SortBy`, toSortBySelectOptions(SORT_ORDERS), queryOptions.SortBy || '', 'Sort By')} +
+
+ ${buildSelect(`query-${index}-SortOrder`, SORT_ORDER_DIRECTIONS, queryOptions.SortOrder || '', 'Sort Order')} +
+
+ ${buildTextInput(`query-${index}-Limit`, queryOptions.Limit ?? '', 'Limit', 'number', '', { min: 0, step: 1 })} +
+ +
+ +
+ + ${buildStaticItemsSectionHTML(index, staticItems)} +
+ +
+ + +
+ ${buildIncludeItemTypesHTML(index, includeTypes)} +
+ + + +
+
Parent Item
+
Use this to show items that are children of a specific parent item. This could be a Collection, Playlist, Library, etc.
+
+ ${PARENT_SELECTION_CARD_TYPES.map(card => buildSelectCardHTML({ + queryIndex: index, + kind: 'parent', + type: card.type, + label: card.label, + description: card.description, + icon: card.icon, + items: parentSelection.type === card.type ? parentSelection.items : [], + active: parentSelection.type === card.type + })).join('')} +
+
+ + + +
+ ${buildFilterChipsRow(index, queryOptions)} +
+ +
+ ${FACET_SELECTION_CARD_TYPES.map(card => buildSelectCardHTML({ + queryIndex: index, + kind: 'facet', + type: card.type, + label: card.label, + description: card.description, + icon: card.icon, + items: getQueryFacetSelection(query, card.type) + })).join('')} +
+ + + +
+ + expand_more + Additional Options + +
+ ${buildAdditionalOptionsControls(index)} + ${buildAdditionalOptionsList(index, queryOptions)} +
+
+
+
+
+ `; + } + + function buildBasicPropertiesHTML(state, context) { + const { buildSelect, buildTextInput, buildToggleCard } = getApi(); + const section = state.section || {}; + const visibility = state.sectionVisibility || 'normal'; + const showSeasonal = visibility === 'seasonal'; + const lockVisibility = state.lockVisibility === true; + const orderValue = visibility === 'discovery' + ? 'Auto' + : (state.order ?? section.order ?? ''); + const showHideWatched = state.itemSource === 'jellyfin'; + + const existingGroupNames = context.existingGroupNames || []; + const groupOptions = ['New...', ...existingGroupNames]; + const selectedGroup = state.groupSelect || groupOptions[0] || 'New...'; + let visibilitySelectHtml = buildSelect('hsae-section-visibility', SECTION_VISIBILITY_OPTIONS, visibility, 'Type'); + if (lockVisibility) { + visibilitySelectHtml = visibilitySelectHtml.replace( + 'id="hsae-section-visibility" class="fld emby-select emby-select-withcolor"', + 'id="hsae-section-visibility" class="fld emby-select emby-select-withcolor" disabled' + ); + } + + return ` +
+
+
Basic Properties
+
General configuration for this section
+
+
+
+ ${buildTextInput('section-name', section.name || state.sectionName || '', 'Section Name')} + ${buildTextInput('section-caption', section.caption || state.caption || '', 'Caption', 'text', 'Displayed below the Section Name')} +
+ +
+ ${buildSelect('section-group', groupOptions, selectedGroup, 'Section Group')} + ${(() => { + const categories = (context.config?.HOME_SETTINGS?.categories + || window.KefinTweaksConfig?.homeScreenConfig?.HOME_SETTINGS?.categories + || window.KefinHomeConfig2?.HOME_SETTINGS?.categories + || []).map((c) => ({ value: c.id, label: c.name || c.id })); + const isDiscovery = state.sectionVisibility === 'discovery' + || section.discoveryEnabled === true + || section.type === 'discovery'; + const selectedCategory = isDiscovery ? 'discovery' : (section.category || state.category || 'none'); + const categorySelect = buildSelect( + 'section-category', + categories.length ? categories : [{ value: 'none', label: 'Home' }], + selectedCategory, + 'Category' + ); + return isDiscovery + ? categorySelect.replace( + 'id="section-category" class="fld emby-select emby-select-withcolor"', + 'id="section-category" class="fld emby-select emby-select-withcolor" disabled' + ) + : categorySelect; + })()} +
+ ${buildTextInput('section-group-new', state.groupNew || '', 'New Group Name')} +
+
+ +
+ ${visibilitySelectHtml} +
+ + +
+
+ + ${showSeasonal ? ` +
+ ${buildTextInput('section-startDate', state.startDate || '', 'Start Date (MM-DD)', 'text')} + ${buildTextInput('section-endDate', state.endDate || '', 'End Date (MM-DD)', 'text')} +
+ ` : ''} + + + +
+ ${buildToggleCard( + 'hsae-user-enabled-by-default', + state.userEnabledByDefault !== false, + 'Default Status', + USER_TOGGLE_TOOLTIPS.userEnabledByDefault[state.userEnabledByDefault !== false ? 'on' : 'off'], + { hintKey: 'userEnabledByDefault' } + )} + ${buildToggleCard( + 'hsae-user-configurable', + state.userConfigurable !== false, + 'User Configuration', + USER_TOGGLE_TOOLTIPS.userConfigurable[state.userConfigurable !== false ? 'on' : 'off'], + { hintKey: 'userConfigurable' } + )} +
+ +
+ ${buildToggleCard( + 'hsae-hide-section-name', + state.hideName === true, + 'Hide Section Name', + HIDE_NAME_TOGGLE_DESCRIPTIONS[state.hideName === true ? 'on' : 'off'], + { hintKey: 'hideName' } + )} + ${showHideWatched ? buildToggleCard( + 'hsae-hide-watched', + state.hideWatched === true, + 'Hide Watched Items', + HIDE_WATCHED_TOGGLE_DESCRIPTIONS[state.hideWatched === true ? 'on' : 'off'], + { hintKey: 'hideWatched' } + ) : ''} +
+
+
+ `; + } + + function buildItemSourceHTML(state) { + const itemSource = state.itemSource || 'jellyfin'; + return ` +
+
+
Item Source
+
Choose where this section gets its items
+
+
+ + +
+
+ `; + } + + function buildExternalListHTML(state) { + const { buildTextInput } = getApi(); + const urls = Array.isArray(state.externalListUrls) && state.externalListUrls.length + ? state.externalListUrls + : ['']; + const apiKey = window.LibraryCacheUtils?.getLibraryCacheSettings?.()?.mdblistApiKey + || window.KefinHomeConfig2?.LIBRARY_CACHE?.mdblistApiKey + || ''; + const warn = !String(apiKey || '').trim() + ? `
MDBList API key is not configured. Add it under Home Screen → General settings before using External Lists.
` + : ''; + const rows = urls.map((url, index) => ` +
+ ${buildTextInput(`hsae-external-list-url-${index}`, url || '', `List URL ${index + 1}`, 'url', 'https://mdblist.com/...')} +
+ `).join(''); + return ` +
+
+
External List URLs
+
Items from these lists are matched to your Jellyfin library by IMDb/TMDB ids
+
+ ${warn} +
${rows}
+ +
+ `; + } + + function buildQueriesHTML(state) { + const queries = state.queries?.length ? state.queries : [createDefaultQuery()]; + const expandedStates = state.queryExpandedStates; + const hasMultiple = queries.length >= 2; + const { buildSelect, buildToggleCard, buildTextInput, SORT_ORDERS, SORT_ORDER_DIRECTIONS } = getApi(); + const useRandomQuery = state.useRandomQuery === true; + const useMultiQueryPicker = state.useMultiQueryPicker === true; + + return ` +
+
+
+
Queries
+ + + +
+
Define what content appears in this section
+
+
+ ${queries.map((query, index) => buildQueryCard( + query, + index, + queries.length > 1, + expandedStates ? expandedStates[index] !== false : true + )).join('')} +
+ + ${hasMultiple ? ` +
+
+
Multi-Query Settings
+
Control how the multiple queries are handled
+
+
+
+ ${buildToggleCard( + 'hsae-use-random-query', + useRandomQuery, + 'Use Random Query', + RANDOM_QUERY_TOGGLE_DESCRIPTIONS[useRandomQuery ? 'on' : 'off'], + { hintKey: 'useRandomQuery' } + )} + ${buildToggleCard( + 'hsae-use-multi-query-picker', + useMultiQueryPicker, + 'Query Picker', + QUERY_PICKER_TOGGLE_DESCRIPTIONS[useMultiQueryPicker ? 'on' : 'off'], + { hintKey: 'useMultiQueryPicker' } + )} +
+
+
+ ${buildTextInput('hsae-multi-query-picker-label', state.multiQueryPickerLabel || '', 'Label for Multi Query Picker button', 'text', 'The multi-query picker control will use this label instead of the name of the query being displayed.')} +
+
+ ${buildToggleCard( + 'hsae-use-query-names-for-section', + state.useQueryNamesForSection === true, + 'Use Query Names for the Section Name', + USE_QUERY_NAMES_TOGGLE_DESCRIPTIONS[state.useQueryNamesForSection === true ? 'on' : 'off'], + { hintKey: 'useQueryNamesForSection' } + )} +
+
+
+
+ ` : ''} +
+ `; + } + + function buildCustomItemsHTML(state) { + const customEditor = getCustomItemsEditor(); + const allowRemove = shouldAllowCustomItemRemove(state); + const html = customEditor + ? customEditor.renderCustomItemsHTML(state, getAdvancedCustomItemsOptions(state)) + : '
Custom items editor not available.
'; + const groupDesc = allowRemove + ? 'Create cards with custom images and links' + : 'Reorder or hide cards. Default section items cannot be deleted.'; + + return ` +
+
+
Custom Items
+
${escapeHtml(groupDesc)}
+
+
${html}
+
+ `; + } + + function buildSpotlightInnerHTML(state, context) { + const section = state.section || {}; + const { + buildSelect, buildTextInput, buildToggleCard, + SPOTLIGHT_LAYOUT_OPTIONS, SPOTLIGHT_SIZE_OPTIONS, SPOTLIGHT_TILE_COUNT_OPTIONS, + SPOTLIGHT_ENTRANCE_OPTIONS, SPOTLIGHT_SLIDE_OPTIONS, SLIDE_STATE_OPTIONS + } = getApi(); + + const globalSpotlight = context.config?.SPOTLIGHT_SETTINGS || {}; + const spotlightDefaults = window.KefinHomeConfig2?.SPOTLIGHT_SETTINGS || {}; + const sectionSpotlight = section.spotlightConfig || {}; + const spotlightOpts = { ...spotlightDefaults, ...globalSpotlight, ...sectionSpotlight }; + const spotlightLayout = spotlightOpts.spotlightLayout ?? 'Border'; + const spotlightSize = spotlightOpts.spotlightSize ?? 'normal'; + const tileCount = Math.max(1, Math.min(3, parseInt(spotlightOpts.tileCount, 10) || 1)); + const tileCountStr = String(tileCount); + const slideStateVal = spotlightOpts.showSlideState === false ? 'none' : (spotlightOpts.showDots === false ? 'numeric' : 'dots'); + const intervalSeconds = Math.round((spotlightOpts.interval ?? 10000) / 1000); + const cycleBackdropsOn = spotlightOpts.cycleBackdrops === true; + const rawBackdropsCount = spotlightOpts.backdropsCount ?? (spotlightOpts.cycleBackdropsTime && spotlightOpts.interval + ? Math.max(1, Math.round(spotlightOpts.interval / spotlightOpts.cycleBackdropsTime)) + : 2); + const backdropsCount = cycleBackdropsOn ? Math.max(2, parseInt(rawBackdropsCount, 10) || 2) : Math.max(1, parseInt(rawBackdropsCount, 10) || 1); + const animateSlides = spotlightOpts.panAnimation !== false; + + function animRow(n) { + const ord = n === 1 ? 'First' : n === 2 ? 'Second' : 'Third'; + return ` +
+ ${buildSelect('section-spotlight-entranceAnimation' + ord, SPOTLIGHT_ENTRANCE_OPTIONS, spotlightOpts['entranceAnimation' + ord] ?? 'fadeIn', 'Entrance animation ' + n)} + ${buildSelect('section-spotlight-slideAnimation' + ord, SPOTLIGHT_SLIDE_OPTIONS, spotlightOpts['slideAnimation' + ord] ?? 'kenBurnsZoomIn', 'Slide animation ' + n)} +
+ `; + } + + function spotlightToggle(id, checked, label) { + return buildToggleCard(id, checked, label, SPOTLIGHT_TOGGLE_DESCRIPTIONS[id] || ''); + } + + return ` +
+
+ ${buildSelect('section-spotlight-spotlightLayout', SPOTLIGHT_LAYOUT_OPTIONS, spotlightLayout, 'Layout')} + ${buildSelect('section-spotlight-spotlightSize', SPOTLIGHT_SIZE_OPTIONS, spotlightSize, 'Size')} + ${buildSelect('section-spotlight-tileCount', SPOTLIGHT_TILE_COUNT_OPTIONS, tileCountStr, 'Tiled Backdrops')} + ${buildSelect('section-spotlight-slideState', SLIDE_STATE_OPTIONS, slideStateVal, 'Slide State')} +
+
+ ${spotlightToggle('section-spotlight-panAnimation', animateSlides, 'Animate Slides')} + ${spotlightToggle('section-spotlight-showNavButtons', spotlightOpts.showNavButtons !== false, 'Show Controls')} + ${spotlightToggle('section-spotlight-showClearArt', spotlightOpts.showClearArt === true, 'Show Clear Art')} + ${spotlightToggle('section-spotlight-cycleBackdrops', cycleBackdropsOn, 'Cycle Backdrops')} +
+
+ ${buildTextInput('section-spotlight-backdropsCount', backdropsCount, 'Backdrops Count', 'number')} +
+ +
+
${animRow(1)}
+
${animRow(2)}
+
${animRow(3)}
+
+
+ ${spotlightToggle('section-spotlight-autoPlay', spotlightOpts.autoPlay !== false, 'Cycle Slides Automatically')} + ${buildTextInput('section-spotlight-interval', intervalSeconds, 'Slide Timer (seconds)', 'number')} +
+
+ `; + } + + /** + * Appearance-panel draft: same form→section path as save/Preview, forced to Normal cards. + * @param {HTMLElement} dialog + * @param {Object} originalSection + * @returns {Object|null} + */ + function buildAppearancePreviewDraft(dialog, originalSection) { + const draft = collectData(dialog, originalSection); + if (!draft) return null; + return { + ...draft, + renderMode: 'Normal' + }; + } + + function getAppearancePreviewFetchKeyFromDraft(draft) { + return JSON.stringify({ + itemSource: inferItemSource(draft), + queries: draft?.queries, + items: draft?.items, + externalListUrls: draft?.externalListUrls, + hideWatched: Array.isArray(draft?.queries) + && draft.queries.length > 0 + && draft.queries.every(q => q?.queryOptions?.IsUnplayed === true) + }); + } + + function getAppearancePreviewRenderKeyFromDraft(draft) { + return JSON.stringify({ + fetch: getAppearancePreviewFetchKeyFromDraft(draft), + cardFormat: draft?.cardFormat, + sortBy: draft?.sortBy, + sortOrder: draft?.sortOrder, + itemsLayout: draft?.itemsLayout, + cardTitleVisibility: draft?.cardTitleVisibility, + hideCardTitles: draft?.hideCardTitles, + useParentCard: draft?.useParentCard === true, + cardTitlePosition: draft?.cardTitlePosition, + cardTitleCapitalization: draft?.cardTitleCapitalization, + cardTitleFontFamily: draft?.cardTitleFontFamily, + cardTitleFontSize: draft?.cardTitleFontSize, + borderStyle: draft?.borderStyle, + borderColor: draft?.borderColor, + cardTitleColor: draft?.cardTitleColor, + sectionCssClass: draft?.sectionCssClass, + hideName: draft?.hideName + }); + } + + const LIVE_PREVIEW_DEBOUNCE_MS = 1000; + + let appearancePreviewDebounceTimer = null; + let appearancePreviewRequestId = 0; + let appearancePreviewCache = { fetchKey: null, renderKey: null, items: null, resolvedSection: null }; + + function clearAppearancePreviewScheduling() { + if (appearancePreviewDebounceTimer) { + clearTimeout(appearancePreviewDebounceTimer); + appearancePreviewDebounceTimer = null; + } + appearancePreviewRequestId += 1; + } + + async function runAppearancePreview(modalInstance, options = {}) { + const state = modalInstance?._hsaeState; + if (!state || state.renderMode !== 'Normal') return; + + const root = modalInstance.dialogContent; + const container = root?.querySelector('#hsae-appearance-preview-container'); + if (!container) return; + + const fetchPreview = window.KefinHomeScreen?.fetchSectionPreviewItems; + const renderPreview = window.KefinHomeScreen?.renderSectionPreviewInto; + const mergeResolved = window.KefinHomeScreen?.mergeDraftWithResolvedPreview; + if (typeof fetchPreview !== 'function' || typeof renderPreview !== 'function') { + container.innerHTML = '

Preview unavailable.

'; + return; + } + + // Keep live editor UI state in sync; draft for fetch/render comes from collectData only. + collectStateFromForm(root, state); + + const dialog = modalInstance.dialogContainer || modalInstance.dialogContent; + const originalSection = state.section || {}; + const getDraftSection = () => buildAppearancePreviewDraft(dialog, originalSection); + + const draft = getDraftSection(); + if (!draft) { + container.innerHTML = '

Preview unavailable.

'; + return; + } + + const fetchKey = getAppearancePreviewFetchKeyFromDraft(draft); + const renderKey = getAppearancePreviewRenderKeyFromDraft(draft); + const requestId = ++appearancePreviewRequestId; + const forceFetch = options.forceFetch === true; + const cacheHit = !forceFetch + && appearancePreviewCache.fetchKey === fetchKey + && appearancePreviewCache.items !== null; + + if (!cacheHit) { + container.innerHTML = '

Loading preview…

'; + } + + try { + if (!cacheHit) { + const { items, section: resolvedSection } = await fetchPreview(draft); + if (requestId !== appearancePreviewRequestId) return; + appearancePreviewCache = { fetchKey, renderKey, items, resolvedSection: resolvedSection || null }; + } else { + appearancePreviewCache.renderKey = renderKey; + } + + if (requestId !== appearancePreviewRequestId) return; + + const previewItems = appearancePreviewCache.items || []; + if (previewItems.length === 0) { + container.innerHTML = '

No items found for preview.

'; + return; + } + + const liveDraft = getDraftSection(); + const previewSection = typeof mergeResolved === 'function' + ? mergeResolved(liveDraft, appearancePreviewCache.resolvedSection) + : liveDraft; + await renderPreview(previewSection, previewItems, container, { getDraftSection }); + } catch (err) { + if (requestId !== appearancePreviewRequestId) return; + appearancePreviewCache = { fetchKey: null, renderKey: null, items: null, resolvedSection: null }; + container.innerHTML = `

${escapeHtml(err.message || 'Preview failed')}

`; + } + } + + function scheduleAppearancePreview(modalInstance, options = {}) { + const state = modalInstance?._hsaeState; + if (!state || state.renderMode !== 'Normal') return; + + const root = modalInstance.dialogContent; + if (!root?.querySelector('#hsae-appearance-preview-container')) return; + + if (options.immediate) { + if (appearancePreviewDebounceTimer) { + clearTimeout(appearancePreviewDebounceTimer); + appearancePreviewDebounceTimer = null; + } + runAppearancePreview(modalInstance, options); + return; + } + + if (!state.livePreviewUpdates) return; + + if (appearancePreviewDebounceTimer) clearTimeout(appearancePreviewDebounceTimer); + appearancePreviewDebounceTimer = setTimeout(() => { + appearancePreviewDebounceTimer = null; + runAppearancePreview(modalInstance); + }, LIVE_PREVIEW_DEBOUNCE_MS); + } + + function buildAppearanceAdvancedHTML(state) { + const { buildTextInput, buildToggleCard } = getApi(); + const section = state.section || {}; + const renderMode = state.renderMode || 'Normal'; + const showItemsLayout = renderMode === 'Normal' || renderMode === 'Random'; + const hideCardTitles = section.cardTitleVisibility === 'hidden' || section.hideCardTitles === true; + const itemsLayout = section.itemsLayout === 'grid' ? 'grid' : 'row'; + const cardGaps = section.useGaplessCards === true ? 'none' : ''; + const borderColor = section.borderColor || ''; + const borderColorDefault = (borderColor && (/^#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/i.test(borderColor) || /^(rgb|hsl)a?\(/i.test(borderColor))) + ? borderColor + : '#e8dab2'; + const cardTitleColor = section.cardTitleColor || ''; + const cardTitleColorDefault = (cardTitleColor && (/^#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/i.test(cardTitleColor) || /^(rgb|hsl)a?\(/i.test(cardTitleColor))) + ? cardTitleColor + : '#ffffff'; + const cardTitlePosition = section.cardTitlePosition || 'default'; + + return ` + +
+ +
+ expand_more + More appearance options +
+ Borders, titles, layout +
+
+
+
+ ${buildSelectFromOptions('hsae-border-style', BORDER_STYLE_OPTIONS, section.borderStyle || '', 'Border Style')} +
Adds a border to the card images in this section.
+
+
+ +
+
+ +
+
CSS color for card borders.
+
+
+
+ ${buildToggleCard( + 'hsae-hide-card-titles', + hideCardTitles, + 'Hide Card Titles', + ADVANCED_TOGGLE_DESCRIPTIONS['hsae-hide-card-titles'][hideCardTitles ? 'on' : 'off'], + { hintKey: 'hsae-hide-card-titles' } + )} +
+
+
+ ${buildSelectFromOptions('hsae-card-title-position', CARD_TITLE_POSITION_OPTIONS, cardTitlePosition, 'Card Title Position')} + ${buildSelectFromOptions('hsae-card-title-capitalization', CARD_TITLE_CAPITALIZATION_OPTIONS, section.cardTitleCapitalization || 'normal', 'Card Title Capitalization')} +
+
+ ${buildSelectFromOptions('hsae-card-title-font-family', CARD_TITLE_FONT_FAMILY_OPTIONS, section.cardTitleFontFamily || 'default', 'Card Title Font Family')} + ${buildSelectFromOptions('hsae-card-title-font-size', CARD_TITLE_FONT_SIZE_OPTIONS, section.cardTitleFontSize || 'normal', 'Card Title Font Size')} +
+
+ ${showItemsLayout ? ` +
+
+ ${buildSelectFromOptions('hsae-items-layout', ITEMS_LAYOUT_OPTIONS, itemsLayout, 'Layout')} +
Default items layout for this section.
+
+
+ ${buildSelectFromOptions('hsae-card-gaps', CARD_GAPS_OPTIONS, cardGaps, 'Card Gaps')} +
Remove spacing between cards.
+
+
+ ` : ''} +
+
+ +
+
+ +
+
CSS color for card titles in this section.
+
+
+ ${buildTextInput('hsae-section-css-class', section.sectionCssClass || '', 'CSS Class')} +
Added to the section container when rendered.
+
+
+
+
+ `; + } + + function buildAppearanceHTML(state, context) { + const { CARD_FORMATS, buildCheckbox } = getApi(); + const renderMode = state.renderMode || 'Normal'; + const isNormal = renderMode === 'Normal'; + const isSpotlight = renderMode === 'Spotlight'; + + return ` +
+
+
Appearance
+
Choose how this section is rendered on the home screen
+
+
+
+ ${RENDER_MODE_CARDS.map(m => ` + + `).join('')} +
+ + ${isNormal ? ` +
+
+
Card Format
+
+ ${CARD_FORMATS.map(fmt => ` + + `).join('')} +
+
+
+
Preview
+
+

Loading preview…

+
+
+
+ ${buildCheckbox('hsae-live-preview-updates', state.livePreviewUpdates === true, 'Live Preview Updates')} +
+
+ ` : ''} + ${isSpotlight ? ` +
+
Spotlight options
+ ${buildSpotlightInnerHTML(state, context)} +
+ ` : ''} + ${buildAppearanceAdvancedHTML(state)} +
+
+ `; + } + + function buildAdvancedOptionsHTML(state) { + const { buildSelect, buildTextInput, buildToggleCard, SORT_ORDERS, SORT_ORDER_DIRECTIONS } = getApi(); + const section = state.section || {}; + const presets = getCachePresets(); + const ttlPreset = state.ttlPreset || 'DEFAULT'; + const isCustomTtl = ttlPreset === 'CUSTOM'; + const premiereAge = resolvePremiereAgeFields(section); + + return ` +
+ +
+ expand_more + Advanced Options +
+ For "real" server admins +
+
+
+
Cache Duration
+
+ ${presets.map(p => ` + + `).join('')} +
+
+ ${buildTextInput('hsae-ttl-custom-value', state.ttlCustomValue || '', 'Custom Duration', 'number')} + ${buildSelectFromOptions('hsae-ttl-custom-unit', TTL_UNIT_OPTIONS.map(u => ({ value: u.value, label: u.value })), state.ttlCustomUnit || 'minutes', 'Unit')} +
+
+ +
+
+ ${buildSelect('hsae-advanced-sortBy', toSortBySelectOptions(SORT_ORDERS), state.sortBy || '', 'Sort By')} +
Items will be sorted in this order after they are returned from Jellyfin.
+
+
+ ${buildSelect('hsae-advanced-sortOrder', getSectionSortOrderDirectionOptions(SORT_ORDER_DIRECTIONS), state.sortOrder || '', 'Sort Order')} +
Items will be sorted in this direction after they are returned from Jellyfin.
+
+
+ +
+
+ ${buildTextInput('hsae-query-minAge', premiereAge.minAge, 'Minimum Age (Days)', 'number')} +
Only show items that have a Premiere Date older than this.
+
+
+ ${buildTextInput('hsae-query-maxAge', premiereAge.maxAge, 'Maximum Age (Days)', 'number')} +
Only show items that have a Premiere Date newer than this.
+
+
+ +
+
+ ${buildTextInput('section-minimumItems', section.minimumItems ?? '', 'Minimum Items', 'number')} +
If the section has fewer items than this it will not be shown.
+
+
+ ${buildTextInput('hsae-section-itemLimit', state.itemLimit ?? '', 'Item Limit', 'number')} +
Limits results from queries after they are fetched.
+
+
+ +
+
+ ${buildToggleCard( + 'section-flattenSeries', + section.flattenSeries === true, + 'Flatten Series', + ADVANCED_TOGGLE_DESCRIPTIONS['section-flattenSeries'][section.flattenSeries === true ? 'on' : 'off'], + { hintKey: 'section-flattenSeries' } + )} +
+
+ ${buildToggleCard( + 'hsae-limit-before-sort', + section.limitBeforeSort === true, + 'Limit Before Sort', + ADVANCED_TOGGLE_DESCRIPTIONS['hsae-limit-before-sort'][section.limitBeforeSort === true ? 'on' : 'off'], + { hintKey: 'hsae-limit-before-sort' } + )} +
+
+
+ ${buildToggleCard( + 'hsae-use-parent-card', + section.useParentCard === true, + 'Link Parent Item', + ADVANCED_TOGGLE_DESCRIPTIONS['hsae-use-parent-card'][section.useParentCard === true ? 'on' : 'off'], + { hintKey: 'hsae-use-parent-card' } + )} +
+
+ +
Describe the section in more detail, useful when sharing Custom Sections.
+ +
+
+
+ `; + } + + function buildSelectFromOptions(id, options, selectedValue, label) { + const { buildSelect } = getApi(); + return buildSelect(id, options, selectedValue, label); + } + + function buildEditorBodyHTML(state, context) { + return ` + ${buildBasicPropertiesHTML(state, context)} + ${buildAppearanceHTML(state, context)} + ${buildItemSourceHTML(state)} + ${buildQueriesHTML(state)} + ${buildCustomItemsHTML(state)} + ${buildExternalListHTML(state)} + ${buildAdvancedOptionsHTML(state)} + `; + } + + function buildHTML(section, context = {}) { + const state = buildEditorStateFromSection(section, context); + const editorProfile = state.editorProfile || 'full'; + return ` +
+ + +
+ ${buildEditorBodyHTML(state, context)} +
+
+ `; + } + + function resolveTtlMs(state) { + const presets = getCachePresets(); + const presetKey = state.ttlPreset || 'DEFAULT'; + if (presetKey !== 'CUSTOM') { + const preset = presets.find(p => p.key === presetKey); + if (preset && preset.ms != null) return preset.ms; + } + const value = parseFloat(state.ttlCustomValue); + if (!value && value !== 0) return undefined; + const unit = TTL_UNIT_OPTIONS.find(u => u.value === (state.ttlCustomUnit || 'minutes')); + return Math.round(value * (unit?.ms || 60000)); + } + + function collectQueryFromEditor(editor, queryIndex, hideWatched, root, originalQuery) { + const profile = root?.dataset?.editorProfile || 'full'; + const limitEl = editor.querySelector(`#query-${queryIndex}-Limit`); + const sortByEl = editor.querySelector(`#query-${queryIndex}-SortBy`); + + if (profile !== 'full' && originalQuery) { + const query = JSON.parse(JSON.stringify(originalQuery)); + if (!query.queryOptions) query.queryOptions = {}; + if (isProfileFieldActive(root, limitEl)) { + const limit = limitEl?.value; + if (limit !== '' && limit != null) { + const limitN = parseInt(limit, 10); + if (Number.isFinite(limitN) && limitN >= 0) { + query.queryOptions.Limit = limitN; + } + } + } + if (isProfileFieldActive(root, sortByEl)) { + const sortBy = sortByEl?.value; + const sortOrder = editor.querySelector(`#query-${queryIndex}-SortOrder`)?.value; + if (sortBy) query.queryOptions.SortBy = sortBy; + else delete query.queryOptions.SortBy; + if (sortOrder) query.queryOptions.SortOrder = sortOrder; + else delete query.queryOptions.SortOrder; + } + if (hideWatched) query.queryOptions.IsUnplayed = true; + else if (query.queryOptions) delete query.queryOptions.IsUnplayed; + return query; + } + + const query = {}; + const nameValue = editor.querySelector(`#query-${queryIndex}-name`)?.value?.trim(); + if (nameValue) query.name = nameValue; + + const sourceType = editor.querySelector(`#query-${queryIndex}-sourceType`)?.value || 'jellyfin'; + + if (sourceType === 'static') { + query._sourceType = 'static'; + query.queryOptions = {}; + const staticItems = parseSelectionItems(editor.querySelector(`#query-${queryIndex}-staticItems`)?.value); + if (staticItems.length) { + query.queryOptions.Ids = staticItems.map(item => item.id); + } + ['SortBy', 'SortOrder'].forEach(key => { + const value = editor.querySelector(`#query-${queryIndex}-${key}`)?.value; + if (value) query.queryOptions[key] = value; + }); + return query; + } + + if (sourceType === 'cache') { + const dataSource = editor.querySelector(`#query-${queryIndex}-dataSource`)?.value + || CACHE_SOURCE_OPTIONS[0]?.value; + if (dataSource) query.dataSource = dataSource; + query.queryOptions = {}; + ['SortBy', 'SortOrder'].forEach(key => { + const value = editor.querySelector(`#query-${queryIndex}-${key}`)?.value; + if (value) query.queryOptions[key] = value; + }); + const limit = editor.querySelector(`#query-${queryIndex}-Limit`)?.value; + if (limit !== '' && limit != null) { + const limitN = parseInt(limit, 10); + if (Number.isFinite(limitN) && limitN >= 0) { + query.queryOptions.Limit = limitN; + } + } + const dataSourceOptions = {}; + collectCacheArgsIntoDataSourceOptions(editor, queryIndex, dataSource, dataSourceOptions); + if (Object.keys(dataSourceOptions).length) { + query.dataSourceOptions = dataSourceOptions; + } + return query; + } + + const endpoint = editor.querySelector(`#query-${queryIndex}-endpoint`)?.value || 'Items'; + const endpointMeta = ENDPOINT_OPTIONS.find(o => o.value === endpoint); + if (endpoint === 'Custom') { + const path = editor.querySelector(`#query-${queryIndex}-path`)?.value?.trim(); + if (path) query.path = path; + } else if (endpointMeta?.path) { + query.path = endpointMeta.path; + } + + query.queryOptions = {}; + + if (hideWatched) { + query.queryOptions.IsUnplayed = true; + } + + const includeTypesValue = editor.querySelector(`#query-${queryIndex}-IncludeItemTypes`)?.value; + if (includeTypesValue) { + query.queryOptions.IncludeItemTypes = includeTypesValue.split(',').map(s => s.trim()).filter(Boolean); + } + + const parentCard = Array.from(editor.querySelectorAll('.hsae-select-card[data-select-kind="parent"]')) + .find(card => parseSelectionItems(card.querySelector('.hsae-select-card-value')?.value).length > 0); + if (parentCard) { + const parentType = parentCard.dataset.selectType; + const parentItems = parseSelectionItems(parentCard.querySelector('.hsae-select-card-value')?.value); + if (parentItems.length && parentType) { + query.queryOptions.ParentId = parentItems[0].id; + query._parentItemType = parentType; + if (parentType === 'Library') { + query.queryOptions.Recursive = false; + } + } + } + + FACET_SELECTION_CARD_TYPES.forEach(facet => { + const card = editor.querySelector(`.hsae-select-card[data-select-kind="facet"][data-select-type="${facet.type}"]`); + if (!card) return; + const items = parseSelectionItems(card.querySelector('.hsae-select-card-value')?.value); + if (!items.length) return; + if (facet.type === 'Genre') { + query.queryOptions.GenreIds = items.map(i => i.id); + } else if (facet.type === 'Tag') { + query.queryOptions.Tags = items.map(i => i.name || i.id); + } else if (facet.type === 'Person') { + query.queryOptions.PersonIds = items.map(i => i.id); + } else if (facet.type === 'Studio') { + query.queryOptions.StudioIds = items.map(i => i.id); + } + }); + + delete query.queryOptions.Genres; + delete query.queryOptions.CollectionIds; + delete query.queryOptions.PlaylistIds; + delete query.queryOptions._parentItemType; + + ['SortBy', 'SortOrder', 'Filters', 'SearchTerm'].forEach(key => { + const value = editor.querySelector(`#query-${queryIndex}-${key}`)?.value; + if (value) query.queryOptions[key] = value; + }); + + const limit = editor.querySelector(`#query-${queryIndex}-Limit`)?.value; + if (limit !== '' && limit != null) { + const limitN = parseInt(limit, 10); + if (Number.isFinite(limitN) && limitN >= 0) { + query.queryOptions.Limit = limitN; + } + } + + const additionalOptionsList = editor.querySelector(`.additional-options-list[data-query-index="${queryIndex}"]`); + if (additionalOptionsList) { + additionalOptionsList.querySelectorAll('.additional-option-row').forEach(row => { + const valueInput = row.querySelector('.additional-option-value'); + if (!valueInput) return; + const customKeyInput = row.querySelector('.hsae-custom-option-key'); + const key = (customKeyInput?.value?.trim()) || valueInput.dataset.key; + const type = valueInput.dataset.type || 'string'; + if (!key) return; + + let value; + if (type === 'boolean') value = valueInput.checked === true; + else if (type === 'number') value = valueInput.value ? parseFloat(valueInput.value) : undefined; + else if (type === 'array') value = valueInput.value ? valueInput.value.split(',').map(s => s.trim()).filter(Boolean) : []; + else value = valueInput.value || undefined; + + if (value !== undefined && value !== null && value !== '') { + query.queryOptions[key] = value; + } + }); + } + + if (!hideWatched) delete query.queryOptions.IsUnplayed; + + delete query._sourceType; + + return query; + } + + function collectData(dialog, originalSection) { + if (!dialog) return null; + + const root = dialog.querySelector('.hsae-root') || dialog; + const section = originalSection ? JSON.parse(JSON.stringify(originalSection)) : {}; + const userEnabledChecked = dialog.querySelector('#hsae-user-enabled-by-default')?.checked === true; + const userConfigChecked = dialog.querySelector('#hsae-user-configurable')?.checked === true; + + section.name = dialog.querySelector('#section-name')?.value || ''; + const captionVal = dialog.querySelector('#section-caption')?.value?.trim() || ''; + if (captionVal) section.caption = captionVal; + else section.caption = ''; + const descriptionVal = dialog.querySelector('#section-description')?.value?.trim() || ''; + if (descriptionVal) section.description = descriptionVal; + else section.description = ''; + section.enabled = userEnabledChecked; + section.userConfigurable = userConfigChecked; + + const hideNameChecked = dialog.querySelector('#hsae-hide-section-name')?.checked === true; + if (hideNameChecked) section.hideName = true; + else section.hideName = ''; + + const activeRenderMode = dialog.querySelector('.hsae-appearance-card.hsae-active[data-render-mode]'); + section.renderMode = activeRenderMode?.dataset.renderMode + || dialog.querySelector('#hsae-render-mode-value')?.value + || 'Normal'; + + const editorState = { + sectionVisibility: dialog.querySelector('#hsae-section-visibility')?.value || 'normal', + order: dialog.querySelector('#section-order')?.value, + startDate: dialog.querySelector('#section-startDate')?.value || '', + endDate: dialog.querySelector('#section-endDate')?.value || '' + }; + applyVisibilityToSection(section, editorState); + + if (section.renderMode !== 'Spotlight') { + const activeCardFormat = dialog.querySelector('.hsae-card-format-btn.hsae-active[data-card-format]'); + section.cardFormat = activeCardFormat?.dataset.cardFormat + || dialog.querySelector('#hsae-card-format-value')?.value + || 'Poster'; + delete section.spotlightConfig; + } else { + const { collectSpotlightConfigFromDialog } = getApi(); + if (typeof collectSpotlightConfigFromDialog === 'function') { + section.spotlightConfig = collectSpotlightConfigFromDialog(dialog, 'section-spotlight-', { useIntervalSeconds: true }); + } else { + section.spotlightConfig = section.spotlightConfig || {}; + } + delete section.cardFormat; + } + + const itemSourceSection = dialog.querySelector('.hsae-item-source'); + const itemSource = isProfileFieldActive(root, itemSourceSection) + ? (dialog.querySelector('.hsae-source-card.hsae-active[data-item-source]')?.dataset.itemSource || inferItemSource(section)) + : inferItemSource(originalSection || section); + const hideWatched = dialog.querySelector('#hsae-hide-watched')?.checked === true; + + if (itemSource === 'custom') { + const customEditor = getCustomItemsEditor(); + const liveState = getLiveEditorState(dialog); + const customItemsGroup = dialog.querySelector('.hsae-custom-items-group'); + if (isProfileFieldActive(root, customItemsGroup) && customEditor && liveState?.customItems) { + customEditor.collectCustomItemsFromForm(dialog, liveState, getAdvancedCustomItemsOptions(liveState)); + section.items = liveState.customItems.map(customEditor.buildStaticItemFromDraft).filter(Boolean); + } else if (!Array.isArray(section.items)) { + section.items = []; + } + section.queries = []; + delete section.externalListUrls; + } else if (itemSource === 'external') { + delete section.items; + section.queries = []; + const urlInputs = dialog.querySelectorAll('[id^="hsae-external-list-url-"]'); + const urls = Array.from(urlInputs).map((el) => (el.value || '').trim()).filter(Boolean); + section.externalListUrls = urls; + } else { + delete section.items; + delete section.externalListUrls; + const queryEditors = dialog.querySelectorAll('.query-editor'); + if (queryEditors.length > 0) { + section.queries = Array.from(queryEditors).map(editor => { + const queryIndex = parseInt(editor.dataset.queryIndex, 10); + const originalQuery = originalSection?.queries?.[queryIndex]; + return collectQueryFromEditor(editor, queryIndex, hideWatched, root, originalQuery); + }); + if (section.queries.length > 1) { + section.useRandomQuery = dialog.querySelector('#hsae-use-random-query')?.checked === true; + section.useMultiQueryPicker = dialog.querySelector('#hsae-use-multi-query-picker')?.checked === true; + + const pickerLabel = dialog.querySelector('#hsae-multi-query-picker-label')?.value?.trim(); + section.multiQueryPickerLabel = pickerLabel || ''; + section.useQueryNamesForSection = + dialog.querySelector('#hsae-use-query-names-for-section')?.checked === true; + } else { + delete section.useRandomQuery; + delete section.useMultiQueryPicker; + delete section.multiQueryPickerLabel; + delete section.useQueryNamesForSection; + } + } + // No query editors: keep cloned original queries (e.g. templates without query UI) + } + + collectAdvancedOptionsFields(dialog, section, root); + + const cssClass = dialog.querySelector('#hsae-section-css-class')?.value?.trim(); + if (cssClass) section.sectionCssClass = cssClass; + else section.sectionCssClass = ''; + + const borderStyle = dialog.querySelector('#hsae-border-style')?.value?.trim(); + if (borderStyle) section.borderStyle = borderStyle; + else section.borderStyle = ''; + + const borderColor = dialog.querySelector('#hsae-border-color-text')?.value?.trim(); + if (borderColor) section.borderColor = borderColor; + else section.borderColor = ''; + + const cardTitleColor = dialog.querySelector('#hsae-card-title-color-text')?.value?.trim(); + if (cardTitleColor) section.cardTitleColor = cardTitleColor; + else section.cardTitleColor = ''; + + const itemsLayout = dialog.querySelector('#hsae-items-layout')?.value; + if (itemsLayout === 'grid') section.itemsLayout = itemsLayout; + else section.itemsLayout = ''; + const cardGaps = dialog.querySelector('#hsae-card-gaps')?.value; + if (cardGaps === 'none') section.useGaplessCards = true; + else section.useGaplessCards = false; + + const hideCardTitles = dialog.querySelector('#hsae-hide-card-titles')?.checked === true; + if (hideCardTitles) section.cardTitleVisibility = 'hidden'; + else section.cardTitleVisibility = ''; + section.hideCardTitles = ''; + + section.useParentCard = dialog.querySelector('#hsae-use-parent-card')?.checked === true; + + const cardTitlePosition = dialog.querySelector('#hsae-card-title-position')?.value; + if (cardTitlePosition && cardTitlePosition !== 'default') section.cardTitlePosition = cardTitlePosition; + else section.cardTitlePosition = ''; + + const cardTitleCapitalization = dialog.querySelector('#hsae-card-title-capitalization')?.value; + if (cardTitleCapitalization && cardTitleCapitalization !== 'normal') section.cardTitleCapitalization = cardTitleCapitalization; + else section.cardTitleCapitalization = ''; + + const cardTitleFontFamily = dialog.querySelector('#hsae-card-title-font-family')?.value; + if (cardTitleFontFamily && cardTitleFontFamily !== 'default') section.cardTitleFontFamily = cardTitleFontFamily; + else section.cardTitleFontFamily = ''; + + const cardTitleFontSize = dialog.querySelector('#hsae-card-title-font-size')?.value; + if (cardTitleFontSize && cardTitleFontSize !== 'normal') section.cardTitleFontSize = cardTitleFontSize; + else section.cardTitleFontSize = ''; + + const targetGroupName = dialog.querySelector('select#section-group')?.value; + if (targetGroupName) { + section._targetGroupName = targetGroupName === 'New...' + ? (dialog.querySelector('#section-group-new')?.value || '').trim() || 'Custom Sections' + : targetGroupName; + } + + const categoryEl = dialog.querySelector('#section-category'); + if (categoryEl) { + const isDiscovery = dialog.querySelector('#hsae-section-visibility')?.value === 'discovery' + || section.discoveryEnabled === true + || section.type === 'discovery'; + section.category = isDiscovery ? 'discovery' : (categoryEl.value || 'none'); + } + + return section; + } + + async function hydrateStaticItemsLabels(modalInstance, queryIndex) { + const { resolveSelectionItemLabels } = getApi(); + if (!resolveSelectionItemLabels || !modalInstance) return; + + const root = modalInstance.dialogContent; + const editor = root.querySelector(`.query-editor[data-query-index="${queryIndex}"]`); + if (!editor) return; + + const sourceType = editor.querySelector(`#query-${queryIndex}-sourceType`)?.value + || inferQuerySourceType(modalInstance._hsaeState?.queries?.[queryIndex]); + if (sourceType !== 'static') return; + + const items = parseSelectionItems(editor.querySelector(`#query-${queryIndex}-staticItems`)?.value); + if (!items.some(selectionItemNeedsLabel)) return; + + const resolved = await resolveSelectionItemLabels('LibraryItem', items); + setStaticItemsInEditor(editor, queryIndex, resolved); + } + + async function hydrateQuerySelectCardLabels(modalInstance, queryIndex) { + const { resolveSelectionItemLabels } = getApi(); + if (!resolveSelectionItemLabels || !modalInstance) return; + + const root = modalInstance.dialogContent; + const editor = root.querySelector(`.query-editor[data-query-index="${queryIndex}"]`); + if (!editor) return; + + const includeTypesValue = editor.querySelector(`#query-${queryIndex}-IncludeItemTypes`)?.value; + const includeItemTypes = includeTypesValue + ? includeTypesValue.split(',').map(s => s.trim()).filter(Boolean) + : []; + + const cards = editor.querySelectorAll('.hsae-select-card'); + for (const card of cards) { + const type = card.dataset.selectType; + const items = parseSelectionItems(card.querySelector('.hsae-select-card-value')?.value); + if (!items.some(selectionItemNeedsLabel)) continue; + + const resolved = await resolveSelectionItemLabels(type, items, { includeItemTypes }); + setSelectCardItems(card, resolved); + } + } + + function selectionItemNeedsLabel(item) { + return !!(item?.id && (!item.name || item.name === item.id)); + } + + async function setupQueryEditors(modalInstance, state) { + const { setupAdditionalOptionsListeners } = getApi(); + const queries = state.queries?.length ? state.queries : [createDefaultQuery()]; + const root = modalInstance.dialogContent; + + for (let queryIndex = 0; queryIndex < queries.length; queryIndex++) { + setupAdditionalOptionsListeners?.(modalInstance, queryIndex); + attachQueryCardListeners(root, modalInstance, queryIndex); + await hydrateQuerySelectCardLabels(modalInstance, queryIndex); + await hydrateStaticItemsLabels(modalInstance, queryIndex); + } + } + + function attachQueryCardListeners(root, modalInstance, queryIndex) { + const editor = root.querySelector(`.query-editor[data-query-index="${queryIndex}"]`); + if (!editor) return; + + attachLimitFieldSafeguard(editor, queryIndex); + + const sourceSelect = editor.querySelector(`#query-${queryIndex}-sourceType`); + const endpointSelect = editor.querySelector(`#query-${queryIndex}-endpoint`); + const dataSourceSelect = editor.querySelector(`#query-${queryIndex}-dataSource`); + const pathContainer = editor.querySelector(`#query-${queryIndex}-path-container`); + const endpointWrap = editor.querySelector('.hsae-jellyfin-endpoint-wrap'); + const cacheWrap = editor.querySelector('.hsae-cache-wrap'); + const cacheArgsWrap = editor.querySelector('.hsae-cache-args-wrap'); + const jellyfinOnlyBlocks = editor.querySelectorAll('.hsae-query-jellyfin-only'); + const staticOnlyBlocks = editor.querySelectorAll('.hsae-query-static-only'); + const limitBlock = editor.querySelector('.hsae-query-limit'); + const searchBlock = editor.querySelector('.hsae-query-search'); + + const updateSourceVisibility = () => { + const sourceType = sourceSelect?.value || 'jellyfin'; + if (endpointWrap) endpointWrap.style.display = sourceType === 'jellyfin' || sourceType === 'static' ? 'block' : 'none'; + if (endpointSelect) { + endpointSelect.disabled = sourceType === 'static'; + if (sourceType === 'static') endpointSelect.value = 'Items'; + } + if (cacheWrap) cacheWrap.style.display = sourceType === 'cache' ? 'block' : 'none'; + if (cacheArgsWrap) cacheArgsWrap.style.display = sourceType === 'cache' ? 'block' : 'none'; + jellyfinOnlyBlocks.forEach(block => { + block.style.display = sourceType === 'jellyfin' ? '' : 'none'; + }); + staticOnlyBlocks.forEach(block => { + block.style.display = sourceType === 'static' ? '' : 'none'; + }); + if (limitBlock) { + limitBlock.style.display = sourceType === 'jellyfin' || sourceType === 'static' || sourceType === 'cache' + ? '' + : 'none'; + } + if (searchBlock) { + searchBlock.style.display = sourceType === 'jellyfin' || sourceType === 'static' ? '' : 'none'; + } + }; + + const updateEndpointVisibility = () => { + const endpoint = endpointSelect?.value || 'Items'; + if (pathContainer) pathContainer.style.display = endpoint === 'Custom' ? 'block' : 'none'; + }; + + sourceSelect?.addEventListener('change', () => { + refreshEditorBody(modalInstance); + }); + dataSourceSelect?.addEventListener('change', () => { + refreshEditorBody(modalInstance); + }); + endpointSelect?.addEventListener('change', updateEndpointVisibility); + + updateSourceVisibility(); + updateEndpointVisibility(); + } + + function openSelectionForCard(card, modalInstance) { + const { openSelectionPopover } = getApi(); + if (!openSelectionPopover || !card) return; + + const kind = card.dataset.selectKind; + const type = card.dataset.selectType; + const queryIndex = card.dataset.queryIndex; + const multi = kind === 'facet'; + const selected = parseSelectionItems(card.querySelector('.hsae-select-card-value')?.value); + const editor = modalInstance.dialogContent.querySelector(`.query-editor[data-query-index="${queryIndex}"]`); + const includeTypesValue = editor?.querySelector(`#query-${queryIndex}-IncludeItemTypes`)?.value; + const includeItemTypes = includeTypesValue + ? includeTypesValue.split(',').map(s => s.trim()).filter(Boolean) + : []; + + openSelectionPopover({ + type, + multi, + selected, + includeItemTypes, + onConfirm: (items) => { + const nextItems = multi ? items : (items.length ? [items[0]] : []); + if (kind === 'parent' && nextItems.length) { + clearSiblingParentCards(editor, type); + } + setSelectCardItems(card, nextItems); + } + }); + } + + function attachChipRowListeners(root, row, hiddenInputId) { + const hiddenInput = hiddenInputId ? root.querySelector(`#${hiddenInputId}`) : null; + row.querySelectorAll('.hsae-chip').forEach(chip => { + chip.addEventListener('click', () => { + chip.classList.toggle('hsae-active'); + if (hiddenInput) { + const values = Array.from(row.querySelectorAll('.hsae-chip.hsae-active')).map(c => c.dataset.chipValue); + hiddenInput.value = values.join(','); + if (hiddenInputId.endsWith('-IncludeItemTypes')) { + updateIncludeItemTypesHint(hiddenInput.closest('.hsae-include-types')); + } + } + }); + }); + } + + function attachSpotlightListeners(root) { + const { attachSpotlightSettingsListeners } = getApi(); + const container = root.querySelector('#section-spotlight-options-container'); + if (!container) return; + + attachSpotlightSettingsListeners(container, 'section-spotlight-'); + + const panAnimation = root.querySelector('#section-spotlight-panAnimation'); + const animDropdowns = root.querySelector('#section-spotlight-animation-dropdowns'); + const animDivider = root.querySelector('#section-spotlight-animation-divider'); + const tileCountSelect = root.querySelector('#section-spotlight-tileCount'); + const animSecond = root.querySelector('#section-spotlight-anim-second'); + const animThird = root.querySelector('#section-spotlight-anim-third'); + const cycleBackdrops = root.querySelector('#section-spotlight-cycleBackdrops'); + const backdropsRow = root.querySelector('#section-spotlight-backdrops-row'); + const backdropsCountInput = root.querySelector('#section-spotlight-backdropsCount'); + + const updateAnimateVisibility = () => { + const checkbox = root.querySelector('#section-spotlight-panAnimation'); + const enabled = checkbox?.checked === true; + if (animDropdowns) animDropdowns.style.display = enabled ? 'grid' : 'none'; + if (animDivider) animDivider.style.display = enabled ? 'block' : 'none'; + }; + + const updateTileAnimVisibility = () => { + const tc = parseInt(tileCountSelect?.value || '1', 10) || 1; + if (animSecond) animSecond.style.display = tc >= 2 ? 'block' : 'none'; + if (animThird) animThird.style.display = tc >= 3 ? 'block' : 'none'; + if (animDropdowns) { + animDropdowns.classList.remove('hsae-spotlight-animations--1', 'hsae-spotlight-animations--2', 'hsae-spotlight-animations--3'); + animDropdowns.classList.add(`hsae-spotlight-animations--${tc}`); + } + }; + + const updateCycleBackdropsRow = () => { + const on = cycleBackdrops?.checked === true; + if (backdropsRow) backdropsRow.style.display = on ? 'block' : 'none'; + if (on && backdropsCountInput) { + const val = parseInt(backdropsCountInput.value, 10); + if (!val || val < 2) backdropsCountInput.value = '2'; + backdropsCountInput.min = '2'; + } + }; + + panAnimation?.addEventListener('change', updateAnimateVisibility); + tileCountSelect?.addEventListener('change', updateTileAnimVisibility); + cycleBackdrops?.addEventListener('change', updateCycleBackdropsRow); + backdropsCountInput?.addEventListener('change', () => { + if (cycleBackdrops?.checked === true) { + const val = parseInt(backdropsCountInput.value, 10); + if (!val || val < 2) backdropsCountInput.value = '2'; + } + }); + updateAnimateVisibility(); + updateTileAnimVisibility(); + updateCycleBackdropsRow(); + } + + function attachAdditionalOptionCustomHandler(modalInstance, queryIndex) { + const dialog = modalInstance.dialogContainer || modalInstance.dialogContent; + const controls = dialog.querySelector(`.query-${queryIndex}-additional-options-controls[data-query-index="${queryIndex}"]`); + const listContainer = dialog.querySelector(`.query-${queryIndex}-additional-options-list-container[data-query-index="${queryIndex}"]`); + if (!controls || !listContainer) return; + + const addBtn = controls.querySelector('.hsae-add-additional-option'); + const select = controls.querySelector('.additional-option-select'); + const list = listContainer.querySelector('.additional-options-list'); + const { SUPPORTED_QUERY_OPTIONS } = getApi(); + + if (!addBtn || !select || !list) return; + + addBtn.addEventListener('click', () => { + const key = select.value; + if (!key) return; + + const index = list.children.length; + const inputId = `query-${queryIndex}-additionalOption-${Date.now()}_${index}`; + let inputField = ''; + let rowType = 'field'; + + if (key === '__custom__') { + rowType = 'custom'; + inputField = ` +
+ + +
+ `; + } else { + const meta = SUPPORTED_QUERY_OPTIONS[key]; + if (!meta) return; + rowType = meta.type === 'boolean' ? 'boolean' : 'field'; + if (meta.type === 'boolean') { + inputField = buildAdditionalBooleanToggleHTML( + inputId, + key, + meta.label, + meta.default === true + ); + } else { + inputField = ` +
+
${escapeHtml(meta.label)}
+ +
+ `; + } + } + + const row = document.createElement('div'); + row.className = getAdditionalOptionRowClass(rowType); + row.innerHTML = ` + ${inputField} + + `; + + if (key === '__custom__') { + const valueInput = row.querySelector('.hsae-custom-option-value'); + const keyInput = row.querySelector('.hsae-custom-option-key'); + keyInput?.addEventListener('input', () => { + if (valueInput) valueInput.dataset.key = keyInput.value.trim(); + }); + } + + row.querySelector('.hsae-remove-additional-option')?.addEventListener('click', () => row.remove()); + list.appendChild(row); + select.value = ''; + }); + + listContainer.querySelectorAll('.hsae-remove-additional-option').forEach(btn => { + btn.addEventListener('click', (e) => { + e.target.closest('.additional-option-row')?.remove(); + }); + }); + } + + function updateMultiQuerySettingsUI(root) { + if (!root) return; + + const randomQueryEl = root.querySelector('#hsae-use-random-query'); + const pickerEl = root.querySelector('#hsae-use-multi-query-picker'); + const useNamesEl = root.querySelector('#hsae-use-query-names-for-section'); + + const sortRow = root.querySelector('#section-query-sort-row'); + if (sortRow && randomQueryEl) { + sortRow.style.display = randomQueryEl.checked ? 'none' : 'grid'; + } + + const pickerRows = root.querySelector('#hsae-multi-query-picker-rows'); + if (pickerRows && pickerEl) { + pickerRows.style.display = pickerEl.checked ? 'block' : 'none'; + } + + const randomHint = root.querySelector('[data-hsae-hint="useRandomQuery"]'); + if (randomHint && randomQueryEl) { + randomHint.textContent = RANDOM_QUERY_TOGGLE_DESCRIPTIONS[randomQueryEl.checked ? 'on' : 'off']; + } + + const pickerHint = root.querySelector('[data-hsae-hint="useMultiQueryPicker"]'); + if (pickerHint && pickerEl) { + pickerHint.textContent = QUERY_PICKER_TOGGLE_DESCRIPTIONS[pickerEl.checked ? 'on' : 'off']; + } + + const useNamesHint = root.querySelector('[data-hsae-hint="useQueryNamesForSection"]'); + if (useNamesHint && useNamesEl) { + useNamesHint.textContent = USE_QUERY_NAMES_TOGGLE_DESCRIPTIONS[useNamesEl.checked ? 'on' : 'off']; + } + } + + function updateToggleCardHints(root) { + const toggles = [ + { key: 'hideName', checkboxId: 'hsae-hide-section-name', tooltips: HIDE_NAME_TOGGLE_DESCRIPTIONS }, + { key: 'hideWatched', checkboxId: 'hsae-hide-watched', tooltips: HIDE_WATCHED_TOGGLE_DESCRIPTIONS }, + { key: 'userEnabledByDefault', checkboxId: 'hsae-user-enabled-by-default', tooltips: USER_TOGGLE_TOOLTIPS.userEnabledByDefault }, + { key: 'userConfigurable', checkboxId: 'hsae-user-configurable', tooltips: USER_TOGGLE_TOOLTIPS.userConfigurable }, + { key: 'hsae-hide-card-titles', checkboxId: 'hsae-hide-card-titles', tooltips: ADVANCED_TOGGLE_DESCRIPTIONS['hsae-hide-card-titles'] }, + { key: 'hsae-use-parent-card', checkboxId: 'hsae-use-parent-card', tooltips: ADVANCED_TOGGLE_DESCRIPTIONS['hsae-use-parent-card'] }, + { key: 'section-flattenSeries', checkboxId: 'section-flattenSeries', tooltips: ADVANCED_TOGGLE_DESCRIPTIONS['section-flattenSeries'] }, + { key: 'hsae-limit-before-sort', checkboxId: 'hsae-limit-before-sort', tooltips: ADVANCED_TOGGLE_DESCRIPTIONS['hsae-limit-before-sort'] } + ]; + + toggles.forEach(({ key, checkboxId, tooltips }) => { + const checkbox = root.querySelector(`#${checkboxId}`); + const hint = root.querySelector(`[data-hsae-hint="${key}"]`); + if (!checkbox || !hint || !tooltips) return; + const enabled = checkbox.checked === true; + hint.textContent = tooltips[enabled ? 'on' : 'off']; + }); + + updateMultiQuerySettingsUI(root); + } + + function updateSectionNameBadge(modalInstance) { + const badge = modalInstance?.dialogHeader?.querySelector('.hsae-section-name-badge'); + const nameInput = modalInstance?.dialogContent?.querySelector('#section-name'); + if (badge && nameInput) { + badge.textContent = nameInput.value.trim() || 'Untitled'; + } + } + + function getHsaePickrAppEl(pickr) { + return pickr?._root?.app || null; + } + + function destroyHsaeColorPickr(modalInstance, storageKey) { + const pickr = modalInstance?.[storageKey]; + if (pickr) { + try { + // Prefer destroy() — destroyAndRemove() can delete useAsButton trigger nodes. + if (typeof pickr.destroy === 'function') pickr.destroy(); + else if (typeof pickr.destroyAndRemove === 'function') pickr.destroyAndRemove(); + } catch (_) { /* ignore */ } + modalInstance[storageKey] = null; + } + // Remove orphaned apps left on document.body after incomplete destroy / body refresh, + // but keep any app still owned by the other appearance pickr. + const keep = new Set(); + for (const key of ['_hsaeBorderPickr', '_hsaeCardTitleColorPickr']) { + const app = getHsaePickrAppEl(modalInstance?.[key]); + if (app) keep.add(app); + } + document.querySelectorAll('.pcr-app.kefin-pickr-app').forEach((el) => { + if (keep.has(el)) return; + try { el.remove(); } catch (__) { /* ignore */ } + }); + } + + function destroyHsaeAppearancePickrs(modalInstance) { + destroyHsaeColorPickr(modalInstance, '_hsaeBorderPickr'); + destroyHsaeColorPickr(modalInstance, '_hsaeCardTitleColorPickr'); + } + + /** + * Lazy-wire a color Pickr: create on first host click, destroy when the picker hides + * so .pcr-app is not left on document.body. Host is a permanent color swatch. + */ + function attachHsaeColorPickr(modalInstance, { hostSelector, textSelector, storageKey, fallbackColor }) { + destroyHsaeColorPickr(modalInstance, storageKey); + const root = modalInstance?.dialogContent; + if (!root || typeof window.Pickr?.create !== 'function') return; + + const host = root.querySelector(hostSelector); + const colorText = root.querySelector(textSelector); + if (!host) return; + + const otherKey = storageKey === '_hsaeBorderPickr' + ? '_hsaeCardTitleColorPickr' + : '_hsaeBorderPickr'; + + const resolveSwatchColor = () => { + const typed = colorText?.value?.trim(); + if (typed && (/^#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/i.test(typed) || /^(rgb|hsl)a?\(/i.test(typed))) { + return typed; + } + return colorText?.dataset.pickrDefault || fallbackColor; + }; + + const setSwatchColor = (css) => { + host.style.setProperty('--hsae-swatch-color', css || fallbackColor); + }; + + setSwatchColor(resolveSwatchColor()); + + const toCss = (color) => { + if (!color) return ''; + try { + return color.toHEXA().toString(); + } catch (_) { + try { + return color.toRGBA().toString(); + } catch (__) { + return ''; + } + } + }; + + const ensurePickr = () => { + if (modalInstance[storageKey]) return modalInstance[storageKey]; + if (!host.isConnected) return null; + + // Only one appearance pickr open at a time. + destroyHsaeColorPickr(modalInstance, otherKey); + + const defaultColor = resolveSwatchColor(); + + let pickr; + try { + pickr = window.Pickr.create({ + el: host, + theme: 'nano', + default: defaultColor, + defaultRepresentation: 'HEXA', + useAsButton: true, + appClass: 'kefin-pickr-app', + components: { + preview: true, + opacity: true, + hue: true, + interaction: { + hex: true, + rgba: true, + input: true, + save: true + } + } + }); + } catch (_) { + return null; + } + + pickr.on('change', (color) => { + const css = toCss(color); + if (css) { + if (colorText) colorText.value = css; + setSwatchColor(css); + } + }); + pickr.on('save', (color) => { + const css = toCss(color); + if (css) { + if (colorText) colorText.value = css; + setSwatchColor(css); + } + pickr.hide(); + }); + // Destroy after hide so .pcr-app is removed; next open recreates. + pickr.on('hide', () => { + requestAnimationFrame(() => { + if (modalInstance[storageKey] === pickr) { + destroyHsaeColorPickr(modalInstance, storageKey); + } + }); + }); + + modalInstance[storageKey] = pickr; + return pickr; + }; + + host.addEventListener('click', (ev) => { + ev.stopPropagation(); + if (modalInstance[storageKey]) { + // Instance open; Pickr useAsButton toggles show/hide. + return; + } + const pickr = ensurePickr(); + // Same-event listeners added by Pickr won't run; open on next frame. + requestAnimationFrame(() => pickr?.show()); + }); + } + + function attachHsaeBorderColorPickr(modalInstance) { + attachHsaeColorPickr(modalInstance, { + hostSelector: '#hsae-border-color-pickr', + textSelector: '#hsae-border-color-text', + storageKey: '_hsaeBorderPickr', + fallbackColor: '#e8dab2' + }); + } + + function attachHsaeCardTitleColorPickr(modalInstance) { + attachHsaeColorPickr(modalInstance, { + hostSelector: '#hsae-card-title-color-pickr', + textSelector: '#hsae-card-title-color-text', + storageKey: '_hsaeCardTitleColorPickr', + fallbackColor: '#ffffff' + }); + } + + function syncHsaeColorPickrFromText(modalInstance, storageKey, textInput, hostSelector, fallbackColor) { + const v = textInput?.value?.trim(); + const root = modalInstance?.dialogContent; + const host = hostSelector ? root?.querySelector(hostSelector) : null; + const isValid = v && (/^#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/i.test(v) || /^(rgb|hsl)a?\(/i.test(v)); + if (host) { + const css = isValid ? v : (textInput?.dataset?.pickrDefault || fallbackColor); + host.style.setProperty('--hsae-swatch-color', css || fallbackColor); + } + const pickr = modalInstance?.[storageKey]; + if (!pickr || !isValid) return; + try { pickr.setColor(v, true); } catch (_) { /* ignore invalid for Pickr */ } + } + + function refreshEditorBody(modalInstance, options = {}) { + const state = modalInstance._hsaeState; + const context = state?.context || {}; + if (!state) return; + + const root = modalInstance.dialogContent; + const body = root.querySelector('.hsae-editor-body'); + if (!body) return; + + const scrollTop = body.scrollTop; + if (!options.skipCollect) { + collectStateFromForm(root, state); + } + + destroyHsaeAppearancePickrs(modalInstance); + body.innerHTML = buildEditorBodyHTML(state, context); + + const hsaeRoot = root.classList?.contains('hsae-root') ? root : root.querySelector('.hsae-root'); + if (hsaeRoot) { + hsaeRoot.dataset.itemSource = state.itemSource || 'jellyfin'; + } + + const renderModeInput = root.querySelector('#hsae-render-mode-value'); + const cardFormatInput = root.querySelector('#hsae-card-format-value'); + if (renderModeInput) renderModeInput.value = state.renderMode || 'Normal'; + if (cardFormatInput) cardFormatInput.value = state.cardFormat || 'Poster'; + + attachBodyListeners(modalInstance, context).then(() => { + body.scrollTop = scrollTop; + }); + updateSectionNameBadge(modalInstance); + syncHeaderActions(modalInstance); + } + + function handleHsaeRootClick(e, modalInstance) { + const root = modalInstance.dialogContent; + const state = modalInstance._hsaeState; + if (!root || !state) return; + + const toggleBtn = e.target.closest('.toggle-slider, .kefin-toggle-switch'); + if (toggleBtn && root.contains(toggleBtn)) { + // Already wired by bindToggleCards — avoid double-flip (Spotlight toggles looked stuck) + if (toggleBtn.classList.contains('kefin-toggle-switch') && toggleBtn.dataset.kefinToggleBound === 'true') { + return; + } + const checkboxId = toggleBtn.dataset.checkboxId; + if (!checkboxId) return; + e.preventDefault(); + e.stopPropagation(); + const checkbox = document.getElementById(checkboxId); + if (checkbox && root.contains(checkbox)) { + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + if (toggleBtn.classList.contains('kefin-toggle-switch')) { + const { updateToggleSwitchUI } = getApi(); + if (typeof updateToggleSwitchUI === 'function') { + updateToggleSwitchUI(toggleBtn, checkbox.checked); + } + } else { + const { updateToggleSliderUI } = getApi(); + if (typeof updateToggleSliderUI === 'function') { + updateToggleSliderUI(toggleBtn, checkbox.checked); + } + } + } + return; + } + + const actionEl = e.target.closest('[data-hsae-action]'); + if (!actionEl || !root.contains(actionEl)) return; + + const action = actionEl.getAttribute('data-hsae-action'); + + if (action?.startsWith('hsae-')) { + const customEditor = getCustomItemsEditor(); + if (customEditor && state.itemSource === 'custom') { + customEditor.handleAction(action, actionEl, root, state, getAdvancedCustomItemsOptions(state), () => refreshEditorBody(modalInstance)); + } + return; + } + + e.preventDefault(); + + if (action === 'open-selection') { + const card = actionEl.closest('.hsae-select-card'); + openSelectionForCard(card, modalInstance); + return; + } + + if (action === 'toggle-include-types-all') { + const queryIndex = actionEl.dataset.queryIndex; + const container = root.querySelector(`.hsae-include-types[data-query-index="${queryIndex}"]`); + if (!container) return; + const showAll = container.dataset.showAll !== 'true'; + container.dataset.showAll = showAll ? 'true' : 'false'; + container.querySelectorAll('.hsae-include-type-extra').forEach(chip => { + chip.hidden = !showAll; + }); + actionEl.textContent = showAll ? 'Show less' : 'Show all'; + return; + } + + if (action === 'remove-select-chip') { + e.stopPropagation(); + const chip = actionEl.closest('.hsae-select-chip'); + const card = actionEl.closest('.hsae-select-card'); + if (!chip || !card) return; + const value = chip.dataset.value; + const items = parseSelectionItems(card.querySelector('.hsae-select-card-value')?.value) + .filter(item => item.id !== value); + setSelectCardItems(card, items); + return; + } + + if (action === 'add-static-item') { + const queryIndex = actionEl.dataset.queryIndex; + const editor = root.querySelector(`.query-editor[data-query-index="${queryIndex}"]`); + if (!editor) return; + const { openLibraryItemPickerPopover } = getApi(); + if (!openLibraryItemPickerPopover) return; + const existing = parseSelectionItems(editor.querySelector(`#query-${queryIndex}-staticItems`)?.value); + openLibraryItemPickerPopover({ + selected: existing, + onConfirm: (items) => { + setStaticItemsInEditor(editor, queryIndex, items); + } + }); + return; + } + + if (action === 'remove-static-item') { + const itemId = actionEl.dataset.itemId; + const editor = actionEl.closest('.query-editor'); + if (!editor || !itemId) return; + const queryIndex = editor.dataset.queryIndex; + const items = parseSelectionItems(editor.querySelector(`#query-${queryIndex}-staticItems`)?.value) + .filter(item => item.id !== itemId); + setStaticItemsInEditor(editor, queryIndex, items); + return; + } + + if (action === 'set-render-mode') { + const renderMode = actionEl.dataset.renderMode; + if (!renderMode || state.renderMode === renderMode) return; + collectStateFromForm(root, state); + state.renderMode = renderMode; + const renderModeInput = root.querySelector('#hsae-render-mode-value'); + if (renderModeInput) renderModeInput.value = renderMode; + appearancePreviewCache = { fetchKey: null, renderKey: null, items: null, resolvedSection: null }; + refreshEditorBody(modalInstance); + return; + } + + if (action === 'set-card-format') { + const cardFormat = actionEl.dataset.cardFormat; + if (!cardFormat) return; + collectStateFromForm(root, state); + state.cardFormat = cardFormat; + root.querySelectorAll('.hsae-card-format-btn').forEach(btn => { + btn.classList.toggle('hsae-active', btn.dataset.cardFormat === cardFormat); + }); + const cardFormatInput = root.querySelector('#hsae-card-format-value'); + if (cardFormatInput) cardFormatInput.value = cardFormat; + if (state.livePreviewUpdates) { + scheduleAppearancePreview(modalInstance); + } + return; + } + + if (action === 'set-item-source') { + const newSource = actionEl.dataset.itemSource; + if (!newSource || state.itemSource === newSource) return; + + // Keep sources in memory while switching; + // collectData clears the inactive source only on save/preview. + collectStateFromForm(root, state); + state.itemSource = newSource; + if (newSource === 'custom') { + const customEditor = getCustomItemsEditor(); + if (customEditor) customEditor.ensureCustomItemsInitialized(state); + } else if (newSource === 'external') { + if (!Array.isArray(state.externalListUrls) || !state.externalListUrls.length) { + state.externalListUrls = ['']; + } + } else { + state.queries = state.queries?.length ? state.queries : [createDefaultQuery()]; + } + refreshEditorBody(modalInstance); + return; + } + + if (action === 'add-external-list-url') { + collectStateFromForm(root, state); + if (!Array.isArray(state.externalListUrls)) state.externalListUrls = ['']; + state.externalListUrls.push(''); + refreshEditorBody(modalInstance, { skipCollect: true }); + return; + } + + if (action === 'delete-query') { + e.stopPropagation(); + const index = parseInt(actionEl.dataset.queryIndex, 10); + if (!state.queries || state.queries.length <= 1) return; + collectStateFromForm(root, state); + state.queries.splice(index, 1); + if (state.queryExpandedStates) state.queryExpandedStates.splice(index, 1); + refreshEditorBody(modalInstance, { skipCollect: true }); + } + } + + async function attachBodyListeners(modalInstance, context) { + const root = modalInstance.dialogContent; + const state = modalInstance._hsaeState; + + const groupSelect = root.querySelector('#section-group'); + const newGroupContainer = root.querySelector('#section-group-new-container'); + groupSelect?.addEventListener('change', () => { + if (newGroupContainer) newGroupContainer.style.display = groupSelect.value === 'New...' ? 'block' : 'none'; + }); + + const visibilitySelect = root.querySelector('#hsae-section-visibility'); + visibilitySelect?.addEventListener('change', () => { + if (state) state.sectionVisibility = visibilitySelect.value; + refreshEditorBody(modalInstance); + }); + + const hideCardTitlesCheckbox = root.querySelector('#hsae-hide-card-titles'); + const updateCardTitleOptionsRow = () => { + const row = root.querySelector('#hsae-card-title-options-row'); + const hide = hideCardTitlesCheckbox?.checked === true; + if (row) row.style.display = hide ? 'none' : ''; + }; + updateCardTitleOptionsRow(); + hideCardTitlesCheckbox?.addEventListener('change', updateCardTitleOptionsRow); + + const borderColorText = root.querySelector('#hsae-border-color-text'); + const cardTitleColorText = root.querySelector('#hsae-card-title-color-text'); + attachHsaeBorderColorPickr(modalInstance); + attachHsaeCardTitleColorPickr(modalInstance); + borderColorText?.addEventListener('input', () => { + syncHsaeColorPickrFromText(modalInstance, '_hsaeBorderPickr', borderColorText, '#hsae-border-color-pickr', '#e8dab2'); + }); + cardTitleColorText?.addEventListener('input', () => { + syncHsaeColorPickrFromText(modalInstance, '_hsaeCardTitleColorPickr', cardTitleColorText, '#hsae-card-title-color-pickr', '#ffffff'); + }); + + root.querySelectorAll('[data-hsae-chips]').forEach(row => { + const attr = row.dataset.hsaeChips; + if (attr?.startsWith('query-') && attr.endsWith('-filters')) { + const match = attr.match(/^query-(\d+)-filters$/); + if (match) attachChipRowListeners(root, row, `query-${match[1]}-Filters`); + } else if (attr?.startsWith('query-') && attr.endsWith('-include-types')) { + const match = attr.match(/^query-(\d+)-include-types$/); + if (match) attachChipRowListeners(root, row, `query-${match[1]}-IncludeItemTypes`); + } + }); + + root.querySelectorAll('.hsae-cache-preset').forEach(chip => { + chip.addEventListener('click', () => { + root.querySelectorAll('.hsae-cache-preset').forEach(c => c.classList.remove('hsae-active')); + chip.classList.add('hsae-active'); + const customContainer = root.querySelector('#hsae-ttl-custom-container'); + if (customContainer) customContainer.style.display = chip.dataset.presetKey === 'CUSTOM' ? 'grid' : 'none'; + if (state) state.ttlPreset = chip.dataset.presetKey; + }); + }); + + const addQueryBtn = root.querySelector('.hsae-add-query-btn'); + addQueryBtn?.addEventListener('click', async () => { + if (!state) return; + collectStateFromForm(root, state); + state.queries = state.queries || []; + state.queries.push(createDefaultQuery()); + if (!state.queryExpandedStates) { + state.queryExpandedStates = state.queries.slice(0, -1).map(() => true); + } + state.queryExpandedStates.push(true); + refreshEditorBody(modalInstance, { skipCollect: true }); + }); + + if (state?.itemSource === 'jellyfin') { + await setupQueryEditors(modalInstance, state); + (state.queries || []).forEach((_, i) => attachAdditionalOptionCustomHandler(modalInstance, i)); + } + + attachSpotlightListeners(root); + updateToggleCardHints(root); + + ['#hsae-use-random-query', '#hsae-use-multi-query-picker', '#hsae-use-query-names-for-section'].forEach((selector) => { + root.querySelector(selector)?.addEventListener('change', () => { + if (state) collectStateFromForm(root, state); + updateMultiQuerySettingsUI(root); + }); + }); + + root.querySelectorAll('.hsae-query-name-input').forEach((input) => { + input.addEventListener('click', (e) => e.stopPropagation()); + input.addEventListener('keydown', (e) => e.stopPropagation()); + }); + + const customEditor = getCustomItemsEditor(); + customEditor?.refreshPreviews(root, state); + + if (!modalInstance._hsaePreviewInitialized) { + modalInstance._hsaePreviewInitialized = true; + scheduleAppearancePreview(modalInstance, { immediate: true }); + } else if (state) { + collectStateFromForm(root, state); + const dialog = modalInstance.dialogContainer || modalInstance.dialogContent; + const draft = buildAppearancePreviewDraft(dialog, state.section || {}); + const fetchKey = draft ? getAppearancePreviewFetchKeyFromDraft(draft) : null; + const container = root.querySelector('#hsae-appearance-preview-container'); + if (fetchKey && appearancePreviewCache.fetchKey === fetchKey && appearancePreviewCache.items?.length) { + runAppearancePreview(modalInstance); + } else if (state.livePreviewUpdates) { + scheduleAppearancePreview(modalInstance); + } else if (container) { + container.innerHTML = '

Preview outdated — refresh the section or enable Live Preview Updates.

'; + } + } + } + + const HSAE_KEFIN_DEFAULT_GROUP_KEYS = [ + 'HOME_SECTION_GROUPS', + 'SEASONAL_SECTION_GROUPS', + 'DISCOVERY_SECTION_GROUPS' + ]; + const HSAE_SECTION_EXPORT_MODAL_ID = 'hsae-section-export'; + const HSAE_SECTION_IMPORT_MODAL_ID = 'hsae-section-import-paste'; + const HSAE_RESTORE_DEFAULTS_MODAL_ID = 'hsae-section-restore-defaults'; + const HSAE_TROUBLESHOOT_MODAL_ID = 'hsae-section-troubleshoot'; + const HSAE_SECTION_DIFF_KEYS = [ + 'name', 'caption', 'description', 'enabled', 'order', 'cardFormat', 'renderMode', 'ttl', + 'queries', 'items', 'externalListUrls', 'spotlightConfig', 'hideName', 'userConfigurable', + 'useRandomQuery', 'useMultiQueryPicker', 'multiQueryPickerLabel', 'useQueryNamesForSection', + 'sortBy', 'sortOrder', 'sortOrderDirection', 'itemLimit', 'minimumItems', 'flattenSeries', + 'limitBeforeSort', 'sectionCssClass', 'borderStyle', 'borderColor', 'cardTitleColor', + 'itemsLayout', 'useGaplessCards', 'cardTitleVisibility', 'useParentCard', 'cardTitlePosition', + 'cardTitleCapitalization', 'cardTitleFontFamily', 'cardTitleFontSize', 'startDate', 'endDate', + 'hidden', 'discoveryEnabled', 'type', 'category', 'livePreviewUpdates' + ]; + + function showHsaeToast(message, duration = '3') { + if (window.KefinTweaksToaster?.toast) { + window.KefinTweaksToaster.toast(message, duration); + } + } + + function deepCloneJson(value) { + return value == null ? value : JSON.parse(JSON.stringify(value)); + } + + /** collectData writes these even when canonical omits them (runtime defaults). */ + const HSAE_IMPLICIT_SECTION_DEFAULTS = { + renderMode: 'Normal', + useGaplessCards: false, + useParentCard: false, + discoveryEnabled: false, + livePreviewUpdates: false, + hideName: false, + useRandomQuery: false, + useMultiQueryPicker: false, + useQueryNamesForSection: false, + flattenSeries: false, + limitBeforeSort: false, + hideCardTitles: false, + category: 'none' + }; + + const HSAE_GROUP_KEY_TO_TYPE = { + HOME_SECTION_GROUPS: 'home', + SEASONAL_SECTION_GROUPS: 'seasonal', + DISCOVERY_SECTION_GROUPS: 'discovery' + }; + + function getDefaultTtlMs() { + const preset = getCachePresets().find((p) => p.key === 'DEFAULT'); + return preset?.ms ?? 3600000; + } + + /** Empty / missing editor values are equivalent for restore diffs ("" vs omitted). */ + function normalizeForDiff(value) { + if (value === undefined || value === null) return null; + if (typeof value === 'string' && value.trim() === '') return null; + if (Array.isArray(value) && value.length === 0) return null; + if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) { + return null; + } + return value; + } + + function omitEmptyDeep(value) { + if (value === undefined || value === null || value === '') return undefined; + if (Array.isArray(value)) { + return value.map(omitEmptyDeep).filter((v) => v !== undefined); + } + if (typeof value === 'object') { + const out = {}; + Object.keys(value).sort().forEach((key) => { + const nested = omitEmptyDeep(value[key]); + if (nested === undefined) return; + if (typeof nested === 'object' && !Array.isArray(nested) && Object.keys(nested).length === 0) { + return; + } + out[key] = nested; + }); + return out; + } + return value; + } + + function stableStringify(value) { + if (value === undefined) return 'null'; + if (value === null || typeof value !== 'object') { + try { + return JSON.stringify(value); + } catch (_) { + return String(value); + } + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`; + } + + function coerceQueryOptionValue(val) { + if (typeof val === 'string' && val.trim() !== '' && /^-?\d+(\.\d+)?$/.test(val.trim())) { + const n = Number(val); + if (Number.isFinite(n)) return n; + } + return val; + } + + function normalizeQueryForCompare(query) { + if (!query || typeof query !== 'object') return null; + const clone = deepCloneJson(query) || {}; + Object.keys(clone).forEach((key) => { + if (key.startsWith('_')) delete clone[key]; + }); + if (clone.queryOptions && typeof clone.queryOptions === 'object') { + Object.keys(clone.queryOptions).forEach((key) => { + if (key.startsWith('_')) { + delete clone.queryOptions[key]; + return; + } + clone.queryOptions[key] = coerceQueryOptionValue(clone.queryOptions[key]); + }); + } + return omitEmptyDeep(clone) || {}; + } + + function normalizeQueriesForCompare(queries) { + if (!Array.isArray(queries)) return null; + return queries.map(normalizeQueryForCompare); + } + + function normalizeItemsForCompare(items) { + if (!Array.isArray(items)) return null; + return items.map((item) => { + if (!item || typeof item !== 'object') return item; + const clone = deepCloneJson(item) || {}; + const name = clone.Name || clone.name; + if (name != null) { + clone.Name = name; + delete clone.name; + } + ['posterUrl', 'thumbUrl', 'backdropUrl', 'squareUrl', 'bannerUrl', 'logoUrl', 'imageUrl', 'cardFooter', 'cardUrl'] + .forEach((key) => { + if (typeof clone[key] === 'string' && !clone[key].trim()) delete clone[key]; + }); + return omitEmptyDeep(clone) || {}; + }); + } + + function normalizeDiffFieldValue(key, value, section) { + if (key === 'queries') { + const normalized = normalizeQueriesForCompare(Array.isArray(value) ? value : null); + if (!normalized || normalized.length === 0) return null; + return normalized; + } + if (key === 'items') { + const normalized = normalizeItemsForCompare(Array.isArray(value) ? value : null); + if (!normalized || normalized.length === 0) return null; + return normalized; + } + + let normalized = normalizeForDiff(value); + if (Object.prototype.hasOwnProperty.call(HSAE_IMPLICIT_SECTION_DEFAULTS, key)) { + const implicit = HSAE_IMPLICIT_SECTION_DEFAULTS[key]; + if (normalized === null || valuesRawEqual(normalized, implicit)) { + return null; + } + } + if (key === 'ttl') { + const defaultTtl = getDefaultTtlMs(); + if (normalized === null || valuesRawEqual(Number(normalized), defaultTtl)) { + return null; + } + } + if (key === 'type') { + const derived = deriveSectionSurfaceType(section); + if (normalized === null || valuesRawEqual(normalized, derived)) { + return derived; + } + } + if (key === 'hideName' && (normalized === false || normalized === null)) return null; + return normalized; + } + + function valuesRawEqual(a, b) { + try { + return stableStringify(a) === stableStringify(b); + } catch (_) { + return a === b; + } + } + + function deriveSectionSurfaceType(section) { + if (!section) return 'home'; + const existing = String(section.type || '').toLowerCase(); + if (existing === 'home' || existing === 'seasonal' || existing === 'discovery') return existing; + if (section.discoveryEnabled === true || section.discoveryType) return 'discovery'; + if (section.startDate?.trim?.() && section.endDate?.trim?.()) return 'seasonal'; + if (section.startDate && section.endDate) return 'seasonal'; + return 'home'; + } + + function valuesDeepEqual(a, b, key, sectionA, sectionB) { + try { + const left = key != null + ? normalizeDiffFieldValue(key, a, sectionA) + : normalizeForDiff(a); + const right = key != null + ? normalizeDiffFieldValue(key, b, sectionB) + : normalizeForDiff(b); + return stableStringify(left) === stableStringify(right); + } catch (_) { + return normalizeForDiff(a) === normalizeForDiff(b); + } + } + + function getCanonicalDefaultSection(sectionId) { + if (!sectionId) return null; + const defaults = window.KefinHomeConfig2; + if (!defaults) return null; + for (const groupKey of HSAE_KEFIN_DEFAULT_GROUP_KEYS) { + const groups = defaults[groupKey]; + if (!Array.isArray(groups)) continue; + for (const group of groups) { + const sections = group?.sections; + if (!Array.isArray(sections)) continue; + const found = sections.find((s) => s?.id === sectionId); + if (found) { + const clone = deepCloneJson(found); + const surfaceType = HSAE_GROUP_KEY_TO_TYPE[groupKey]; + if (clone && surfaceType && !clone.type) clone.type = surfaceType; + return clone; + } + } + } + return null; + } + + function isKefinDefaultSection(section, context) { + if (context?.isCustomSection === true) return false; + const id = section?.id; + if (!id) return false; + return getCanonicalDefaultSection(id) != null; + } + + function parseSectionImportPayload(text) { + if (text == null || String(text).trim() === '') return null; + let parsed; + try { + parsed = JSON.parse(String(text)); + } catch (_) { + return null; + } + if (Array.isArray(parsed)) { + if (parsed.length !== 1) return null; + parsed = parsed[0]; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const hasShape = ['name', 'queries', 'items', 'renderMode', 'id', 'cardFormat'] + .some((key) => Object.prototype.hasOwnProperty.call(parsed, key)); + if (!hasShape) return null; + return parsed; + } + + function exportCurrentSectionJson(modalInstance) { + const dialog = modalInstance?.dialogContainer || modalInstance?.dialogContent; + const state = modalInstance?._hsaeState; + if (!dialog || !state) return null; + const original = state.context?.originalSection || state.section || {}; + const section = collectData(dialog, original); + if (!section) return null; + if (state.section?.id) section.id = state.section.id; + return JSON.stringify(section, null, 2); + } + + function applyImportedSection(modalInstance, importedSection) { + const state = modalInstance?._hsaeState; + if (!state || !importedSection) return; + const current = state.section || {}; + const context = { ...(state.context || {}) }; + const merged = deepCloneJson(importedSection) || {}; + merged.id = current.id || merged.id; + if (current.deleted === true) merged.deleted = true; + // Keep collect/export/troubleshoot bases in sync with the applied section + context.originalSection = deepCloneJson(merged); + context.section = merged; + const newState = buildEditorStateFromSection(merged, context); + modalInstance._hsaeState = newState; + const stateHost = modalInstance.dialogContainer || modalInstance.dialogContent; + if (stateHost) stateHost._hsaeState = newState; + activeEditorState = newState; + refreshEditorBody(modalInstance, { skipCollect: true }); + updateSectionNameBadge(modalInstance); + } + + function abbreviateDiffValue(value, { markChanged = false } = {}) { + if (value === undefined) return '(none)'; + if (value === null) return '(none)'; + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + const base = `array (${value.length})`; + return markChanged ? `${base} · changed` : base; + } + if (typeof value === 'object') { + const keys = Object.keys(value); + return keys.length ? `object (${keys.length} keys)` : '{}'; + } + if (typeof value === 'boolean' || typeof value === 'number') return String(value); + const s = String(value); + if (!s) return '""'; + return s.length > 48 ? `${s.slice(0, 45)}…` : s; + } + + function diffSectionAgainstDefault(current, canonical) { + const diffs = []; + if (!current || !canonical) return diffs; + for (const key of HSAE_SECTION_DIFF_KEYS) { + const hasCurrent = Object.prototype.hasOwnProperty.call(current, key); + const hasCanonical = Object.prototype.hasOwnProperty.call(canonical, key); + if (!hasCurrent && !hasCanonical) continue; + const curVal = hasCurrent ? current[key] : undefined; + const defVal = hasCanonical ? canonical[key] : undefined; + if (valuesDeepEqual(curVal, defVal, key, current, canonical)) continue; + + const normCur = normalizeDiffFieldValue(key, curVal, current); + const normDef = normalizeDiffFieldValue(key, defVal, canonical); + const bothArraysSameLen = Array.isArray(normCur) && Array.isArray(normDef) + && normCur.length === normDef.length + && normCur.length > 0; + diffs.push({ + key, + current: abbreviateDiffValue(normCur ?? curVal, { markChanged: bothArraysSameLen }), + default: abbreviateDiffValue(normDef ?? defVal, { markChanged: bothArraysSameLen }) + }); + } + return diffs; + } + + function isHttpsContextForClipboard() { + try { + if (typeof ApiClient !== 'undefined' && ApiClient._serverAddress?.startsWith?.('https://')) return true; + } catch (_) { /* ignore */ } + return typeof location !== 'undefined' && location.protocol === 'https:'; + } + + function canUseClipboardWrite() { + return !!(isHttpsContextForClipboard() + && navigator.clipboard + && typeof navigator.clipboard.writeText === 'function'); + } + + function canUseClipboardRead() { + return !!(isHttpsContextForClipboard() + && navigator.clipboard + && typeof navigator.clipboard.readText === 'function'); + } + + function showSectionExportModal(json) { + if (!window.ModalSystem) { + showHsaeToast('Unable to open export modal'); + return; + } + if (window.ModalSystem.isOpen?.(HSAE_SECTION_EXPORT_MODAL_ID)) { + window.ModalSystem.close(HSAE_SECTION_EXPORT_MODAL_ID); + } + const canCopy = canUseClipboardWrite(); + window.ModalSystem.create({ + id: HSAE_SECTION_EXPORT_MODAL_ID, + title: 'Export Section', + content: ` +
+ Section JSON is shown below. Select and copy this text to back up or share the section. + ${canCopy ? '' : ' Clipboard access requires an HTTPS connection, so manual copying is required.'} +
+ + `, + footer: ` + ${canCopy ? ` + ` : ''} + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modal) => { + const textarea = modal.dialogContent?.querySelector('#hsae-export-section-textarea'); + if (textarea) { + textarea.value = json; + setTimeout(() => { + textarea.focus(); + textarea.select(); + }, 100); + } + modal.dialogFooter?.querySelector('#hsae-export-close-btn')?.addEventListener('click', () => { + window.ModalSystem.close(HSAE_SECTION_EXPORT_MODAL_ID); + }); + modal.dialogFooter?.querySelector('#hsae-export-copy-btn')?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(json); + showHsaeToast('Section copied to clipboard'); + } catch (_) { + showHsaeToast('Error copying to clipboard', '5'); + } + }); + } + }); + } + + function showSectionImportPasteModal(onImport) { + if (!window.ModalSystem) { + showHsaeToast('Unable to open import modal'); + return; + } + if (window.ModalSystem.isOpen?.(HSAE_SECTION_IMPORT_MODAL_ID)) { + window.ModalSystem.close(HSAE_SECTION_IMPORT_MODAL_ID); + } + window.ModalSystem.create({ + id: HSAE_SECTION_IMPORT_MODAL_ID, + title: 'Import Section', + content: ` +
+ Paste a single section JSON below, then Import. +
+ + `, + footer: ` + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modal) => { + const textarea = modal.dialogContent?.querySelector('#hsae-import-section-textarea'); + setTimeout(() => textarea?.focus(), 100); + modal.dialogFooter?.querySelector('#hsae-import-cancel-btn')?.addEventListener('click', () => { + window.ModalSystem.close(HSAE_SECTION_IMPORT_MODAL_ID); + }); + modal.dialogFooter?.querySelector('#hsae-import-confirm-btn')?.addEventListener('click', () => { + const text = textarea?.value || ''; + let parsed = null; + try { + const raw = JSON.parse(String(text)); + if (Array.isArray(raw) && raw.length !== 1) { + showHsaeToast('Not a single section'); + return; + } + parsed = parseSectionImportPayload(text); + } catch (_) { + parsed = null; + } + if (!parsed) { + showHsaeToast('Invalid section JSON'); + return; + } + window.ModalSystem.close(HSAE_SECTION_IMPORT_MODAL_ID); + if (typeof onImport === 'function') onImport(parsed); + }); + } + }); + } + + function showRestoreDefaultsConfirmModal(modalInstance, diffs, onProceed) { + if (!window.ModalSystem) { + showHsaeToast('Unable to open restore dialog'); + return; + } + if (window.ModalSystem.isOpen?.(HSAE_RESTORE_DEFAULTS_MODAL_ID)) { + window.ModalSystem.close(HSAE_RESTORE_DEFAULTS_MODAL_ID); + } + const matching = !diffs?.length; + const rowsHtml = matching + ? `
Already matching defaults
` + : ` +
+
+ FieldCurrentDefault +
+ ${diffs.map((d) => ` +
+ ${escapeHtml(d.key)} + ${escapeHtml(d.current)} + ${escapeHtml(d.default)} +
+ `).join('')} +
+ `; + const content = document.createElement('div'); + content.innerHTML = ` +
+ Restoring defaults will save the KefinTweaks defaults for this section and reload the editor. + Enabled status and order are kept. +
+ ${rowsHtml} + `; + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.5em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + window.ModalSystem.create({ + id: HSAE_RESTORE_DEFAULTS_MODAL_ID, + title: 'Restore Defaults', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + fixedSize: false, + dialogStyle: { + width: 'min(36rem, calc(100vw - 2rem))', + maxWidth: '36rem' + }, + onOpen: (modal) => { + modal.dialogFooter?.querySelector('#hsae-restore-cancel')?.addEventListener('click', () => { + window.ModalSystem.close(HSAE_RESTORE_DEFAULTS_MODAL_ID); + }); + modal.dialogFooter?.querySelector('#hsae-restore-proceed')?.addEventListener('click', async () => { + if (matching) return; + const proceedBtn = modal.dialogFooter?.querySelector('#hsae-restore-proceed'); + const cancelBtn = modal.dialogFooter?.querySelector('#hsae-restore-cancel'); + if (proceedBtn) proceedBtn.disabled = true; + if (cancelBtn) cancelBtn.disabled = true; + try { + window.ModalSystem.close(HSAE_RESTORE_DEFAULTS_MODAL_ID); + if (typeof onProceed === 'function') await onProceed(); + } catch (e) { + console.warn('[HSAE] Restore defaults failed:', e); + showHsaeToast('Failed to restore defaults'); + } + }); + } + }); + } + + function getEligibleTroubleshootQueries(section) { + const queries = Array.isArray(section?.queries) ? section.queries : []; + return queries + .map((query, index) => ({ query, index, sourceType: inferQuerySourceType(query) })) + .filter((entry) => entry.sourceType === 'jellyfin' || entry.sourceType === 'static'); + } + + function formatTroubleshootRequestUrl(method, url) { + const methodLabel = method || 'GET'; + if (!url || typeof url !== 'string') { + return `${methodLabel} (no URL)`; + } + try { + const parsed = new URL(url, window.location?.origin || undefined); + const lines = [`${methodLabel} ${parsed.origin}${parsed.pathname}`]; + parsed.searchParams.forEach((value, key) => { + let decoded = value; + try { + decoded = decodeURIComponent(value); + } catch (_) { /* keep raw */ } + lines.push(`${key}=${decoded}`); + }); + return lines.join('\n'); + } catch (_) { + const qIndex = url.indexOf('?'); + if (qIndex < 0) return `${methodLabel} ${url}`; + const base = url.slice(0, qIndex); + const query = url.slice(qIndex + 1); + const lines = [`${methodLabel} ${base}`]; + query.split('&').forEach((pair) => { + if (!pair) return; + const eq = pair.indexOf('='); + const key = eq >= 0 ? pair.slice(0, eq) : pair; + const rawVal = eq >= 0 ? pair.slice(eq + 1) : ''; + let decodedKey = key; + let decodedVal = rawVal; + try { decodedKey = decodeURIComponent(key); } catch (_) { /* keep */ } + try { decodedVal = decodeURIComponent(rawVal); } catch (_) { /* keep */ } + lines.push(`${decodedKey}=${decodedVal}`); + }); + return lines.join('\n'); + } + } + + function troubleshootQueryLabel(entry) { + const name = entry?.name != null ? String(entry.name).trim() : ''; + if (name) return name; + return `Query ${entry?.index ?? '?'}`; + } + + function formatTroubleshootSectionConfigText(section) { + const eligible = getEligibleTroubleshootQueries(section); + if (!eligible.length) return '(no eligible queries)'; + return eligible.map(({ query, index }) => { + const label = troubleshootQueryLabel({ + index, + name: query?.name != null ? String(query.name).trim() : '' + }); + const clone = deepCloneJson(query) || {}; + // Drop ephemeral editor-only keys from the displayed config object + Object.keys(clone).forEach((key) => { + if (key.startsWith('_') && key !== '_sourceType') delete clone[key]; + }); + return `=== ${label} ===\n${JSON.stringify(clone, null, 2)}`; + }).join('\n\n'); + } + + function formatTroubleshootRequestText(payload) { + const entries = Array.isArray(payload?.queries) ? payload.queries : []; + if (!entries.length) return '(no requests)'; + return entries.map((entry) => { + const header = `=== ${troubleshootQueryLabel(entry)} ===`; + const req = entry.request; + if (!req) return `${header}\n(no request)`; + if (req.error) return `${header}\nERROR: ${req.error}`; + if (typeof req.url === 'string') { + return `${header}\n${formatTroubleshootRequestUrl(req.method, req.url)}`; + } + // Non-URL request object (e.g. cache marker) + return `${header}\n${JSON.stringify(req, null, 2)}`; + }).join('\n\n'); + } + + function formatTroubleshootResponseText(payload) { + const entries = Array.isArray(payload?.queries) ? payload.queries : []; + if (!entries.length) return '(no responses)'; + return entries.map((entry) => { + const header = `=== ${troubleshootQueryLabel(entry)} ===`; + return `${header}\n${JSON.stringify(entry.response ?? null, null, 2)}`; + }).join('\n\n'); + } + + function countTroubleshootItems(response) { + if (!response || typeof response !== 'object') return null; + if (response.error || response.skipped) return null; + if (Array.isArray(response.Items)) return response.Items.length; + if (Array.isArray(response.items)) return response.items.length; + if (Array.isArray(response)) return response.length; + if (typeof response.TotalRecordCount === 'number') return response.TotalRecordCount; + return null; + } + + function measureTroubleshootResponseSize(response) { + if (response == null) return null; + try { + const json = typeof response === 'string' ? response : JSON.stringify(response); + if (typeof TextEncoder !== 'undefined') { + return new TextEncoder().encode(json).length; + } + return json.length; + } catch (_) { + return null; + } + } + + function formatTroubleshootDuration(ms) { + if (ms == null || !Number.isFinite(ms)) return '—'; + if (ms < 1000) return `${Math.round(ms)} ms`; + return `${(ms / 1000).toFixed(2)} s`; + } + + function formatTroubleshootSize(bytes) { + if (bytes == null || !Number.isFinite(bytes)) return '—'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + } + + function buildTroubleshootSummaryHtml(payload) { + const entries = Array.isArray(payload?.queries) ? payload.queries : []; + if (!entries.length) { + return '
No query results.
'; + } + const rows = entries.map((entry) => { + const stats = entry.stats || {}; + const itemCount = stats.itemCount != null ? String(stats.itemCount) : '—'; + const duration = formatTroubleshootDuration(stats.durationMs); + const size = formatTroubleshootSize(stats.responseBytes); + return ` + + ${escapeHtml(troubleshootQueryLabel(entry))} + ${escapeHtml(itemCount)} + ${escapeHtml(duration)} + ${escapeHtml(size)} + + `; + }).join(''); + return ` + + + + + + + + + + ${rows} +
QueryItem CountResponse TimeResponse Size
+ `; + } + + function attachTroubleshootStats(entry, response, durationMs) { + entry.stats = { + itemCount: countTroubleshootItems(response), + durationMs: durationMs != null && Number.isFinite(durationMs) ? durationMs : null, + responseBytes: measureTroubleshootResponseSize(response) + }; + return entry; + } + + async function runSectionTroubleshoot(section) { + const apiHelper = window.apiHelper; + const userId = window.ApiClient?.getCurrentUserId?.(); + const serverUrl = window.ApiClient?.serverAddress?.(); + if (!apiHelper?.buildQueryFromSection || !apiHelper?.getQuery) { + return { + queries: [attachTroubleshootStats( + { index: -1, request: null, response: { error: 'apiHelper unavailable' } }, + { error: 'apiHelper unavailable' }, + null + )] + }; + } + if (!userId || !serverUrl) { + return { + queries: [attachTroubleshootStats( + { index: -1, request: null, response: { error: 'Unable to get user ID or server URL' } }, + { error: 'Unable to get user ID or server URL' }, + null + )] + }; + } + + const isSpotlight = section?.renderMode === 'Spotlight' || section?.spotlight === true; + const sectionType = section?.type || deriveSectionSurfaceType(section); + const eligible = getEligibleTroubleshootQueries(section); + const results = []; + + for (const { query, index } of eligible) { + const queryName = query?.name != null ? String(query.name).trim() : ''; + let built; + try { + built = apiHelper.buildQueryFromSection(query, userId, serverUrl, isSpotlight, { sectionType }); + } catch (err) { + const errBody = { error: err?.message || String(err) }; + results.push(attachTroubleshootStats({ + index, + name: queryName || null, + request: { method: 'GET', url: null, error: errBody.error }, + response: errBody + }, errBody, null)); + continue; + } + + if (typeof built !== 'string') { + const skipped = { skipped: true, reason: 'Non-URL request (e.g. cache dataSource)' }; + results.push(attachTroubleshootStats({ + index, + name: queryName || null, + request: built, + response: skipped + }, skipped, null)); + continue; + } + + const entry = { + index, + name: queryName || null, + request: { method: 'GET', url: built }, + response: null + }; + const started = (typeof performance !== 'undefined' && performance.now) + ? performance.now() + : Date.now(); + try { + entry.response = await apiHelper.getQuery(built, { useCache: false, forceRefresh: true }); + } catch (err) { + entry.response = { error: err?.message || String(err) }; + } + const ended = (typeof performance !== 'undefined' && performance.now) + ? performance.now() + : Date.now(); + attachTroubleshootStats(entry, entry.response, ended - started); + results.push(entry); + } + + return { queries: results }; + } + + function showSectionTroubleshootModal(modalInstance) { + if (!window.ModalSystem) { + showHsaeToast('Unable to open troubleshoot modal'); + return; + } + if (window.ModalSystem.isOpen?.(HSAE_TROUBLESHOOT_MODAL_ID)) { + window.ModalSystem.close(HSAE_TROUBLESHOOT_MODAL_ID); + } + + const dialog = modalInstance?.dialogContainer || modalInstance?.dialogContent; + const state = modalInstance?._hsaeState; + if (!dialog || !state) return; + + const original = state.context?.originalSection || state.section || {}; + const section = collectData(dialog, original); + if (!section) return; + if (state.section?.id) section.id = state.section.id; + + // Quiet no-op if source/queries flipped mid-click (button should already be hidden) + if (!getEligibleTroubleshootQueries(section).length) return; + + const canCopy = canUseClipboardWrite(); + const sectionConfigText = formatTroubleshootSectionConfigText(section); + window.ModalSystem.create({ + id: HSAE_TROUBLESHOOT_MODAL_ID, + title: 'Troubleshoot', + content: ` +
+
Loading summary…
+
+
+
+
Section Config
+ +
+
+
Request
+ +
+
+
Response
+ +
+
+ `, + footer: ` + ${canCopy ? ` + + + ` : ''} + + `, + closeOnBackdrop: true, + closeOnEscape: true, + dialogStyle: { + width: 'min(48rem, calc(100vw - 2rem))', + maxWidth: '48rem' + }, + onOpen: (modal) => { + const summaryEl = modal.dialogContent?.querySelector('#hsae-troubleshoot-summary'); + const configTa = modal.dialogContent?.querySelector('#hsae-troubleshoot-section-config'); + const requestTa = modal.dialogContent?.querySelector('#hsae-troubleshoot-request'); + const responseTa = modal.dialogContent?.querySelector('#hsae-troubleshoot-response'); + const copyConfigBtn = modal.dialogFooter?.querySelector('#hsae-troubleshoot-copy-config-btn'); + const copyRequestBtn = modal.dialogFooter?.querySelector('#hsae-troubleshoot-copy-request-btn'); + const copyResponseBtn = modal.dialogFooter?.querySelector('#hsae-troubleshoot-copy-response-btn'); + if (configTa) configTa.value = sectionConfigText; + if (requestTa) requestTa.value = 'Loading…'; + if (responseTa) responseTa.value = 'Loading…'; + + modal.dialogFooter?.querySelector('#hsae-troubleshoot-close-btn')?.addEventListener('click', () => { + window.ModalSystem.close(HSAE_TROUBLESHOOT_MODAL_ID); + }); + + const copyFrom = async (textarea, label) => { + const text = textarea?.value || ''; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + showHsaeToast(`${label} copied to clipboard`); + } catch (_) { + textarea?.focus(); + textarea?.select(); + showHsaeToast('Select and copy the text manually'); + } + }; + copyConfigBtn?.addEventListener('click', () => copyFrom(configTa, 'Section config')); + copyRequestBtn?.addEventListener('click', () => copyFrom(requestTa, 'Request')); + copyResponseBtn?.addEventListener('click', () => copyFrom(responseTa, 'Response')); + + const applyPayload = (payload) => { + if (summaryEl) summaryEl.innerHTML = buildTroubleshootSummaryHtml(payload); + if (requestTa) requestTa.value = formatTroubleshootRequestText(payload); + if (responseTa) responseTa.value = formatTroubleshootResponseText(payload); + if (copyRequestBtn) copyRequestBtn.disabled = false; + if (copyResponseBtn) copyResponseBtn.disabled = false; + }; + + runSectionTroubleshoot(section).then(applyPayload).catch((err) => { + const errBody = { error: err?.message || String(err) }; + applyPayload({ + queries: [attachTroubleshootStats( + { index: -1, request: null, response: errBody }, + errBody, + null + )] + }); + }); + } + }); + } + + function handleHeaderTroubleshoot(modalInstance) { + showSectionTroubleshootModal(modalInstance); + } + + async function handleHeaderExport(modalInstance) { + const json = exportCurrentSectionJson(modalInstance); + if (!json) { + showHsaeToast('Unable to export section'); + return; + } + try { + if (canUseClipboardWrite()) { + await navigator.clipboard.writeText(json); + showHsaeToast('Section copied to clipboard'); + return; + } + } catch (_) { /* fall through to modal */ } + showSectionExportModal(json); + } + + async function handleHeaderImport(modalInstance) { + const apply = (section) => { + applyImportedSection(modalInstance, section); + showHsaeToast('Section imported'); + }; + if (canUseClipboardRead()) { + try { + const text = await navigator.clipboard.readText(); + let parsed = null; + try { + const raw = JSON.parse(String(text)); + if (Array.isArray(raw) && raw.length !== 1) { + showHsaeToast('Not a single section'); + return; + } + parsed = parseSectionImportPayload(text); + } catch (_) { + parsed = null; + } + if (parsed) { + apply(parsed); + return; + } + } catch (_) { /* paste modal */ } + } + showSectionImportPasteModal(apply); + } + + function handleHeaderRestoreDefaults(modalInstance) { + const state = modalInstance?._hsaeState; + const dialog = modalInstance?.dialogContainer || modalInstance?.dialogContent; + if (!state || !dialog) return; + const sectionId = state.section?.id; + const canonical = getCanonicalDefaultSection(sectionId); + if (!canonical) { + showHsaeToast('No default found for this section'); + syncHeaderActions(modalInstance); + return; + } + const original = state.context?.originalSection || state.section || {}; + const current = collectData(dialog, original) || deepCloneJson(state.section) || {}; + if (state.section?.id) current.id = state.section.id; + const diffs = diffSectionAgainstDefault(current, canonical); + showRestoreDefaultsConfirmModal(modalInstance, diffs, async () => { + const merged = deepCloneJson(canonical) || {}; + merged.id = current.id || sectionId; + if (Object.prototype.hasOwnProperty.call(current, 'enabled')) merged.enabled = current.enabled; + if (Object.prototype.hasOwnProperty.call(current, 'order')) merged.order = current.order; + if (current.deleted === true) merged.deleted = true; + + const callbacks = modalInstance._hsaeCallbacks || {}; + const persist = typeof callbacks.persistSection === 'function' + ? callbacks.persistSection + : (typeof callbacks.onSave === 'function' ? callbacks.onSave : null); + + if (persist) { + await persist(merged, { quiet: true }); + if (typeof callbacks.remountAfterPersist === 'function') { + callbacks.remountAfterPersist(merged); + } else { + applyImportedSection(modalInstance, merged); + } + } else { + // No persist path (e.g. create flow): update editor only + applyImportedSection(modalInstance, merged); + } + showHsaeToast('Defaults restored'); + }); + } + + function getEditorItemSource(modalInstance) { + const state = modalInstance?._hsaeState; + const root = modalInstance?.dialogContent; + const fromDom = root?.querySelector?.('.hsae-source-card.hsae-active[data-item-source]')?.dataset?.itemSource; + if (fromDom) return fromDom; + const hsaeRoot = root?.classList?.contains('hsae-root') ? root : root?.querySelector?.('.hsae-root'); + if (hsaeRoot?.dataset?.itemSource) return hsaeRoot.dataset.itemSource; + return state?.itemSource || inferItemSource(state?.section) || 'jellyfin'; + } + + function getEditorQueriesForTroubleshoot(modalInstance) { + const state = modalInstance?._hsaeState; + const root = modalInstance?.dialogContent; + const editors = root?.querySelectorAll?.('.query-editor'); + if (editors?.length) { + return Array.from(editors).map((editor) => { + const index = parseInt(editor.dataset.queryIndex, 10); + const sourceType = editor.querySelector(`#query-${index}-sourceType`)?.value + || inferQuerySourceType(state?.queries?.[index]); + if (sourceType === 'static') return { _sourceType: 'static' }; + if (sourceType === 'cache') return { dataSource: 'cache' }; + return {}; + }); + } + return Array.isArray(state?.queries) ? state.queries : []; + } + + function sectionHasTroubleshootEligibleQueries(queries) { + return (queries || []).some((q) => { + const type = inferQuerySourceType(q); + return type === 'jellyfin' || type === 'static'; + }); + } + + function canShowTroubleshoot(modalInstance) { + if (getEditorItemSource(modalInstance) !== 'jellyfin') return false; + return sectionHasTroubleshootEligibleQueries(getEditorQueriesForTroubleshoot(modalInstance)); + } + + function syncHeaderActions(modalInstance) { + const actions = modalInstance?.dialogHeader?.querySelector('.hsae-header-actions'); + if (!actions) return; + const state = modalInstance._hsaeState; + const section = state?.section || {}; + const context = state?.context || {}; + const isDefault = isKefinDefaultSection(section, context); + const importBtn = actions.querySelector('[data-hsae-header-action="import"]'); + const exportBtn = actions.querySelector('[data-hsae-header-action="export"]'); + const restoreBtn = actions.querySelector('[data-hsae-header-action="restore"]'); + const troubleshootBtn = actions.querySelector('[data-hsae-header-action="troubleshoot"]'); + if (importBtn) importBtn.hidden = isDefault; + if (exportBtn) exportBtn.hidden = isDefault; + if (restoreBtn) { + restoreBtn.hidden = !isDefault; + if (isDefault && !getCanonicalDefaultSection(section.id)) { + restoreBtn.hidden = true; + } + } + if (troubleshootBtn) { + troubleshootBtn.hidden = !canShowTroubleshoot(modalInstance); + } + } + + function createHeaderActionButton(action, title, icon) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.setAttribute('is', 'paper-icon-button-light'); + btn.className = 'paper-icon-button-light autoSize hsae-header-action-btn'; + btn.title = title; + btn.setAttribute('aria-label', title); + btn.dataset.hsaeHeaderAction = action; + btn.innerHTML = `${escapeHtml(icon)}`; + return btn; + } + + function setupModalHeader(modalInstance, options = {}) { + if (!modalInstance?.dialogHeader) return; + if (modalInstance.dialogHeader.dataset.hsaeHeaderSetup === 'true') { + const titleEl = modalInstance.dialogHeader.querySelector('.dialogTitle, h2'); + if (titleEl && options.title) { + titleEl.textContent = options.title; + } + updateSectionNameBadge(modalInstance); + syncHeaderActions(modalInstance); + return; + } + modalInstance.dialogHeader.dataset.hsaeHeaderSetup = 'true'; + + const titleEl = modalInstance.dialogHeader.querySelector('h2, .dialogTitle'); + const closeBtn = modalInstance.dialogHeader.querySelector('.btnClose'); + + const headerMain = document.createElement('div'); + headerMain.className = 'hsae-header-main'; + + const titleWrap = document.createElement('div'); + titleWrap.className = 'hsae-header-title-wrap'; + + if (titleEl) { + titleEl.textContent = options.title || 'Edit Section'; + titleEl.classList.add('dialogTitle'); + titleWrap.appendChild(titleEl); + } + + const badge = document.createElement('span'); + badge.className = 'hsae-section-name-badge'; + badge.textContent = modalInstance._hsaeState?.section?.name?.trim() || 'Untitled'; + titleWrap.appendChild(badge); + + headerMain.appendChild(titleWrap); + + const actions = document.createElement('div'); + actions.className = 'hsae-header-actions'; + const importBtn = createHeaderActionButton('import', 'Import', 'content_paste'); + const exportBtn = createHeaderActionButton('export', 'Export', 'content_copy'); + const restoreBtn = createHeaderActionButton('restore', 'Restore Defaults', 'settings_backup_restore'); + const troubleshootBtn = createHeaderActionButton('troubleshoot', 'Troubleshoot', 'troubleshoot'); + actions.appendChild(importBtn); + actions.appendChild(exportBtn); + actions.appendChild(restoreBtn); + actions.appendChild(troubleshootBtn); + headerMain.appendChild(actions); + + actions.addEventListener('click', (e) => { + const btn = e.target.closest('[data-hsae-header-action]'); + if (!btn || !actions.contains(btn) || btn.hidden) return; + e.preventDefault(); + e.stopPropagation(); + const action = btn.dataset.hsaeHeaderAction; + if (action === 'export') handleHeaderExport(modalInstance); + else if (action === 'import') handleHeaderImport(modalInstance); + else if (action === 'restore') handleHeaderRestoreDefaults(modalInstance); + else if (action === 'troubleshoot') handleHeaderTroubleshoot(modalInstance); + }); + + if (closeBtn) { + modalInstance.dialogHeader.insertBefore(headerMain, closeBtn); + } else { + modalInstance.dialogHeader.prepend(headerMain); + } + + syncHeaderActions(modalInstance); + } + + function attachListeners(modalInstance, context, callbacks = {}) { + clearAppearancePreviewScheduling(); + appearancePreviewCache = { fetchKey: null, renderKey: null, items: null, resolvedSection: null }; + modalInstance._hsaePreviewInitialized = false; + + const section = context.section || {}; + // Ensure collect/export/troubleshoot always have a stable original base + if (!context.originalSection) { + context = { ...context, originalSection: deepCloneJson(section) }; + } + modalInstance._hsaeState = buildEditorStateFromSection(section, context); + modalInstance._hsaeCallbacks = callbacks || {}; + + const stateHost = modalInstance.dialogContainer || modalInstance.dialogContent; + if (stateHost) stateHost._hsaeState = modalInstance._hsaeState; + activeEditorState = modalInstance._hsaeState; + + setupModalHeader(modalInstance, { title: context.headerTitle }); + attachBodyListeners(modalInstance, context); + + const root = modalInstance.dialogContent; + const { onSave, refreshQueries, updateSectionInConfig } = callbacks; + + if (typeof refreshQueries === 'function') { + modalInstance._hsaeRefreshQueries = refreshQueries; + } + + const saveBtn = modalInstance.dialogFooter?.querySelector('.section-save-btn'); + if (saveBtn && typeof onSave === 'function' && saveBtn.dataset.listenerAttached !== 'true') { + saveBtn.dataset.listenerAttached = 'true'; + saveBtn.addEventListener('click', async () => { + const dialog = modalInstance.dialogContainer || modalInstance.dialogContent; + const liveState = modalInstance._hsaeState; + const originalSection = liveState?.context?.originalSection || context.originalSection || section; + const sectionData = collectData(dialog, originalSection); + if (!sectionData) return; + sectionData.id = liveState?.section?.id || section.id || originalSection.id; + if (typeof updateSectionInConfig === 'function') { + updateSectionInConfig(sectionData); + } + await onSave(sectionData); + }); + } + + if (!modalInstance._hsaeRootListenersAttached) { + modalInstance._hsaeRootListenersAttached = true; + + root.addEventListener('click', (e) => { + handleHsaeRootClick(e, modalInstance); + }); + + root.addEventListener('change', (e) => { + const target = e.target; + if (!root.contains(target)) return; + + if (target.id === 'hsae-live-preview-updates' && modalInstance._hsaeState) { + modalInstance._hsaeState.livePreviewUpdates = target.checked === true; + if (target.checked) { + scheduleAppearancePreview(modalInstance); + } else if (appearancePreviewDebounceTimer) { + clearTimeout(appearancePreviewDebounceTimer); + appearancePreviewDebounceTimer = null; + } + return; + } + + if (modalInstance._hsaeState?.livePreviewUpdates) { + const editorBody = root.querySelector('.hsae-editor-body'); + if (editorBody?.contains(target)) { + scheduleAppearancePreview(modalInstance); + } + } + + if (target.matches?.('input[type="checkbox"]') && target.closest('.kefin-toggle-card')) { + updateToggleCardHints(root); + } + }); + + root.addEventListener('input', (e) => { + const target = e.target; + if (!modalInstance._hsaeState?.livePreviewUpdates) return; + if (!root.querySelector('.hsae-editor-body')?.contains(target)) return; + scheduleAppearancePreview(modalInstance); + }); + + root.addEventListener('input', (e) => { + const target = e.target; + if (target?.id === 'section-name' && root.contains(target)) { + updateSectionNameBadge(modalInstance); + } + }); + } + } + + function init() { + // Deprecated no-op: API is resolved lazily via getApi(). + } + + window.KefinHomeScreenAdvancedEditor = { + init, + buildHTML, + collectData, + attachListeners, + setupModalHeader, + refreshEditorBody, + destroyAppearancePickrs: destroyHsaeAppearancePickrs, + inferItemSource, + sectionToVisibility, + applyVisibilityToSection, + FILTER_CHIP_OPTIONS, + ENDPOINT_OPTIONS, + CACHE_SOURCE_OPTIONS, + getCachePresets + }; +})(); diff --git a/configuration/homeScreenCustomItemsEditor.js b/configuration/homeScreenCustomItemsEditor.js new file mode 100644 index 0000000..9f5f539 --- /dev/null +++ b/configuration/homeScreenCustomItemsEditor.js @@ -0,0 +1,631 @@ +// Shared Custom Items editor (wizard + advanced section editor) + +(function() { + 'use strict'; + + const CUSTOM_IMAGE_FIELDS = [ + { key: 'posterUrl', label: 'Poster URL', required: true }, + { key: 'thumbUrl', label: 'Thumb URL' }, + { key: 'backdropUrl', label: 'Backdrop URL' }, + { key: 'squareUrl', label: 'Square URL' }, + { key: 'bannerUrl', label: 'Banner URL' }, + { key: 'logoUrl', label: 'Logo URL' } + ]; + const CUSTOM_POSTER_FIELD = CUSTOM_IMAGE_FIELDS[0]; + const CUSTOM_ADDITIONAL_IMAGE_FIELDS = CUSTOM_IMAGE_FIELDS.slice(1); + + function escapeHtml(str) { + if (str == null) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function renderMaterialIcon(name, className = '') { + return ``; + } + + function getOptions(options = {}) { + return { + idPrefix: options.idPrefix || 'hsse-custom', + actionAttr: options.actionAttr || 'data-hsse-action', + actions: { + openEditor: 'wizard-open-custom-item-editor', + closeEditor: 'wizard-close-custom-item-editor', + addItem: 'wizard-add-custom-item', + removeItem: 'wizard-remove-custom-item', + toggleImages: 'wizard-toggle-custom-images', + toggleHide: 'wizard-toggle-custom-item-hide', + moveLeft: 'wizard-move-custom-item-left', + moveRight: 'wizard-move-custom-item-right', + ...(options.actions || {}) + }, + intro: options.intro !== false, + // Default Kefin sections pass false — hide only, never delete + allowRemove: options.allowRemove !== false + }; + } + + function actionAttr(options, action) { + return `${options.actionAttr}="${escapeHtml(options.actions[action])}"`; + } + + function fieldId(options, index, key) { + return `${options.idPrefix}-${index}-${key}`; + } + + function createBlankCustomItem() { + return { + name: '', + cardFooter: '', + cardUrl: '', + posterUrl: '', + thumbUrl: '', + backdropUrl: '', + squareUrl: '', + bannerUrl: '', + logoUrl: '', + hidden: false + }; + } + + function staticItemToDraft(item) { + if (!item) return createBlankCustomItem(); + const draft = createBlankCustomItem(); + draft.name = item.Name || item.name || ''; + draft.cardFooter = item.cardFooter || ''; + draft.cardUrl = item.cardUrl || ''; + draft.posterUrl = item.posterUrl || item.imageUrl || ''; + draft.thumbUrl = item.thumbUrl || ''; + draft.backdropUrl = item.backdropUrl || ''; + draft.squareUrl = item.squareUrl || ''; + draft.bannerUrl = item.bannerUrl || ''; + draft.logoUrl = item.logoUrl || ''; + draft.hidden = item.hidden === true; + if (item.Id || item.id) draft.id = item.Id || item.id; + if (item.Type) draft.type = item.Type; + return draft; + } + + function staticItemsToDrafts(items) { + const list = (items || []).map(staticItemToDraft).filter(Boolean); + return list.length ? list : [createBlankCustomItem()]; + } + + function ensureCustomItemsInitialized(state) { + if (!state.customItems?.length) { + state.customItems = [createBlankCustomItem()]; + } + } + + function hasAdditionalCustomImages(item) { + return CUSTOM_ADDITIONAL_IMAGE_FIELDS.some(field => item[field.key]?.trim()); + } + + function isCustomItemAdditionalExpanded(state, index, item) { + const explicit = state.customItemAdditionalExpanded?.[index]; + if (explicit === true) return true; + if (explicit === false) return false; + return hasAdditionalCustomImages(item); + } + + function reindexCustomItemAdditionalExpanded(state, removedIndex) { + if (!state.customItemAdditionalExpanded) return; + const next = {}; + Object.entries(state.customItemAdditionalExpanded).forEach(([key, value]) => { + const i = parseInt(key, 10); + if (Number.isNaN(i) || value === undefined) return; + if (i < removedIndex) next[i] = value; + else if (i > removedIndex) next[i - 1] = value; + }); + state.customItemAdditionalExpanded = next; + } + + function swapCustomItemExpanded(state, fromIndex, toIndex) { + if (!state.customItemAdditionalExpanded) return; + const a = state.customItemAdditionalExpanded[fromIndex]; + const b = state.customItemAdditionalExpanded[toIndex]; + if (a === undefined && b === undefined) return; + if (b === undefined) delete state.customItemAdditionalExpanded[fromIndex]; + else state.customItemAdditionalExpanded[fromIndex] = b; + if (a === undefined) delete state.customItemAdditionalExpanded[toIndex]; + else state.customItemAdditionalExpanded[toIndex] = a; + } + + function moveCustomItemExpanded(state, fromIndex, toIndex) { + if (!state.customItemAdditionalExpanded || fromIndex === toIndex) return; + const next = {}; + const moved = state.customItemAdditionalExpanded[fromIndex]; + Object.entries(state.customItemAdditionalExpanded).forEach(([key, value]) => { + const i = parseInt(key, 10); + if (Number.isNaN(i) || i === fromIndex) return; + let dest = i; + if (fromIndex < toIndex && i > fromIndex && i <= toIndex) dest = i - 1; + else if (fromIndex > toIndex && i >= toIndex && i < fromIndex) dest = i + 1; + next[dest] = value; + }); + if (moved !== undefined) next[toIndex] = moved; + state.customItemAdditionalExpanded = next; + } + + function hasValidCustomItems(items) { + return (items || []).some((item) => { + const name = item.name?.trim(); + if (!name) return false; + return !!(item.posterUrl?.trim() || item.cardUrl?.trim()); + }); + } + + function hasCustomItemContent(item) { + if (!item) return false; + return [ + item.name, item.cardFooter, item.cardUrl, item.posterUrl, + item.thumbUrl, item.backdropUrl, item.squareUrl, item.bannerUrl, item.logoUrl + ].some(value => value?.trim()); + } + + function buildStaticItemFromDraft(draft) { + const name = draft.name?.trim(); + const posterUrl = draft.posterUrl?.trim(); + const cardUrl = draft.cardUrl?.trim(); + // Name + poster, or Name + link (e.g. liveTv-browse button tiles) + if (!name || (!posterUrl && !cardUrl)) return null; + + const imageKeys = ['posterUrl', 'thumbUrl', 'backdropUrl', 'squareUrl', 'bannerUrl', 'logoUrl']; + const provided = {}; + imageKeys.forEach(key => { + const value = draft[key]?.trim(); + if (value) provided[key] = value; + }); + + const item = { Name: name }; + if (draft.type) item.Type = draft.type; + else if (posterUrl) item.Type = 'Folder'; + if (draft.id) item.Id = draft.id; + if (cardUrl) item.cardUrl = cardUrl; + const cardFooter = draft.cardFooter?.trim(); + if (cardFooter) item.cardFooter = cardFooter; + if (draft.hidden === true) item.hidden = true; + + const keys = Object.keys(provided); + if (keys.length === 1 && provided.posterUrl) { + item.imageUrl = provided.posterUrl; + } else if (keys.length) { + Object.assign(item, provided); + } + return item; + } + + function collectCustomItemFromForm(root, state, index, options) { + if (!root || !state.customItems?.[index]) return; + const opts = getOptions(options); + const item = state.customItems[index]; + const nameEl = root.querySelector(`#${fieldId(opts, index, 'name')}`); + const cardFooterEl = root.querySelector(`#${fieldId(opts, index, 'cardFooter')}`); + const cardUrlEl = root.querySelector(`#${fieldId(opts, index, 'cardUrl')}`); + if (nameEl) item.name = nameEl.value; + if (cardFooterEl) item.cardFooter = cardFooterEl.value; + if (cardUrlEl) item.cardUrl = cardUrlEl.value; + CUSTOM_IMAGE_FIELDS.forEach(field => { + const el = root.querySelector(`#${fieldId(opts, index, field.key)}`); + if (el) item[field.key] = el.value; + }); + } + + function collectCustomItemsFromForm(root, state, options) { + if (!root || !state.customItems) return; + const editorIndex = state.customItemEditorIndex; + if (editorIndex != null && !Number.isNaN(editorIndex)) { + collectCustomItemFromForm(root, state, editorIndex, options); + } + } + + function resolvePreviewUrl(url) { + const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot || ''; + const serverId = (typeof ApiClient !== 'undefined' && typeof ApiClient.serverId === 'function') + ? ApiClient.serverId() + : ''; + return String(url || '') + .replace(/\$\{kefinTweaksRoot\}/g, kefinTweaksRoot) + .replace(/\$\{serverId\}/g, serverId) + .trim(); + } + + function updateCustomImagePreview(imgEl, url) { + if (!imgEl) return; + const trimmed = resolvePreviewUrl(url); + if (!trimmed) { + imgEl.removeAttribute('src'); + imgEl.style.display = 'none'; + imgEl.dataset.previewState = 'empty'; + return; + } + imgEl.style.display = ''; + imgEl.dataset.previewState = 'loading'; + imgEl.onerror = () => { + imgEl.style.display = 'none'; + imgEl.dataset.previewState = 'error'; + }; + imgEl.onload = () => { + imgEl.style.display = ''; + imgEl.dataset.previewState = 'loaded'; + }; + imgEl.src = trimmed; + } + + function refreshPreviews(root, state, options) { + if (!root || !state.customItems) return; + state.customItems.forEach((item, index) => { + const tileImg = root.querySelector(`img[data-tile-index="${index}"]`); + updateCustomImagePreview(tileImg, item.posterUrl); + }); + root.querySelectorAll('[data-hsse-custom-preview], [data-hsae-custom-preview]').forEach(input => { + const img = root.querySelector(`img[data-preview-for="${input.id}"]`); + updateCustomImagePreview(img, input.value); + }); + } + + function buildCustomImageFieldHTML(index, field, value, options, fieldOptions = {}) { + const opts = getOptions(options); + const { isPoster = false } = fieldOptions; + const requiredMark = field.required ? ' *' : ''; + const inputId = fieldId(opts, index, field.key); + const previewAttr = isPoster ? ' data-hsse-custom-preview="true" data-hsae-custom-preview="true"' : ''; + const posterHint = isPoster + ? `

You can place images in your "Jellyfin\\Server\\jellyfin-web" folder and use the "/web/image_name.png" path to access them.

` + : ''; + return ` +
+ + + ${posterHint} +
+ `; + } + + function renderCustomItemEditorHTML(index, item, state, options) { + const opts = getOptions(options); + const additionalExpanded = isCustomItemAdditionalExpanded(state, index, item); + const posterInputId = fieldId(opts, index, 'posterUrl'); + + return ` +
+
+
+ + +
+
+ + +
+
+ + +
+ ${buildCustomImageFieldHTML(index, CUSTOM_POSTER_FIELD, item.posterUrl || '', opts, { isPoster: true })} +
+ +
+
+ +
+
+ ${CUSTOM_ADDITIONAL_IMAGE_FIELDS.map(field => buildCustomImageFieldHTML(index, field, item[field.key] || '', opts)).join('')} +
+ `; + } + + function renderCustomItemTileHTML(index, item, canRemove, options, itemCount) { + const opts = getOptions(options); + const label = item.name?.trim() || `Item ${index + 1}`; + const isHidden = item.hidden === true; + const canMoveLeft = index > 0; + const canMoveRight = index < itemCount - 1; + const hideIcon = isHidden ? 'visibility_off' : 'visibility'; + const hideLabel = isHidden ? `Show ${label}` : `Hide ${label}`; + const removeTitle = canRemove + ? `Remove ${label}` + : (opts.allowRemove === false + ? 'Default section items cannot be deleted — hide instead' + : `Remove ${label}`); + + return ` +
+ +
${escapeHtml(label)}
+
+ + + + +
+
+ `; + } + + function renderCustomItemEditorPanelHTML(state, options) { + const opts = getOptions(options); + const editorIndex = state.customItemEditorIndex; + if (editorIndex == null || Number.isNaN(editorIndex)) return ''; + const item = state.customItems?.[editorIndex]; + if (!item) return ''; + + const title = item.name?.trim() + ? `Edit: ${item.name.trim()}` + : `Edit Item ${editorIndex + 1}`; + + return ` +
+ + +
+ `; + } + + function renderCustomItemsHTML(state, options) { + const opts = getOptions(options); + ensureCustomItemsInitialized(state); + const items = state.customItems; + const canRemove = opts.allowRemove !== false && items.length > 1; + + return ` +
+ ${opts.intro ? ` +
+ +

Name required; poster or link recommended. Other images optional.

+
` : ''} +
+ ${items.map((item, index) => renderCustomItemTileHTML(index, item, canRemove, opts, items.length)).join('')} + +
+ ${renderCustomItemEditorPanelHTML(state, opts)} +
+ `; + } + + function reindexTileDom(tile, index, itemCount) { + if (!tile) return; + tile.dataset.customItemIndex = String(index); + tile.querySelectorAll('[data-item-index]').forEach((el) => { + el.dataset.itemIndex = String(index); + }); + const tileImg = tile.querySelector('img[data-tile-index]'); + if (tileImg) tileImg.dataset.tileIndex = String(index); + + // Action order: left | hide | delete | right + const actions = tile.querySelectorAll('.hsse-custom-item-actions .hsse-custom-item-action'); + if (actions.length >= 4) { + actions[0].disabled = index <= 0; + actions[3].disabled = index >= itemCount - 1; + } + } + + function swapAdjacentCustomItemTiles(root, fromIndex, toIndex) { + if (!root || fromIndex === toIndex) return false; + const grid = root.querySelector('.hsse-custom-items-grid'); + if (!grid) return false; + const a = grid.querySelector(`[data-custom-item-index="${fromIndex}"]`); + const b = grid.querySelector(`[data-custom-item-index="${toIndex}"]`); + if (!a || !b) return false; + + const aNext = a.nextSibling; + const bNext = b.nextSibling; + if (aNext === b) { + grid.insertBefore(b, a); + } else if (bNext === a) { + grid.insertBefore(a, b); + } else { + grid.insertBefore(a, bNext); + grid.insertBefore(b, aNext); + } + + const itemCount = grid.querySelectorAll('[data-custom-item-index]').length; + reindexTileDom(a, toIndex, itemCount); + reindexTileDom(b, fromIndex, itemCount); + return true; + } + + function handleAction(action, el, root, state, options, onRefresh) { + const opts = getOptions(options); + const actions = opts.actions; + + switch (action) { + case actions.openEditor: { + const openIndex = parseInt(el.dataset.itemIndex, 10); + if (Number.isNaN(openIndex)) break; + if (state.customItemEditorIndex != null && state.customItemEditorIndex !== openIndex) { + collectCustomItemFromForm(root, state, state.customItemEditorIndex, opts); + } + state.customItemEditorIndex = openIndex; + if (typeof onRefresh === 'function') onRefresh(root); + break; + } + case actions.closeEditor: { + const discard = el.dataset.discard === 'true'; + if (!discard && state.customItemEditorIndex != null) { + collectCustomItemFromForm(root, state, state.customItemEditorIndex, opts); + } + state.customItemEditorIndex = null; + if (typeof onRefresh === 'function') onRefresh(root); + break; + } + case actions.addItem: { + collectCustomItemsFromForm(root, state, opts); + state.customItems.push(createBlankCustomItem()); + state.customItemEditorIndex = state.customItems.length - 1; + if (typeof onRefresh === 'function') onRefresh(root); + break; + } + case actions.removeItem: { + if (opts.allowRemove === false) break; + const removeIndex = parseInt(el.dataset.itemIndex, 10); + if (state.customItems.length <= 1 || Number.isNaN(removeIndex)) break; + if (state.customItemEditorIndex === removeIndex) { + collectCustomItemFromForm(root, state, removeIndex, opts); + } else { + collectCustomItemsFromForm(root, state, opts); + } + const itemToRemove = state.customItems[removeIndex]; + if (hasCustomItemContent(itemToRemove) && !confirm('Are you sure you want to remove this item?')) { + break; + } + state.customItems.splice(removeIndex, 1); + reindexCustomItemAdditionalExpanded(state, removeIndex); + if (state.customItemEditorIndex === removeIndex) { + state.customItemEditorIndex = null; + } else if (state.customItemEditorIndex > removeIndex) { + state.customItemEditorIndex -= 1; + } + if (typeof onRefresh === 'function') onRefresh(root); + break; + } + case actions.toggleImages: { + const toggleIndex = parseInt(el.dataset.itemIndex, 10); + if (Number.isNaN(toggleIndex)) break; + collectCustomItemFromForm(root, state, toggleIndex, opts); + if (!state.customItemAdditionalExpanded) state.customItemAdditionalExpanded = {}; + const item = state.customItems[toggleIndex]; + const currentlyExpanded = isCustomItemAdditionalExpanded(state, toggleIndex, item); + state.customItemAdditionalExpanded[toggleIndex] = !currentlyExpanded; + if (typeof onRefresh === 'function') onRefresh(root); + break; + } + case actions.toggleHide: { + const hideIndex = parseInt(el.dataset.itemIndex, 10); + if (Number.isNaN(hideIndex) || !state.customItems?.[hideIndex]) break; + collectCustomItemsFromForm(root, state, opts); + const item = state.customItems[hideIndex]; + item.hidden = !item.hidden; + + // Remove, then reinsert at the target slot among remaining items + state.customItems.splice(hideIndex, 1); + let insertAt; + if (item.hidden === true) { + // Hidden items stay at the end + insertAt = state.customItems.length; + } else { + // Unhide: last active item, immediately before the first still-hidden item + insertAt = state.customItems.findIndex((it) => it.hidden === true); + if (insertAt < 0) insertAt = state.customItems.length; + } + state.customItems.splice(insertAt, 0, item); + + if (insertAt !== hideIndex) { + moveCustomItemExpanded(state, hideIndex, insertAt); + if (state.customItemEditorIndex === hideIndex) { + state.customItemEditorIndex = insertAt; + } else if (state.customItemEditorIndex != null) { + if (hideIndex < state.customItemEditorIndex && insertAt >= state.customItemEditorIndex) { + state.customItemEditorIndex -= 1; + } else if (hideIndex > state.customItemEditorIndex && insertAt <= state.customItemEditorIndex) { + state.customItemEditorIndex += 1; + } + } + } + + if (typeof onRefresh === 'function') onRefresh(root); + break; + } + case actions.moveLeft: { + const leftIndex = parseInt(el.dataset.itemIndex, 10); + if (Number.isNaN(leftIndex) || leftIndex <= 0) break; + collectCustomItemsFromForm(root, state, opts); + const swapWith = leftIndex - 1; + const tmp = state.customItems[leftIndex]; + state.customItems[leftIndex] = state.customItems[swapWith]; + state.customItems[swapWith] = tmp; + swapCustomItemExpanded(state, leftIndex, swapWith); + if (state.customItemEditorIndex === leftIndex) state.customItemEditorIndex = swapWith; + else if (state.customItemEditorIndex === swapWith) state.customItemEditorIndex = leftIndex; + if (!swapAdjacentCustomItemTiles(root, leftIndex, swapWith) && typeof onRefresh === 'function') { + onRefresh(root); + } + break; + } + case actions.moveRight: { + const rightIndex = parseInt(el.dataset.itemIndex, 10); + if (Number.isNaN(rightIndex) || rightIndex >= state.customItems.length - 1) break; + collectCustomItemsFromForm(root, state, opts); + const swapWith = rightIndex + 1; + const tmp = state.customItems[rightIndex]; + state.customItems[rightIndex] = state.customItems[swapWith]; + state.customItems[swapWith] = tmp; + swapCustomItemExpanded(state, rightIndex, swapWith); + if (state.customItemEditorIndex === rightIndex) state.customItemEditorIndex = swapWith; + else if (state.customItemEditorIndex === swapWith) state.customItemEditorIndex = rightIndex; + if (!swapAdjacentCustomItemTiles(root, rightIndex, swapWith) && typeof onRefresh === 'function') { + onRefresh(root); + } + break; + } + default: + break; + } + } + + window.KefinHomeScreenCustomItemsEditor = { + CUSTOM_IMAGE_FIELDS, + createBlankCustomItem, + staticItemToDraft, + staticItemsToDrafts, + ensureCustomItemsInitialized, + hasValidCustomItems, + hasCustomItemContent, + buildStaticItemFromDraft, + collectCustomItemsFromForm, + collectCustomItemFromForm, + renderCustomItemsHTML, + refreshPreviews, + handleAction, + reindexCustomItemAdditionalExpanded, + getCustomItemsOptions: (overrides = {}) => getOptions({ + idPrefix: 'hsae-custom', + actionAttr: 'data-hsae-action', + actions: { + openEditor: 'hsae-open-custom-item-editor', + closeEditor: 'hsae-close-custom-item-editor', + addItem: 'hsae-add-custom-item', + removeItem: 'hsae-remove-custom-item', + toggleImages: 'hsae-toggle-custom-images', + toggleHide: 'hsae-toggle-custom-item-hide', + moveLeft: 'hsae-move-custom-item-left', + moveRight: 'hsae-move-custom-item-right' + }, + ...overrides + }) + }; +})(); diff --git a/configuration/homeScreenEditorProfiles.js b/configuration/homeScreenEditorProfiles.js new file mode 100644 index 0000000..8a324c0 --- /dev/null +++ b/configuration/homeScreenEditorProfiles.js @@ -0,0 +1,165 @@ +// KefinTweaks Home Screen Section Editor Profiles + +(function() { + 'use strict'; + + const EDITOR_PROFILES = { + full: { + queryFields: 'all', + advancedFields: 'all', + lockVisibility: false, + allowGroupChange: true, + allowItemSourceChange: true + }, + minimal: { + queryFields: ['limit'], + advancedFields: ['cache', 'useParentCard'], + lockVisibility: true, + allowGroupChange: false, + allowItemSourceChange: false, + hideCustomItems: false + }, + postProcessing: { + queryFields: ['limit'], + advancedFields: ['cache', 'sectionSort', 'minimumItems', 'itemLimit', 'limitBeforeSort', 'useParentCard'], + lockVisibility: true, + allowGroupChange: false, + allowItemSourceChange: false, + hideCustomItems: false + }, + normal: { + queryFields: ['limit', 'sortBy', 'sortOrder'], + advancedFields: ['cache', 'sectionSort', 'itemLimit', 'minimumItems', 'flattenSeries', 'limitBeforeSort', 'useParentCard'], + lockVisibility: true, + allowGroupChange: false, + allowItemSourceChange: false + }, + dateCutoffs: { + queryFields: ['limit', 'sortBy', 'sortOrder'], + advancedFields: ['cache', 'sectionSort', 'itemLimit', 'minimumItems', 'flattenSeries', 'limitBeforeSort', 'minAge', 'maxAge', 'useParentCard'], + lockVisibility: true, + allowGroupChange: false, + allowItemSourceChange: false + } + }; + + const SECTION_PROFILE_BY_ID = { + 'my-media': 'minimal', + 'my-media-small': 'minimal', + 'continueWatching': 'minimal', + 'nextUp': 'minimal', + 'continueWatchingAndNextUp': 'minimal', + 'continueListening': 'minimal', + 'continueReading': 'minimal', + 'upcoming': 'minimal', + 'thematic-story-origins': 'minimal', + 'thematic-core-concepts': 'minimal', + 'thematic-crime-stories': 'minimal', + 'thematic-character-perspective': 'minimal', + 'thematic-settings': 'minimal', + 'thematic-tone-style': 'minimal', + 'thematic-themes-topics': 'minimal', + + 'popularTVStudios': 'postProcessing', + 'popularMovieStudios': 'postProcessing', + 'popularStudios': 'postProcessing', + + 'recentlyReleased.movies': 'dateCutoffs', + 'recentlyReleased.episodes': 'dateCutoffs', + + 'liveTv-recordings': 'normal', + 'liveTv-timers': 'normal', + 'liveTv-browse': 'normal', + 'liveTv-on-now': 'normal', + 'liveTv-programs': 'normal', + 'liveTv-channels': 'normal', + 'liveTv-series-on-now': 'normal', + 'liveTv-movies-on-now': 'normal', + 'liveTv-sports-on-now': 'normal', + 'liveTv-news-on-now': 'normal', + 'liveTv-kids-on-now': 'normal', + 'imdbTop250': 'normal', + 'watchAgain': 'normal', + 'watchlist': 'normal', + 'resumePlaylists': 'normal', + 'completeCollections': 'normal', + 'playlists': 'normal', + 'collections': 'normal', + + 'seasonal.halloween.halloween-tag': 'normal', + 'seasonal.halloween.halloween-horror': 'normal', + 'seasonal.halloween.halloween-thriller': 'normal', + 'seasonal.christmas.christmas-movies': 'normal', + 'seasonal.christmas.christmas-episodes': 'normal', + 'seasonal.new-years.new-years-movies': 'normal', + 'seasonal.new-years.new-years-episodes': 'normal', + 'seasonal.valentines.valentines-movies': 'normal', + 'seasonal.valentines.valentines-episodes': 'normal', + 'seasonal.thanksgiving.thanksgiving-movies': 'normal', + 'seasonal.thanksgiving.thanksgiving-episodes': 'normal', + + 'genreMovies': 'normal', + 'spotlightGenre': 'normal', + 'directedByTopDirector': 'normal', + 'writtenByTopWriter': 'normal', + 'starringTopActor': 'normal', + 'starringActorRecentlyWatched': 'normal', + 'directedByDirectorRecentlyWatched': 'normal', + 'writtenByWriterRecentlyWatched': 'normal', + 'becauseYouWatched': 'normal', + 'becauseYouRecentlyWatched': 'normal', + 'becauseYouLiked': 'normal', + 'studioShows': 'normal', + 'spotlightNetwork': 'normal' + }; + + const SECTION_PROFILE_BY_PREFIX = [ + { prefix: 'custom-', profile: 'full' }, + { prefix: 'recently-added-', profile: 'minimal' }, + { prefix: 'popular-genres-', profile: 'minimal' } + ]; + + function isKnownDefaultSection(sectionId) { + if (!sectionId || sectionId.startsWith('custom-')) return false; + if (SECTION_PROFILE_BY_ID[sectionId]) return true; + return SECTION_PROFILE_BY_PREFIX.some((rule) => sectionId.startsWith(rule.prefix)); + } + + function resolveEditorProfile(section, context = {}) { + const id = section?.id; + if (!id) return 'full'; + + if (SECTION_PROFILE_BY_ID[id]) { + return SECTION_PROFILE_BY_ID[id]; + } + + const prefixRule = SECTION_PROFILE_BY_PREFIX.find((rule) => id.startsWith(rule.prefix)); + if (prefixRule) { + return prefixRule.profile; + } + + if (id === 'collections' && context.groupType === 'DISCOVERY_SECTION_GROUPS') { + return 'normal'; + } + + if (context.isCustomSection) { + return 'full'; + } + + console.warn('[KefinHomeScreenEditorProfiles] No profile for section:', id); + return 'normal'; + } + + function getEditorProfileDefinition(profileKey) { + return EDITOR_PROFILES[profileKey] || EDITOR_PROFILES.full; + } + + window.KefinHomeScreenEditorProfiles = { + EDITOR_PROFILES, + SECTION_PROFILE_BY_ID, + SECTION_PROFILE_BY_PREFIX, + isKnownDefaultSection, + resolveEditorProfile, + getEditorProfileDefinition + }; +})(); diff --git a/configuration/homeScreenSectionEditor.css b/configuration/homeScreenSectionEditor.css new file mode 100644 index 0000000..17224af --- /dev/null +++ b/configuration/homeScreenSectionEditor.css @@ -0,0 +1,1024 @@ +[data-modal-id="kefin-homescreen-section-create-flow"] .formDialogHeader .hsse-header-main { + display: flex; + align-items: center; + gap: 0.35em; + flex: 1; + min-width: 0; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .formDialogHeader .hsse-header-back { + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + padding: 0.2em; + margin: -0.2em 0; + color: inherit; + opacity: 0.85; + flex-shrink: 0; + border-radius: 4px; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .formDialogHeader .hsse-header-back:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.08); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .formDialogHeader .hsse-header-back .material-icons { + font-size: 1.375rem; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .formDialogHeader .hsse-header-title-wrap { + min-width: 0; + flex: 1; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .formDialogHeader .hsse-header-title-wrap h2, +[data-modal-id="kefin-homescreen-section-create-flow"] .formDialogHeader .hsse-header-title-wrap .dialogTitle { + margin: 0; + text-align: left; +} + +/* Fixed modal dimensions — content scrolls inside rather than resizing the dialog */ +.dialogContainer[data-modal-id="kefin-homescreen-section-create-flow"] > .formDialog { + width: 1400px; + height: 100%; +} + +.dialogContainer[data-modal-id="kefin-homescreen-section-create-flow"] [data-name="kefin-modal-content"] { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.dialogContainer[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-root { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-flow { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + height: 100%; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-flow-body { + flex: 1; + overflow: auto; + padding: 0.25em 0; + min-height: 0; + scrollbar-width: none; + -ms-overflow-style: none; +} + +html:not(.layout-mobile) [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-flow-body > * { + width: 800px; + margin-left: auto !important; + margin-right: auto !important; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-subtitle { + margin: 0 0 1.25em; + font-size: 0.9375rem; + line-height: 1.45; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25em; +} + +@media (max-width: 640px) { + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-grid { + grid-template-columns: 1fr; + } +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-card { + display: flex; + flex-direction: column; + text-align: left; + padding: 1.75em 1.75em 2.25em; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); + cursor: pointer; + transition: + border-color 0.2s, + background 0.2s; + font-family: inherit; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-card:hover { + border-color: rgba(0, 164, 220, 0.5); + background: rgba(0, 164, 220, 0.08); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-action { + margin-top: auto; + padding-top: 1.25em; + overflow: hidden; + font-size: 0.9375rem; + font-weight: 500; + line-height: 1.35; + color: var(--theme-primary-color, #00a4dc); + visibility: hidden; + opacity: 0; + transition: + opacity 0.2s ease, + visibility 0.2s ease, + max-height 0.2s ease, + padding-top 0.2s ease; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-card:hover .hsse-mode-action { + max-height: 2em; + visibility: visible; + opacity: 1; + color: var(--theme-primary-color, #00a4dc); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-icon { + display: flex; + align-items: center; + justify-content: center; + width: 3em; + height: 3em; + border-radius: 10px; + background: rgba(255, 255, 255, 0.08); + flex-shrink: 0; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-card.hsse-wizard .hsse-mode-icon { + background: var(--theme-primary-color, #00a4dc); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-card.hsse-wizard .hsse-mode-icon .material-icons { + color: #fff; + opacity: 1; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-badge { + font-size: 0.8125rem; + padding: 0.3em 0.75em; + border-radius: 999px; + background: rgba(0, 164, 220, 0.15); + color: var(--theme-primary-color, #00a4dc); + font-weight: 500; + flex-shrink: 0; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-title { + margin: 1em 0 0.5em; + font-size: 1.125rem; + font-weight: 600; + line-height: 1.25; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-desc { + margin: 0; + font-size: 0.9375rem; + line-height: 1.6; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + margin-top: 1.25em; + list-style: none; + padding: 0; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-tag-list li { + font-size: 0.8125rem; + padding: 0.35em 0.7em; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-steps { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35em; + padding: 0.75em 0 1em; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 1em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-step { + display: flex; + align-items: center; + gap: 0.35em; + font-size: 1.2rem; + padding: 0.3rem 0.65rem; + border-radius: 999px; + opacity: 0.65; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-step.hsse-active { + opacity: 1; + background: rgba(0, 164, 220, 0.15); + color: var(--theme-primary-color, #00a4dc); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-step.hsse-done { + opacity: 1; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-step-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.65em; + height: 1.65em; + border-radius: 50%; + font-size: 0.75rem; + background: rgba(255, 255, 255, 0.1); + line-height: 1.65em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-step.hsse-active .hsse-step-num { + background: var(--theme-primary-color, #00a4dc); + color: #000; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-step.hsse-clickable { + cursor: pointer; + border: none; + background: transparent; + font-family: inherit; + padding: 0.3rem 0.65rem; + color: inherit; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-step-sep { + font-size: 0.75rem; + opacity: 0.45; + user-select: none; + flex-shrink: 0; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-step.hsse-clickable:hover { + background: rgba(255, 255, 255, 0.06); + border-radius: 999px; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-type-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75em; +} + +@media (max-width: 640px) { + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-type-grid { + grid-template-columns: 1fr; + } +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-type-card { + display: flex; + gap: 0.85em; + text-align: left; + padding: 1.15em 1.25em; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + cursor: pointer; + font-family: inherit; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-type-label { + font-weight: 600; + font-size: 1rem; + line-height: 1.3; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-type-desc { + font-size: 0.875rem; + margin-top: 0.35em; + line-height: 1.5; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-type-card:hover { + border-color: rgba(0, 164, 220, 0.5); + background: rgba(0, 164, 220, 0.06); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-type-card.hsse-active { + border-color: rgba(0, 164, 220, 0.6); + background: rgba(0, 164, 220, 0.1); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-type-card-span { + grid-column: 1 / -1; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-required-mark { + color: #ff6b6b; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-items-intro { + margin-bottom: 0.75em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-items-intro .hsse-field-hint { + margin-top: 0.25em; + margin-bottom: 0; + font-size: 0.8125rem; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-items-stage { + position: relative; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-items-grid { + display: flex; + flex-wrap: wrap; + gap: 0.85em; + align-items: flex-start; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-tile-image { + position: relative; + width: 10rem; + height: 15rem; + padding: 0; + cursor: pointer; + font-family: inherit; + color: inherit; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-tile-image:hover { + border-color: rgba(0, 164, 220, 0.55); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-edit-badge { + position: absolute; + top: 0.25em; + right: 0.25em; + display: flex; + align-items: center; + justify-content: center; + width: 1.5em; + height: 1.5em; + border-radius: 4px; + background: rgba(0, 0, 0, 0.65); + color: #fff; + pointer-events: none; + z-index: 2; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-edit-badge .material-icons { + font-size: 0.95rem; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-tile-label { + font-size: 0.75rem; + line-height: 1.2; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-tile.is-hidden .hsse-custom-item-tile-image, +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-tile.is-hidden .hsse-custom-item-tile-label { + opacity: 0.4; + filter: grayscale(0.35); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 0.2em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-action, +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-trash { + font-size: 0; + padding: 0.3em; + min-height: 0; + min-width: 0; + line-height: 1; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-action:disabled { + opacity: 0.35; + cursor: default; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-action-icon, +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-trash-icon { + font-size: 1.1rem; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-add-tile { + display: flex; + align-items: center; + justify-content: center; + width: 10rem; + height: 15rem; + border: 1px dashed rgba(255, 255, 255, 0.28); + border-radius: 6px; + background: rgba(255, 255, 255, 0.02); + cursor: pointer; + padding: 0; + font-family: inherit; + color: rgba(255, 255, 255, 0.75); + transition: + border-color 0.2s, + background 0.2s, + color 0.2s; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-add-tile:hover { + border-color: rgba(0, 164, 220, 0.55); + background: rgba(0, 164, 220, 0.08); + color: #fff; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-add-icon { + font-size: 2rem; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-editor-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 1em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-editor-backdrop { + position: absolute; + inset: 0; + border: 0; + padding: 0; + margin: 0; + background: rgba(0, 0, 0, 0.55); + cursor: pointer; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-editor-panel { + position: relative; + z-index: 1; + width: min(24rem, 100%); + max-height: min(70vh, 100%); + overflow: auto; + padding: 0.85em 0.9em; + display: flex; + flex-direction: column; + gap: 0.65em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-editor-title { + font-weight: 600; + font-size: 0.9375rem; + line-height: 1.25; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-editor-footer { + display: flex; + justify-content: flex-end; + gap: 0.5em; + margin-top: 0.15em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-main { + display: grid; + grid-template-columns: minmax(0, 1fr) 4.5rem; + gap: 0.65em; + align-items: start; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-fields { + display: flex; + flex-direction: column; + gap: 0.45em; + min-width: 0; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-field, +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-image-field { + min-width: 0; +} + +.hsse-field.hsse-custom-field { + display: flex; + flex-direction: column; +} + +.hsse-custom-field-label { + font-size: 0.8125rem; + margin-bottom: 0.25em; + line-height: 1.25; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-editor-panel .fld { + padding-top: 0.4em; + padding-bottom: 0.4em; + font-size: 0.875rem; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-preview-col { + align-self: start; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-item-main-preview { + width: 4.5rem; + height: 6.75rem; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-image-preview { + flex-shrink: 0; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + background: rgba(0, 0, 0, 0.25); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-image-preview::before { + content: ""; + display: block; + width: 100%; + height: 100%; + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.04) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.04) 50%, + rgba(255, 255, 255, 0.04) 75%, + transparent 75%, + transparent + ); + background-size: 12px 12px; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-image-preview-img { + width: 100%; + height: 100%; + object-fit: contain; + position: relative; + z-index: 1; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-image-grid { + margin-top: 0.35em; + gap: 0.5em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-image-toggle-wrap { + margin-top: 0.45em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-image-toggle { + font-size: 0.8125rem; + padding: 0.4em 0.7em; + min-height: 0; +} + +@media (max-width: 640px) { + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-tile, + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-add-tile { + width: 5.5rem; + } + + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-tile-image, + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-add-tile { + width: 5.5rem; + height: 8.25rem; + } + + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-main { + grid-template-columns: 1fr; + } + + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-fields { + order: 1; + } + + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-preview-col { + order: 2; + display: flex; + justify-content: center; + width: 100%; + } + + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-item-main-preview { + width: 5.5rem; + height: 8.25rem; + } + + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-item-editor-panel { + width: 100%; + max-height: 85vh; + } +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-image-additional.hsse-collapsed { + display: none; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-seasonal-dates { + margin-top: 0.25em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-parent-list, +[data-modal-id="kefin-selection-popover"] .hsse-parent-list, +[data-modal-id="kefin-library-item-picker"] .hsse-parent-list { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + overflow: hidden; + max-height: calc(100% - 100px); + overflow-y: auto; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-parent-item, +[data-modal-id="kefin-selection-popover"] .hsse-parent-item, +[data-modal-id="kefin-library-item-picker"] .hsse-parent-item { + display: flex; + align-items: center; + gap: 0.75em; + width: 100%; + text-align: left; + padding: 0.85em 1.1em; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: transparent; + cursor: pointer; + color: inherit; + font-family: inherit; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-parent-name, +[data-modal-id="kefin-selection-popover"] .hsse-parent-name, +[data-modal-id="kefin-library-item-picker"] .hsse-parent-name { + font-weight: 500; + font-size: 1rem; + line-height: 1.35; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-parent-item:hover, +[data-modal-id="kefin-selection-popover"] .hsse-parent-item:hover, +[data-modal-id="kefin-library-item-picker"] .hsse-parent-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-parent-item.hsse-active, +[data-modal-id="kefin-selection-popover"] .hsse-parent-item.hsse-active, +[data-modal-id="kefin-library-item-picker"] .hsse-parent-item.hsse-active { + background: rgba(0, 164, 220, 0.1); + box-shadow: inset 3px 0 0 var(--theme-primary-color, #00a4dc); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-parent-item:last-child, +[data-modal-id="kefin-selection-popover"] .hsse-parent-item:last-child, +[data-modal-id="kefin-library-item-picker"] .hsse-parent-item:last-child { + border-bottom: none; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-parent-meta, +[data-modal-id="kefin-selection-popover"] .hsse-parent-meta, +[data-modal-id="kefin-library-item-picker"] .hsse-parent-meta { + font-size: 0.875rem; + margin-top: 0.15em; + line-height: 1.4; +} + +/* Shared chip base (library picker, advanced editor, create-flow) */ +.kefin-chip { + padding: 0.375rem 0.8125rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.04); + font-size: 0.875rem; + cursor: pointer; + font-family: inherit; + color: inherit; +} + +.kefin-chip.hsae-active, +.kefin-chip.hsse-active { + border-color: rgba(0, 164, 220, 0.6); + background: rgba(0, 164, 220, 0.15); + color: var(--theme-primary-color, #00a4dc); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + margin-top: 0.5em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-chip { + font-size: 1.2rem; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-appearance-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75em; +} + +@media (max-width: 640px) { + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-appearance-grid { + grid-template-columns: 1fr; + } +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-spotlight-options-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75em; +} + +@media (max-width: 640px) { + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-spotlight-options-grid { + grid-template-columns: 1fr; + } +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-appearance-card { + text-align: left; + padding: 1.15em 1.25em; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + cursor: pointer; + font-family: inherit; + align-content: start; + display: grid; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-appearance-card:hover { + border-color: rgba(0, 164, 220, 0.5); + background: rgba(0, 164, 220, 0.06); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-appearance-card.hsse-active { + border-color: rgba(0, 164, 220, 0.6); + background: rgba(0, 164, 220, 0.1); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-appearance-title { + font-weight: 600; + font-size: 1rem; + line-height: 1.3; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-appearance-desc { + font-size: 0.875rem; + margin-top: 0.35em; + line-height: 1.5; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-card-format-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5em; + margin-top: 0.75em; +} + +@media (max-width: 640px) { + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-card-format-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-card-format-btn { + text-align: left; + padding: 0.75em; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + cursor: pointer; + font-size: 0.875rem; + font-family: inherit; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-card-format-btn:hover { + border-color: rgba(0, 164, 220, 0.5); + background: rgba(0, 164, 220, 0.06); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-card-format-btn.hsse-active { + border-color: rgba(0, 164, 220, 0.6); + background: rgba(0, 164, 220, 0.1); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-appearance-preview { + margin-top: 1.5em; + padding-top: 1.25em; + border-top: 1px solid rgba(255, 255, 255, 0.1); + width: 100%; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-appearance-preview-container { + margin-top: 0.75em; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 0.75em; + background: rgba(0, 0, 0, 0.15); + overflow: hidden; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-section-block { + margin-bottom: 1.25em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-field-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1em; +} + +@media (max-width: 640px) { + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-field-grid { + grid-template-columns: 1fr; + } +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-field { + min-width: 0; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-section-label { + display: block; + font-weight: 600; + font-size: 1rem; + margin-bottom: 0.5em; + line-height: 1.3; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-custom-image-field-hint { + margin: 0.35em 0 0; + font-size: 0.8125rem; + line-height: 1.4; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-field-hint { + margin: 0 0 0.65em; + font-size: 0.875rem; + line-height: 1.5; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-label-with-info { + display: flex; + align-items: center; + gap: 0.35em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-info-icon { + font-size: 1rem; + opacity: 0.65; + cursor: help; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-info-icon:hover { + opacity: 1; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-empty, +[data-modal-id="kefin-selection-popover"] .hsse-empty, +[data-modal-id="kefin-library-item-picker"] .hsse-empty { + text-align: center; + padding: 2em 1em; + font-size: 1rem; + line-height: 1.5; +} + +/* Let Jellyfin text classes control color inside unstyled buttons */ +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-card, +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-type-card, +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-appearance-card, +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-card-format-btn, +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-chip { + color: inherit; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-confirm-list { + margin: 0; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + overflow: hidden; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-confirm-row { + display: grid; + grid-template-columns: 11em 1fr; + gap: 1em; + padding: 0.65em 1em; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-confirm-row:last-child { + border-bottom: none; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-confirm-row dt { + margin: 0; + font-size: 0.875rem; + line-height: 1.4; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-confirm-row dd { + margin: 0; + font-weight: 500; + font-size: 1rem; + line-height: 1.4; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-footer { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + gap: 1em; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-footer-back { + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + padding: 0.2em; + color: inherit; + opacity: 0.85; + flex-shrink: 0; + border-radius: 4px; + font-family: inherit; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-footer-back:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.08); +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-footer-back .material-icons { + font-size: 1.375rem; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-footer-end { + margin-left: auto; + display: flex; + align-items: center; + justify-content: flex-end; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-footer-hint { + text-align: right; + font-size: 0.875rem; + line-height: 1.45; +} + +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-type-icon, +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-parent-icon, +[data-modal-id="kefin-selection-popover"] .hsse-parent-icon, +[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-mode-icon .material-icons { + font-size: 1.375rem; + opacity: 0.9; +} + +@media (max-width: 640px) { + [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-confirm-row { + grid-template-columns: 1fr; + gap: 0.2em; + } +} diff --git a/configuration/homeScreenSectionEditor.js b/configuration/homeScreenSectionEditor.js new file mode 100644 index 0000000..07a63bd --- /dev/null +++ b/configuration/homeScreenSectionEditor.js @@ -0,0 +1,4785 @@ +// KefinTweaks Home Screen Section Editor +// Creation flow (mode select + wizard) and routing to the legacy advanced editor + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks Section Editor]', ...args); + + const CREATE_FLOW_MODAL_ID = 'kefin-homescreen-section-create-flow'; + const DEFAULT_SECTION_QUERY_LIMIT = 16; + + const QUERY_WIZARD_STEPS = ['type', 'parent', 'query', 'settings', 'appearance', 'confirm']; + const CUSTOM_WIZARD_STEPS = ['type', 'customItems', 'settings', 'appearance', 'confirm']; + const EXTERNAL_WIZARD_STEPS = ['type', 'externalList', 'settings', 'appearance', 'confirm']; + const WIZARD_STEP_LABELS = { + type: 'Type', + parent: 'Selection', + query: 'Query', + customItems: 'Create Items', + externalList: 'External List', + appearance: 'Appearance', + settings: 'Settings', + confirm: 'Confirm' + }; + + const CUSTOM_TYPE = { + type: 'Custom', + label: 'Custom Item Section', + description: 'Show items with custom images, links and names. These items can link to existing Jellyfin pages or to external websites. Use this to link your other services, share client download locations, provide help information, etc.', + icon: 'widgets' + }; + + const EXTERNAL_TYPE = { + type: 'External', + label: 'External List Section', + description: 'Match an MDBList (or compatible) URL against your Jellyfin library by IMDb/TMDB provider IDs. Requires an MDBList API key in Home Screen General settings.', + icon: 'link' + }; + + const CUSTOM_IMAGE_FIELDS = [ + { key: 'posterUrl', label: 'Poster URL', required: true }, + { key: 'thumbUrl', label: 'Thumb URL' }, + { key: 'backdropUrl', label: 'Backdrop URL' }, + { key: 'squareUrl', label: 'Square URL' }, + { key: 'bannerUrl', label: 'Banner URL' }, + { key: 'logoUrl', label: 'Logo URL' } + ]; + const CUSTOM_POSTER_FIELD = CUSTOM_IMAGE_FIELDS[0]; + const CUSTOM_ADDITIONAL_IMAGE_FIELDS = CUSTOM_IMAGE_FIELDS.slice(1); + + const SECTION_VISIBILITY_OPTIONS = [ + { value: 'normal', title: 'Normal', desc: 'These sections are loaded every time the home page loads.' }, + { value: 'seasonal', title: 'Seasonal', desc: 'These sections will only appear between the specified start and end date every time the home page loads.' }, + { value: 'discovery', title: 'Discovery', desc: 'These sections will appear randomly after scrolling to the bottom of the home page when loading new sections.' } + ]; + + const WIZARD_PARENT_DATA = { + PERSON_RESULT_LIMIT: 100, + STUDIO_RESULT_LIMIT: 100, + PARENT_LIST_DISPLAY_LIMIT: 100, + SEARCH_DEBOUNCE_MS: 300, + APPEARANCE_PREVIEW_DEBOUNCE_MS: 400 + }; + + const LOCAL_SEARCH_PARENT_TYPES = new Set(['Genre', 'Tag', 'Collection', 'Playlist', 'Library']); + const SERVER_SEARCH_PARENT_TYPES = new Set(['Person', 'Studio']); + const PARENT_FILTER_CHIP_TYPES = new Set(['Genre', 'Tag', 'Studio']); + + function getParentFilterItemTypes(wizardState, parentType = wizardState?.parentType) { + if (!parentType || !wizardState?.parentFilterItemTypesByType) return []; + const list = wizardState.parentFilterItemTypesByType[parentType]; + return Array.isArray(list) ? list : []; + } + + function ensureParentFilterItemTypes(wizardState, parentType = wizardState?.parentType) { + if (!wizardState.parentFilterItemTypesByType) { + wizardState.parentFilterItemTypesByType = {}; + } + if (!parentType) return []; + if (!Array.isArray(wizardState.parentFilterItemTypesByType[parentType])) { + wizardState.parentFilterItemTypesByType[parentType] = []; + } + return wizardState.parentFilterItemTypesByType[parentType]; + } + + function getParentSearch(wizardState, parentType = wizardState?.parentType) { + if (!parentType || !wizardState?.parentSearchByType) return ''; + return wizardState.parentSearchByType[parentType] || ''; + } + + function setParentSearch(wizardState, value, parentType = wizardState?.parentType) { + if (!wizardState.parentSearchByType) { + wizardState.parentSearchByType = {}; + } + if (!parentType) return; + wizardState.parentSearchByType[parentType] = value || ''; + // Keep legacy mirror in sync for the active type + if (parentType === wizardState.parentType) { + wizardState.search = wizardState.parentSearchByType[parentType]; + } + } + + function getParentFetchKey(wizardState, parentType = wizardState?.parentType) { + if (!parentType) return ''; + const search = getParentSearch(wizardState, parentType); + const types = getParentFilterItemTypes(wizardState, parentType).slice().sort().join(','); + return `${search}\0${types}`; + } + + function markParentFetchKey(wizardState, parentType = wizardState?.parentType) { + if (!wizardState.parentFetchKeyByType) { + wizardState.parentFetchKeyByType = {}; + } + if (!parentType) return; + wizardState.parentFetchKeyByType[parentType] = getParentFetchKey(wizardState, parentType); + } + + function parentFetchKeyMatches(wizardState, parentType = wizardState?.parentType) { + if (!parentType) return false; + return wizardState.parentFetchKeyByType?.[parentType] === getParentFetchKey(wizardState, parentType); + } + + const PARENT_TYPES = [ + { type: 'Genre', label: 'Genre', plural: 'genres', description: 'Show items that share a Genre, like Action or Comedy.', icon: 'theater_comedy', queryField: 'GenreIds' }, + { type: 'Collection', label: 'Collection', plural: 'collections', description: 'Show items from a specific Collection in your library.', icon: 'collections_bookmark', queryField: 'ParentId' }, + { type: 'Playlist', label: 'Playlist', plural: 'playlists', description: 'Show items from a specific Playlist in your library.', icon: 'playlist_play', queryField: 'ParentId' }, + { type: 'Studio', label: 'Studio', plural: 'studios', description: 'Show items from a specific Film Studio or TV Network.', icon: 'apartment', queryField: 'StudioIds' }, + { type: 'Person', label: 'Person', plural: 'people', description: 'Show items that a specific Person has acted in or has directed, written, produced, etc.', icon: 'person', queryField: 'PersonIds' }, + { type: 'Tag', label: 'Tag', plural: 'tags', description: 'Show items that share a Tag, like Time Travel or Revenge.', icon: 'sell', queryField: 'Tags' } + ]; + + const MULTI_SELECT_TYPES = new Set(['Genre', 'Tag', 'Person', 'Studio']); + + function isMultiSelectType(type) { + return MULTI_SELECT_TYPES.has(type); + } + + function getPrimaryParent(state) { + return state.parents?.[0] ?? null; + } + + function pickRandom(items) { + return items[Math.floor(Math.random() * items.length)]; + } + + // --- Wizard parent data (live Jellyfin API) --- + + function getWizardSteps(state) { + if (state?.parentType === 'Custom') return CUSTOM_WIZARD_STEPS; + if (state?.parentType === 'External') return EXTERNAL_WIZARD_STEPS; + return QUERY_WIZARD_STEPS; + } + + function isCustomType(state) { + return state?.parentType === 'Custom'; + } + + function isExternalType(state) { + return state?.parentType === 'External'; + } + + function getStepIndex(step, state) { + return getWizardSteps(state).indexOf(step); + } + + function createBlankCustomItem() { + if (window.KefinHomeScreenCustomItemsEditor?.createBlankCustomItem) { + return window.KefinHomeScreenCustomItemsEditor.createBlankCustomItem(); + } + return { + name: '', + cardFooter: '', + cardUrl: '', + posterUrl: '', + thumbUrl: '', + backdropUrl: '', + squareUrl: '', + bannerUrl: '', + logoUrl: '', + hidden: false + }; + } + + function getWizardCustomItemsOptions() { + return window.KefinHomeScreenCustomItemsEditor?.getCustomItemsOptions({ + idPrefix: 'hsse-custom', + actionAttr: 'data-hsse-action', + actions: { + openEditor: 'wizard-open-custom-item-editor', + closeEditor: 'wizard-close-custom-item-editor', + addItem: 'wizard-add-custom-item', + removeItem: 'wizard-remove-custom-item', + toggleImages: 'wizard-toggle-custom-images', + toggleHide: 'wizard-toggle-custom-item-hide', + moveLeft: 'wizard-move-custom-item-left', + moveRight: 'wizard-move-custom-item-right' + } + }); + } + + function ensureCustomItemsInitialized(state) { + if (!state.customItems?.length) { + state.customItems = [createBlankCustomItem()]; + } + } + + function hasAdditionalCustomImages(item) { + return CUSTOM_ADDITIONAL_IMAGE_FIELDS.some(field => item[field.key]?.trim()); + } + + function isCustomItemAdditionalExpanded(wizardState, index, item) { + const explicit = wizardState.customItemAdditionalExpanded?.[index]; + if (explicit === true) return true; + if (explicit === false) return false; + return hasAdditionalCustomImages(item); + } + + function reindexCustomItemAdditionalExpanded(wizardState, removedIndex) { + if (!wizardState.customItemAdditionalExpanded) return; + const next = {}; + Object.entries(wizardState.customItemAdditionalExpanded).forEach(([key, value]) => { + const i = parseInt(key, 10); + if (Number.isNaN(i) || value === undefined) return; + if (i < removedIndex) next[i] = value; + else if (i > removedIndex) next[i - 1] = value; + }); + wizardState.customItemAdditionalExpanded = next; + } + + function hasValidCustomItems(items) { + return (items || []).some(item => item.name?.trim() && item.posterUrl?.trim()); + } + + function hasCustomItemContent(item) { + if (!item) return false; + return [ + item.name, + item.cardFooter, + item.cardUrl, + item.posterUrl, + item.thumbUrl, + item.backdropUrl, + item.squareUrl, + item.bannerUrl, + item.logoUrl + ].some(value => value?.trim()); + } + + function buildStaticItemFromDraft(draft) { + if (window.KefinHomeScreenCustomItemsEditor?.buildStaticItemFromDraft) { + return window.KefinHomeScreenCustomItemsEditor.buildStaticItemFromDraft(draft); + } + const name = draft.name?.trim(); + const posterUrl = draft.posterUrl?.trim(); + if (!name || !posterUrl) return null; + + const imageKeys = ['posterUrl', 'thumbUrl', 'backdropUrl', 'squareUrl', 'bannerUrl', 'logoUrl']; + const provided = {}; + imageKeys.forEach(key => { + const value = draft[key]?.trim(); + if (value) provided[key] = value; + }); + + const item = { Name: name, Type: draft.type || 'Folder' }; + if (draft.id) item.Id = draft.id; + const cardUrl = draft.cardUrl?.trim(); + if (cardUrl) item.cardUrl = cardUrl; + const cardFooter = draft.cardFooter?.trim(); + if (cardFooter) item.cardFooter = cardFooter; + if (draft.hidden === true) item.hidden = true; + + const keys = Object.keys(provided); + if (keys.length === 1 && provided.posterUrl) { + item.imageUrl = provided.posterUrl; + } else { + Object.assign(item, provided); + } + return item; + } + + function collectCustomItemFromForm(root, wizardState, index) { + if (!root || !wizardState.customItems?.[index]) return; + const item = wizardState.customItems[index]; + const nameEl = root.querySelector(`#hsse-custom-${index}-name`); + const cardFooterEl = root.querySelector(`#hsse-custom-${index}-cardFooter`); + const cardUrlEl = root.querySelector(`#hsse-custom-${index}-cardUrl`); + if (nameEl) item.name = nameEl.value; + if (cardFooterEl) item.cardFooter = cardFooterEl.value; + if (cardUrlEl) item.cardUrl = cardUrlEl.value; + CUSTOM_IMAGE_FIELDS.forEach(field => { + const el = root.querySelector(`#hsse-custom-${index}-${field.key}`); + if (el) item[field.key] = el.value; + }); + } + + function collectCustomItemsFromForm(root, wizardState) { + if (!root || !wizardState.customItems) return; + const editorIndex = wizardState.customItemEditorIndex; + if (editorIndex != null && !Number.isNaN(editorIndex)) { + collectCustomItemFromForm(root, wizardState, editorIndex); + } + } + + function updateCustomImagePreview(imgEl, url) { + if (!imgEl) return; + const trimmed = (url || '').trim(); + if (!trimmed) { + imgEl.removeAttribute('src'); + imgEl.style.display = 'none'; + imgEl.dataset.previewState = 'empty'; + return; + } + imgEl.style.display = ''; + imgEl.dataset.previewState = 'loading'; + imgEl.onerror = () => { + imgEl.style.display = 'none'; + imgEl.dataset.previewState = 'error'; + }; + imgEl.onload = () => { + imgEl.style.display = ''; + imgEl.dataset.previewState = 'loaded'; + }; + imgEl.src = trimmed; + } + + function markStepReached(wizardState, stepIndex) { + if (stepIndex > wizardState.maxReachedStepIndex) { + wizardState.maxReachedStepIndex = stepIndex; + } + } + + function syncCurrentStepFromForm(root, wizardState) { + if (!root) return; + switch (wizardState.step) { + case 'query': + collectQueryStepFromForm(root, wizardState); + break; + case 'customItems': + collectCustomItemsFromForm(root, wizardState); + break; + case 'externalList': + collectExternalListFromForm(root, wizardState); + break; + case 'appearance': + collectAppearanceStepFromForm(root, wizardState); + break; + case 'settings': + collectSettingsFromForm(root, wizardState); + break; + default: + break; + } + } + + function filterParentItemsBySearch(items, search) { + const q = (search || '').trim().toLowerCase(); + if (!q) return items; + return items.filter(i => i.name.toLowerCase().includes(q)); + } + + function getDisplayParentItems(fullList, search, limit = WIZARD_PARENT_DATA.PARENT_LIST_DISPLAY_LIMIT) { + return filterParentItemsBySearch(fullList, search).slice(0, limit); + } + + function renderParentItemTypeChipsHTML(activeTypes, actionAttr, toggleAction) { + return ` +
+ +

${escapeHtml(getIncludedItemTypesHintText(activeTypes))}

+
+ ${ITEM_TYPE_OPTIONS.map(type => { + const active = activeTypes.includes(type); + return ``; + }).join('')} +
+
+ `; + } + + function formatItemCountMeta(count) { + if (count == null || Number.isNaN(count)) return ''; + const n = parseInt(count, 10); + return n === 1 ? '1 item' : `${n} items`; + } + + /** + * Unwrap progressive apiHelper.getQuery results ({ data, dataPromise/ensureData }). + * On cache miss, data is null until ensureData()/dataPromise resolves — never treat + * the wrapper object itself as the payload. + */ + async function resolveQueryPayload(result) { + if (!result) return null; + if (typeof result.ensureData === 'function') { + return await result.ensureData(); + } + if (result.dataPromise != null) { + return await result.dataPromise; + } + return result; + } + + function asItemArray(payload) { + if (Array.isArray(payload?.Items)) return payload.Items; + if (Array.isArray(payload)) return payload; + return []; + } + + async function fetchGenres(includeItemTypes) { + if (!window.dataHelper?.getFilterGenreItems) return []; + const types = (includeItemTypes && includeItemTypes.length > 0) ? includeItemTypes : null; + return window.dataHelper.getFilterGenreItems(types, { useCache: true }); + } + + async function fetchTags(includeItemTypes) { + if (!window.dataHelper?.getFilterTagItems) return []; + const types = (includeItemTypes && includeItemTypes.length > 0) ? includeItemTypes : null; + return window.dataHelper.getFilterTagItems(types, { useCache: true }); + } + + async function fetchCollectionsOrPlaylists(parentType) { + if (!window.apiHelper?.getItems) return []; + const itemType = parentType === 'Playlist' ? 'Playlist' : 'BoxSet,CollectionFolder'; + const data = await window.apiHelper.getItems( + { IncludeItemTypes: itemType, Recursive: true, Fields: 'ItemCounts' }, + true, + 24 * 60 * 60 * 1000 + ); + return asItemArray(data).map(item => ({ + id: item.Id, + name: item.Name, + meta: formatItemCountMeta(item.ChildCount ?? item.UserData?.UnplayedItemCount ?? item.RecursiveItemCount) + })); + } + + async function fetchStudios(search, includeItemTypes) { + const userId = window.ApiClient?.getCurrentUserId(); + const serverAddress = window.ApiClient?.serverAddress(); + if (!userId || !serverAddress || !window.apiHelper?.getQuery) return []; + + const params = new URLSearchParams({ + UserId: userId, + Limit: String(WIZARD_PARENT_DATA.STUDIO_RESULT_LIMIT), + Fields: 'PrimaryImageAspectRatio' + }); + + const trimmed = (search || '').trim(); + if (trimmed.length === 1) { + params.set('NameStartsWith', trimmed); + } else if (trimmed.length > 1) { + params.set('SearchTerm', trimmed); + } + + const types = (includeItemTypes && includeItemTypes.length > 0) + ? (Array.isArray(includeItemTypes) ? includeItemTypes.join(',') : String(includeItemTypes)) + : ''; + if (types) { + params.set('IncludeItemTypes', types); + } + + const url = `${serverAddress}/Studios?${params.toString()}`; + const useCache = !trimmed && !types; + const result = await window.apiHelper.getQuery(url, { + useCache, + ttl: 24 * 60 * 60 * 1000 + }); + const data = await resolveQueryPayload(result); + return asItemArray(data).map(item => ({ + id: item.Id, + name: item.Name, + meta: formatItemCountMeta(item.ChildCount ?? item.SeriesCount) + })).filter(item => item.id); + } + + async function fetchPersons(search) { + const userId = window.ApiClient?.getCurrentUserId(); + const serverAddress = window.ApiClient?.serverAddress(); + if (!userId || !serverAddress) return []; + + const params = new URLSearchParams({ + UserId: userId, + Limit: String(WIZARD_PARENT_DATA.PERSON_RESULT_LIMIT), + Fields: 'PrimaryImageAspectRatio,ProductionYear', + EnableTotalRecordCount: 'false' + }); + + const trimmed = (search || '').trim(); + if (trimmed.length === 1) { + params.set('NameStartsWith', trimmed); + } else if (trimmed.length > 1) { + params.set('SearchTerm', trimmed); + } + + const url = `${serverAddress}/Persons?${params.toString()}`; + let data; + if (window.apiHelper?.getQuery) { + const result = await window.apiHelper.getQuery(url, { useCache: false }); + data = await resolveQueryPayload(result); + } else if (window.ApiClient?.fetch) { + const resp = await window.ApiClient.fetch({ url, method: 'GET' }); + data = await resp.json(); + } else { + return []; + } + + return asItemArray(data).map(item => ({ + id: item.Id, + name: item.Name, + meta: item.ProductionYear ? String(item.ProductionYear) : '' + })); + } + + async function fetchLibraries() { + const userId = window.ApiClient?.getCurrentUserId(); + if (!userId) return []; + + try { + if (window.ApiClient?.getUserViews) { + const result = await window.ApiClient.getUserViews({}, userId); + return asItemArray(result).map(item => ({ + id: item.Id, + name: item.Name, + meta: item.CollectionType ? humanizeLabel(item.CollectionType) : '' + })).filter(item => item.id); + } + + const serverAddress = window.ApiClient?.serverAddress(); + if (!serverAddress || !window.apiHelper?.getQuery) return []; + const url = `${serverAddress}/UserViews?UserId=${userId}`; + const result = await window.apiHelper.getQuery(url, { useCache: true, ttl: 24 * 60 * 60 * 1000 }); + const data = await resolveQueryPayload(result); + return asItemArray(data).map(item => ({ + id: item.Id, + name: item.Name, + meta: item.CollectionType ? humanizeLabel(item.CollectionType) : '' + })).filter(item => item.id); + } catch (err) { + console.warn('[KefinTweaks Section Editor] Failed to fetch libraries:', err); + return []; + } + } + + async function fetchWizardParentItems(parentType, options = {}) { + const { search = '', includeItemTypes = [] } = options; + if (!window.ApiClient?.getCurrentUserId?.()) { + throw new Error('Server connection not available'); + } + + switch (parentType) { + case 'Genre': + return fetchGenres(includeItemTypes); + case 'Tag': + return fetchTags(includeItemTypes); + case 'Collection': + case 'Playlist': + return fetchCollectionsOrPlaylists(parentType); + case 'Studio': + return fetchStudios(search, includeItemTypes); + case 'Person': + return fetchPersons(search); + case 'Library': + return fetchLibraries(); + default: + return []; + } + } + + const ALL_ITEM_TYPES = [ + 'Movie', 'Series', 'Episode', 'Season', + 'AggregateFolder', 'Audio', 'AudioBook', 'BasePluginFolder', 'Book', 'BoxSet', 'Channel', + 'ChannelFolderItem', 'CollectionFolder', 'Folder', 'Genre', 'ManualPlaylistsFolder', + 'LiveTvChannel', 'LiveTvProgram', 'MusicAlbum', 'MusicArtist', 'MusicGenre', 'MusicVideo', + 'Person', 'Photo', 'PhotoAlbum', 'Playlist', 'PlaylistsFolder', 'Program', 'Recording', + 'Studio', 'Trailer', 'TvChannel', 'TvProgram', 'UserRootFolder', 'UserView', 'Video', 'Year' + ]; + + const LIBRARY_TYPE_META = { + type: 'Library', + label: 'Library', + plural: 'libraries', + description: 'Show items from a specific Library in your collection.', + icon: 'video_library', + queryField: 'ParentId' + }; + + const CUSTOM_PARENT_META = { + type: 'Custom', + label: 'Custom', + plural: 'custom parents', + description: 'Show items from a custom parent item from your collection including specific folders within your libraries.', + icon: 'folder_open', + queryField: 'ParentId' + }; + + const LIBRARY_ITEM_META = { + type: 'LibraryItem', + label: 'Item', + plural: 'items', + description: 'Search your library for specific items.', + icon: 'movie', + queryField: 'Ids' + }; + + const LIBRARY_ITEM_TYPE_OPTIONS = ['Movie', 'Series', 'Episode', 'Season', 'MusicAlbum', 'Audio', 'MusicVideo', 'Book']; + + const PARENT_SELECTION_TYPES = ['Collection', 'Playlist', 'Library', 'Custom']; + const FACET_SELECTION_TYPES = ['Genre', 'Tag', 'Person', 'Studio']; + + function getSelectionTypeMeta(type) { + if (type === 'Library') return LIBRARY_TYPE_META; + if (type === 'Custom') return CUSTOM_PARENT_META; + if (type === 'LibraryItem') return LIBRARY_ITEM_META; + return PARENT_TYPES.find(t => t.type === type) || null; + } + + async function fetchLibrarySearchItems(options = {}) { + const { search = '', includeItemTypes = [], limit = 100 } = options; + if (!window.apiHelper?.getItems || !window.ApiClient?.getCurrentUserId?.()) { + return []; + } + + const params = { + Recursive: true, + SortBy: 'SortName', + SortOrder: 'Ascending', + Fields: 'PrimaryImageAspectRatio,ProductionYear,Type', + Limit: limit + }; + const trimmed = (search || '').trim(); + if (trimmed) params.SearchTerm = trimmed; + if (includeItemTypes.length) params.IncludeItemTypes = includeItemTypes.join(','); + + const data = await window.apiHelper.getItems(params, false, 60000); + return (data?.Items || []).map(item => ({ + id: item.Id, + name: item.Name, + type: item.Type, + meta: item.ProductionYear + ? String(item.ProductionYear) + : (item.Type ? humanizeLabel(item.Type) : '') + })).filter(item => item.id); + } + + function fetchSelectionItems(type, options = {}) { + return fetchWizardParentItems(type, options); + } + + function selectionItemNeedsLabel(item) { + return !!(item?.id && (!item.name || item.name === item.id)); + } + + async function resolveItemsByGetItems(ids, includeItemTypes, idToName) { + if (!ids.length || !window.apiHelper?.getItems || !window.ApiClient?.getCurrentUserId?.()) return; + + const options = { + Ids: ids.join(','), + Recursive: true, + Fields: 'PrimaryImageAspectRatio' + }; + if (includeItemTypes) { + options.IncludeItemTypes = includeItemTypes; + } + + const data = await window.apiHelper.getItems(options, true, 300000); + (data?.Items || []).forEach(item => { + if (item?.Id && item?.Name) { + idToName.set(String(item.Id), item.Name); + } + }); + } + + /** + * Resolve human-readable labels for selection chips saved with IDs only. + * @param {string} type - Collection|Playlist|Library|Custom|Genre|Tag|Person|Studio + * @param {Array<{id:string,name?:string}>} items + * @param {Object} [options] + * @param {string[]} [options.includeItemTypes] + * @returns {Promise>} + */ + async function resolveSelectionItemLabels(type, items, options = {}) { + if (!Array.isArray(items) || items.length === 0) return items; + + const unresolved = items.filter(selectionItemNeedsLabel); + if (unresolved.length === 0) return items; + + const idToName = new Map(); + const ids = unresolved.map(item => item.id); + const includeItemTypes = options.includeItemTypes || []; + + try { + switch (type) { + case 'Collection': + await resolveItemsByGetItems(ids, 'BoxSet,CollectionFolder', idToName); + break; + case 'Playlist': + await resolveItemsByGetItems(ids, 'Playlist', idToName); + break; + case 'Library': + (await fetchLibraries()).forEach(item => idToName.set(item.id, item.name)); + await resolveItemsByGetItems(ids.filter(id => !idToName.has(id)), null, idToName); + break; + case 'Custom': + await resolveItemsByGetItems(ids, null, idToName); + break; + case 'Genre': { + (await fetchGenres(includeItemTypes.length ? includeItemTypes : null)) + .forEach(item => idToName.set(item.id, item.name)); + const remaining = ids.filter(id => !idToName.has(id)); + if (remaining.length) { + await resolveItemsByGetItems(remaining, 'Genre', idToName); + } + break; + } + case 'Tag': + (await fetchTags(includeItemTypes.length ? includeItemTypes : null)) + .forEach(item => idToName.set(item.id, item.name)); + break; + case 'Person': + await resolveItemsByGetItems(ids, 'Person', idToName); + break; + case 'Studio': { + (await fetchStudios('', includeItemTypes.length ? includeItemTypes : null)) + .forEach(item => idToName.set(item.id, item.name)); + const remaining = ids.filter(id => !idToName.has(id)); + if (remaining.length) { + await resolveItemsByGetItems(remaining, 'Studio', idToName); + } + break; + } + case 'LibraryItem': + await resolveItemsByGetItems(ids, null, idToName); + break; + default: + await resolveItemsByGetItems(ids, null, idToName); + break; + } + } catch (err) { + WARN('Error resolving selection labels:', err); + } + + return items.map(item => { + const resolvedName = idToName.get(item.id); + if (resolvedName) { + return { ...item, name: resolvedName }; + } + return item; + }); + } + + /** + * Render a searchable selection list (shared by wizard Selection step and advanced popovers). + */ + function renderSelectionListHTML({ + type, + items = [], + selected = [], + search = '', + status = 'idle', + error = '', + actionAttr = 'data-hsse-action', + selectAction = 'wizard-select-parent', + retryAction = 'wizard-retry-parent-load' + } = {}) { + const meta = getSelectionTypeMeta(type); + const selectedIds = new Set((selected || []).map(s => (typeof s === 'object' ? s.id : s))); + + if (status === 'loading') { + return `
Loading ${escapeHtml(meta?.plural || 'items')}…
`; + } + if (status === 'error') { + return ` +
${escapeHtml(error || 'Failed to load items.')}
+ + `; + } + if (items.length === 0) { + const emptyMsg = search + ? `No ${meta?.plural || 'items'} match “${escapeHtml(search)}”` + : `No ${meta?.plural || 'items'} found`; + return `
${emptyMsg}
`; + } + + return items.map(item => { + const isSelected = selectedIds.has(item.id); + const icon = item.type + ? (window.cardBuilder?.getItemMaterialIcon?.({ Type: item.type }) || meta?.icon || 'folder') + : (meta?.icon || 'folder'); + return ` + + `; + }).join(''); + } + + function getWizardParentListItems(wizardState) { + const type = wizardState.parentType; + const search = getParentSearch(wizardState, type); + if (type === 'Person') { + return wizardState.personSearchResults || []; + } + if (SERVER_SEARCH_PARENT_TYPES.has(type)) { + return wizardState.parentItemsByType[type] || []; + } + return getDisplayParentItems( + wizardState.parentItemsByType[type] || [], + search + ); + } + + function renderParentListHTML(wizardState) { + const items = getWizardParentListItems(wizardState); + const search = getParentSearch(wizardState); + + return renderSelectionListHTML({ + type: wizardState.parentType, + items, + selected: wizardState.parents || [], + search, + status: wizardState.parentListStatus, + error: wizardState.parentListError, + actionAttr: 'data-hsse-action', + selectAction: 'wizard-select-parent', + retryAction: 'wizard-retry-parent-load' + }); + } + + /** + * Open a Selection popover (same UX as wizard Selection step). + * @param {Object} options + * @param {string} options.type - Collection|Playlist|Library|Custom|Genre|Tag|Person|Studio + * @param {boolean} options.multi - multi-select mode + * @param {Array<{id:string,name:string}>} options.selected - initially selected items + * @param {string[]} [options.includeItemTypes] + * @param {Function} options.onConfirm - called with selected items array + */ + function openSelectionPopover(options = {}) { + const { + type, + multi = false, + selected = [], + includeItemTypes = [], + onConfirm + } = options; + + const meta = getSelectionTypeMeta(type); + const modalId = 'kefin-selection-popover'; + if (window.ModalSystem?.isOpen?.(modalId)) { + window.ModalSystem.close(modalId); + } + + let draftSelected = (selected || []).map(s => (typeof s === 'object' ? { ...s } : { id: s, name: s })); + let search = ''; + let itemsCache = []; + let personResults = []; + let activeItemTypes = [...(includeItemTypes || [])]; + let listStatus = type === 'Custom' ? 'idle' : 'loading'; + let listError = ''; + let searchTimer = null; + let loadRequestId = 0; + + const content = document.createElement('div'); + content.className = 'hsse-selection-popover'; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.5em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + function getVisibleItems() { + if (type === 'Person') return personResults; + if (SERVER_SEARCH_PARENT_TYPES.has(type)) return itemsCache; + return getDisplayParentItems(itemsCache, search); + } + + function renderBody(options = {}) { + const preserveScroll = options.preserveScroll === true; + const prevList = preserveScroll ? content.querySelector('#hsse-selection-list') : null; + const prevScrollTop = prevList ? prevList.scrollTop : 0; + if (type === 'Custom') { + const currentGuid = draftSelected[0]?.id || ''; + content.innerHTML = ` +
+ +
Enter the GUID of a library folder or other parent item.
+ +
+ `; + return; + } + + const filterChips = PARENT_FILTER_CHIP_TYPES.has(type) + ? renderParentItemTypeChipsHTML(activeItemTypes, 'data-sel-action', 'toggle-filter-type') + : ''; + + content.innerHTML = ` + ${filterChips} +
+ +
+
+ ${renderSelectionListHTML({ + type, + items: getVisibleItems(), + selected: draftSelected, + search, + status: listStatus, + error: listError, + actionAttr: 'data-sel-action', + selectAction: 'select-item', + retryAction: 'retry-load' + })} +
+ `; + + if (preserveScroll) { + const nextList = content.querySelector('#hsse-selection-list'); + if (nextList) nextList.scrollTop = prevScrollTop; + } + } + + async function loadItems(forceSearch) { + if (type === 'Custom') return; + const requestId = ++loadRequestId; + listStatus = 'loading'; + listError = ''; + renderBody(); + + try { + const fetched = await fetchSelectionItems(type, { + search: forceSearch != null ? forceSearch : search, + includeItemTypes: PARENT_FILTER_CHIP_TYPES.has(type) ? activeItemTypes : includeItemTypes + }); + if (requestId !== loadRequestId) return; + if (type === 'Person') { + personResults = fetched; + } else { + itemsCache = fetched; + } + listStatus = 'idle'; + } catch (err) { + if (requestId !== loadRequestId) return; + listStatus = 'error'; + listError = err?.message || 'Failed to load items.'; + } + renderBody(); + } + + function toggleItem(id, name) { + const existing = draftSelected.findIndex(p => p.id === id); + if (multi) { + if (existing >= 0) draftSelected.splice(existing, 1); + else draftSelected.push({ id, name }); + } else { + draftSelected = existing >= 0 ? [] : [{ id, name }]; + } + renderBody({ preserveScroll: true }); + const searchEl = content.querySelector('#hsse-selection-search'); + if (searchEl) { + searchEl.focus(); + const len = searchEl.value.length; + searchEl.setSelectionRange(len, len); + } + } + + window.ModalSystem.create({ + id: modalId, + title: meta ? `Select ${meta.label}` : 'Select', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + onOpen: (modalInstance) => { + const root = modalInstance.dialogContent; + const foot = modalInstance.dialogFooter; + + renderBody(); + if (type !== 'Custom') loadItems(); + + root.addEventListener('click', (e) => { + const actionEl = e.target.closest('[data-sel-action]'); + if (!actionEl || !root.contains(actionEl)) return; + const action = actionEl.dataset.selAction; + if (action === 'select-item') { + toggleItem(actionEl.dataset.parentId, actionEl.dataset.parentName); + } else if (action === 'retry-load') { + loadItems(); + } else if (action === 'toggle-filter-type') { + const chipType = actionEl.dataset.itemType; + const idx = activeItemTypes.indexOf(chipType); + if (idx >= 0) activeItemTypes.splice(idx, 1); + else activeItemTypes.push(chipType); + loadItems(); + } + }); + + root.addEventListener('input', (e) => { + if (e.target.id === 'hsse-selection-search') { + search = e.target.value; + if (searchTimer) clearTimeout(searchTimer); + if (SERVER_SEARCH_PARENT_TYPES.has(type)) { + searchTimer = setTimeout(() => loadItems(search), WIZARD_PARENT_DATA.SEARCH_DEBOUNCE_MS); + } else { + renderBody(); + const searchEl = content.querySelector('#hsse-selection-search'); + if (searchEl) { + searchEl.focus(); + const len = searchEl.value.length; + searchEl.setSelectionRange(len, len); + } + } + } else if (e.target.id === 'hsse-selection-guid') { + const guid = e.target.value.trim(); + draftSelected = guid ? [{ id: guid, name: guid }] : []; + } + }); + + foot.addEventListener('click', (e) => { + const actionEl = e.target.closest('[data-sel-action]'); + if (!actionEl) return; + if (actionEl.dataset.selAction === 'cancel') { + window.ModalSystem.close(modalId); + return; + } + if (actionEl.dataset.selAction === 'confirm') { + if (type === 'Custom') { + const guidInput = root.querySelector('#hsse-selection-guid'); + const guid = guidInput?.value?.trim() || ''; + draftSelected = guid ? [{ id: guid, name: guid }] : []; + } + if (typeof onConfirm === 'function') { + onConfirm(draftSelected.slice()); + } + window.ModalSystem.close(modalId); + } + }); + } + }); + } + + /** + * Open a library item picker for static query items. + * @param {Object} options + * @param {Array<{id:string,name:string}>} [options.selected] + * @param {Function} options.onConfirm + */ + function openLibraryItemPickerPopover(options = {}) { + const { selected = [], onConfirm } = options; + const modalId = 'kefin-library-item-picker'; + const meta = LIBRARY_ITEM_META; + + if (window.ModalSystem?.isOpen?.(modalId)) { + window.ModalSystem.close(modalId); + } + + let draftSelected = (selected || []).map(s => (typeof s === 'object' ? { ...s } : { id: s, name: s })); + let search = ''; + let activeItemTypes = []; + let itemsCache = []; + let listStatus = 'loading'; + let listError = ''; + let searchTimer = null; + let loadRequestId = 0; + + const content = document.createElement('div'); + content.className = 'hsse-selection-popover hsse-library-item-picker'; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.5em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + function renderTypeChipsHTML() { + return LIBRARY_ITEM_TYPE_OPTIONS.map(type => ` + + `).join(''); + } + + function renderBody(options = {}) { + const preserveScroll = options.preserveScroll === true; + const prevList = preserveScroll ? content.querySelector('#hsse-library-item-list') : null; + const prevScrollTop = prevList ? prevList.scrollTop : 0; + + content.innerHTML = ` +
+ +
+
+
Filter by item type (optional)
+
+ ${renderTypeChipsHTML()} +
+
+
+ ${renderSelectionListHTML({ + type: 'LibraryItem', + items: itemsCache, + selected: draftSelected, + search, + status: listStatus, + error: listError, + actionAttr: 'data-libpick-action', + selectAction: 'select-item', + retryAction: 'retry-load' + })} +
+ `; + + if (preserveScroll) { + const nextList = content.querySelector('#hsse-library-item-list'); + if (nextList) nextList.scrollTop = prevScrollTop; + } + } + + async function loadItems() { + const requestId = ++loadRequestId; + listStatus = 'loading'; + listError = ''; + renderBody(); + + try { + const fetched = await fetchLibrarySearchItems({ + search, + includeItemTypes: activeItemTypes + }); + if (requestId !== loadRequestId) return; + itemsCache = fetched; + listStatus = 'idle'; + } catch (err) { + if (requestId !== loadRequestId) return; + listStatus = 'error'; + listError = err?.message || 'Failed to load items.'; + } + renderBody(); + } + + function toggleItem(id, name) { + const existing = draftSelected.findIndex(p => p.id === id); + if (existing >= 0) draftSelected.splice(existing, 1); + else draftSelected.push({ id, name }); + renderBody({ preserveScroll: true }); + const searchEl = content.querySelector('#hsse-library-item-search'); + if (searchEl) { + searchEl.focus(); + const len = searchEl.value.length; + searchEl.setSelectionRange(len, len); + } + } + + function toggleItemType(type) { + const idx = activeItemTypes.indexOf(type); + if (idx >= 0) activeItemTypes.splice(idx, 1); + else activeItemTypes.push(type); + loadItems(); + } + + window.ModalSystem.create({ + id: modalId, + title: 'Select Items', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + onOpen: (modalInstance) => { + const root = modalInstance.dialogContent; + const foot = modalInstance.dialogFooter; + + renderBody(); + loadItems(); + + root.addEventListener('click', (e) => { + const actionEl = e.target.closest('[data-libpick-action]'); + if (!actionEl || !root.contains(actionEl)) return; + const action = actionEl.dataset.libpickAction; + if (action === 'select-item') { + toggleItem(actionEl.dataset.parentId, actionEl.dataset.parentName); + } else if (action === 'toggle-type') { + toggleItemType(actionEl.dataset.itemType); + } else if (action === 'retry-load') { + loadItems(); + } + }); + + root.addEventListener('input', (e) => { + if (e.target.id !== 'hsse-library-item-search') return; + search = e.target.value; + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(() => loadItems(), WIZARD_PARENT_DATA.SEARCH_DEBOUNCE_MS); + }); + + foot.addEventListener('click', (e) => { + const actionEl = e.target.closest('[data-libpick-action]'); + if (!actionEl) return; + if (actionEl.dataset.libpickAction === 'cancel') { + window.ModalSystem.close(modalId); + return; + } + if (actionEl.dataset.libpickAction === 'confirm') { + if (typeof onConfirm === 'function') { + onConfirm(draftSelected.slice()); + } + window.ModalSystem.close(modalId); + } + }); + } + }); + } + + function renderAppearancePreviewHTML() { + return ` +
+ +
+

Loading preview…

+
+
+ `; + } + + function getAppearancePreviewQueryKey(wizardState) { + if (isCustomType(wizardState)) { + return JSON.stringify({ + parentType: 'Custom', + customItems: (wizardState.customItems || []).map(item => ({ + name: item.name, + cardFooter: item.cardFooter, + cardUrl: item.cardUrl, + posterUrl: item.posterUrl, + thumbUrl: item.thumbUrl, + backdropUrl: item.backdropUrl, + squareUrl: item.squareUrl, + bannerUrl: item.bannerUrl, + logoUrl: item.logoUrl + })) + }); + } + if (isExternalType(wizardState)) { + return JSON.stringify({ + parentType: 'External', + externalListUrls: wizardState.externalListUrls || [] + }); + } + const parents = wizardState.parents || []; + return JSON.stringify({ + parentType: wizardState.parentType, + parentIds: parents.map(p => p.id).sort(), + parentNames: parents.map(p => p.name).sort(), + includeItemTypes: [...(wizardState.includeItemTypes || [])].sort(), + sortBy: wizardState.sortBy, + sortOrder: wizardState.sortOrder, + unwatchedOnly: wizardState.unwatchedOnly === true + }); + } + + const ITEM_TYPE_OPTIONS = ['Movie', 'Series', 'Episode', 'Season', 'MusicAlbum', 'Audio', 'MusicVideo', 'Book']; + + function getIncludedItemTypesHintText(includeItemTypes) { + if (!includeItemTypes || includeItemTypes.length === 0) { + return 'None selected - all item types will be included.'; + } + const selected = ITEM_TYPE_OPTIONS.filter(type => includeItemTypes.includes(type)); + return `This section will display items with the following types: ${formatItemTypesLabel(selected)}`; + } + + function updateIncludedItemTypesHint(root, includeItemTypes) { + const hint = root?.querySelector('#hsse-item-types-hint'); + if (hint) hint.textContent = getIncludedItemTypesHintText(includeItemTypes); + } + + const FALLBACK_SORT_ORDERS = ['Default', 'Random', 'SortName', 'DateCreated', 'CommunityRating']; + const FALLBACK_SORT_ORDER_DIRECTIONS = ['Ascending', 'Descending']; + const FALLBACK_CARD_FORMATS = ['Poster', 'Thumb', 'Series Thumb', 'Series Poster', 'Backdrop', 'Square', 'Random', 'Button', 'Banner', 'Logo', 'Clear Art', 'Disc']; + const FALLBACK_SPOTLIGHT_LAYOUT_OPTIONS = [{ value: 'Border', label: 'Border' }, { value: 'Borderless', label: 'Borderless' }]; + const FALLBACK_SPOTLIGHT_SIZE_OPTIONS = [{ value: 'normal', label: 'Normal' }, { value: 'large', label: 'Large' }, { value: 'full', label: 'Full' }]; + const FALLBACK_SPOTLIGHT_TILE_COUNT_OPTIONS = [{ value: '1', label: '1' }, { value: '2', label: '2' }, { value: '3', label: '3' }]; + + function getEditorConstants() { + return window.KefinHomeScreenEditorConstants || {}; + } + + function getSortOrders() { + return getEditorConstants().SORT_ORDERS || FALLBACK_SORT_ORDERS; + } + + function getSortOrderDirections() { + return getEditorConstants().SORT_ORDER_DIRECTIONS || FALLBACK_SORT_ORDER_DIRECTIONS; + } + + function getCardFormats() { + return getEditorConstants().CARD_FORMATS || FALLBACK_CARD_FORMATS; + } + + function getSpotlightLayoutOptions() { + return getEditorConstants().SPOTLIGHT_LAYOUT_OPTIONS || FALLBACK_SPOTLIGHT_LAYOUT_OPTIONS; + } + + function getSpotlightSizeOptions() { + return getEditorConstants().SPOTLIGHT_SIZE_OPTIONS || FALLBACK_SPOTLIGHT_SIZE_OPTIONS; + } + + function getSpotlightTileCountOptions() { + return getEditorConstants().SPOTLIGHT_TILE_COUNT_OPTIONS || FALLBACK_SPOTLIGHT_TILE_COUNT_OPTIONS; + } + + function applyRandomAppearanceOptions(state) { + const formats = getCardFormats().filter(f => f !== 'Random'); + state.cardFormat = pickRandom(formats.length ? formats : ['Poster']); + const layouts = getSpotlightLayoutOptions(); + const sizes = getSpotlightSizeOptions(); + state.spotlightLayout = pickRandom(layouts).value; + state.spotlightSize = pickRandom(sizes).value; + const maxTiles = state.spotlightSize === 'full' ? 1 + : state.spotlightSize === 'large' ? 2 : 3; + state.tileCount = String(1 + Math.floor(Math.random() * maxTiles)); + state.panAnimation = Math.random() < 0.5; + } + + function escapeHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function humanizeLabel(value) { + if (!value || typeof value !== 'string') return value; + return value + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2'); + } + + function formatItemTypesLabel(types) { + return (types || []).map(humanizeLabel).join(', '); + } + + function renderMaterialIcon(name, className = '') { + return ``; + } + + function scrollSelectedParentIntoView(root) { + root?.querySelector('.hsse-parent-item.hsse-active') + ?.scrollIntoView({ block: 'nearest', behavior: 'instant' }); + } + + function buildFieldLabel(label, labelTooltip) { + if (labelTooltip) { + return ``; + } + return ``; + } + + function hsseBuildSelect(id, options, selectedValue, label, labelTooltip) { + const optionsHTML = options.map(opt => { + const value = typeof opt === 'string' ? opt : opt.value; + const optLabel = typeof opt === 'string' ? humanizeLabel(opt) : opt.label; + const selected = value === selectedValue ? 'selected' : ''; + return ``; + }).join(''); + + return ` +
+ ${buildFieldLabel(label, labelTooltip)} + +
+ `; + } + + function hsseBuildTextInput(id, value, label, type = 'text', placeholder = '') { + return ` +
+ ${buildFieldLabel(label)} + +
+ `; + } + + function getExistingGroupNames(config) { + const groups = config?.CUSTOM_SECTION_GROUPS || []; + return [...new Set(groups.map(g => g.name).filter(Boolean))]; + } + + function getDefaultGroupSelect(config) { + const existing = getExistingGroupNames(config); + if (existing.includes('Custom Sections')) return 'Custom Sections'; + return existing.length > 0 ? existing[0] : 'New...'; + } + + function resolveGroupName(wizardState, config) { + const select = wizardState.sectionGroupSelect || getDefaultGroupSelect(config); + if (select === 'New...') { + return (wizardState.sectionGroupNew || '').trim() || 'Custom Sections'; + } + return select; + } + + function buildSectionGroupHTML(config, wizardState) { + const existing = getExistingGroupNames(config); + const options = ['New...', ...existing]; + const selected = wizardState.sectionGroupSelect || getDefaultGroupSelect(config); + const showNew = selected === 'New...'; + + return ` +
+ ${hsseBuildSelect('hsse-wizard-sectionGroup', options, selected, 'Section Group')} +
+ ${buildFieldLabel('New Group Name')} + +
+
+ `; + } + + function getSectionVisibilityLabel(value) { + return SECTION_VISIBILITY_OPTIONS.find(o => o.value === value)?.title || value || 'Normal'; + } + + function hasValidSeasonalDates(wizardState) { + return !!(wizardState.startDate?.trim() && wizardState.endDate?.trim()); + } + + function applySectionVisibilityToSection(section, wizardState, baseSection) { + const visibility = wizardState.sectionVisibility || 'normal'; + const { order, startDate, endDate } = wizardState; + + if (visibility === 'discovery') { + section.discoveryEnabled = true; + section.type = 'discovery'; + delete section.startDate; + delete section.endDate; + section.order = baseSection.order || 100; + } else if (visibility === 'seasonal') { + section.discoveryEnabled = false; + section.type = 'seasonal'; + section.startDate = (startDate || '').trim(); + section.endDate = (endDate || '').trim(); + section.order = order ? parseInt(order, 10) : (baseSection.order || 100); + } else { + section.discoveryEnabled = false; + section.type = 'home'; + delete section.startDate; + delete section.endDate; + section.order = order ? parseInt(order, 10) : (baseSection.order || 100); + } + } + + function renderSectionVisibilityHTML(wizardState) { + const visibility = wizardState.sectionVisibility || 'normal'; + const showSeasonalDates = visibility === 'seasonal'; + const showOrder = visibility !== 'discovery'; + + return ` +
+ +
+ ${SECTION_VISIBILITY_OPTIONS.map(opt => ` + + `).join('')} +
+
+ ${showSeasonalDates ? ` +
+
+ ${hsseBuildTextInput('hsse-wizard-startDate', wizardState.startDate, 'Start Date (MM-DD)')} + ${hsseBuildTextInput('hsse-wizard-endDate', wizardState.endDate, 'End Date (MM-DD)')} +
+
+ ` : ''} + ${showOrder ? ` +
+ ${hsseBuildTextInput('hsse-wizard-order', wizardState.order, 'Order', 'number', 'Auto')} +
+ ` : ''} + `; + } + + function collectExternalListFromForm(root, wizardState) { + const inputs = root.querySelectorAll('[id^="hsse-external-list-url-"]'); + wizardState.externalListUrls = Array.from(inputs).map((el) => (el.value || '').trim()).filter(Boolean); + if (!wizardState.externalListUrls.length) wizardState.externalListUrls = ['']; + } + + function collectSettingsFromForm(root, wizardState) { + const nameEl = root.querySelector('#hsse-wizard-sectionName'); + const groupSelect = root.querySelector('#hsse-wizard-sectionGroup'); + const groupNew = root.querySelector('#hsse-wizard-sectionGroup-new'); + const orderEl = root.querySelector('#hsse-wizard-order'); + const startDateEl = root.querySelector('#hsse-wizard-startDate'); + const endDateEl = root.querySelector('#hsse-wizard-endDate'); + if (nameEl) wizardState.sectionName = nameEl.value; + if (groupSelect) wizardState.sectionGroupSelect = groupSelect.value; + if (groupNew) wizardState.sectionGroupNew = groupNew.value; + if (startDateEl) wizardState.startDate = startDateEl.value; + if (endDateEl) wizardState.endDate = endDateEl.value; + if (orderEl && wizardState.sectionVisibility !== 'discovery') wizardState.order = orderEl.value; + } + + function collectQueryStepFromForm(root, wizardState) { + const sortByEl = root.querySelector('#hsse-wizard-sortBy'); + const sortOrderEl = root.querySelector('#hsse-wizard-sortOrder'); + const unwatchedEl = root.querySelector('#hsse-wizard-unwatched'); + if (sortByEl) wizardState.sortBy = sortByEl.value; + if (sortOrderEl) wizardState.sortOrder = sortOrderEl.value; + if (unwatchedEl) wizardState.unwatchedOnly = unwatchedEl.checked; + } + + function collectAppearanceStepFromForm(root, wizardState) { + if (wizardState.renderMode !== 'Spotlight') return; + const layoutEl = root.querySelector('#hsse-wizard-spotlightLayout'); + const sizeEl = root.querySelector('#hsse-wizard-spotlightSize'); + const tileEl = root.querySelector('#hsse-wizard-tileCount'); + const animatedEl = root.querySelector('#hsse-wizard-animated'); + if (layoutEl) wizardState.spotlightLayout = layoutEl.value; + if (sizeEl) wizardState.spotlightSize = sizeEl.value; + if (tileEl) wizardState.tileCount = tileEl.value; + if (animatedEl) wizardState.panAnimation = animatedEl.checked; + } + + function renderConfirmOverview(wizardState, config) { + const isCustom = isCustomType(wizardState); + const isExternal = isExternalType(wizardState); + const meta = isCustom ? CUSTOM_TYPE + : (isExternal ? EXTERNAL_TYPE : PARENT_TYPES.find(t => t.type === wizardState.parentType)); + const parents = wizardState.parents || []; + const selectionLabel = isCustom + ? ((wizardState.customItems || []).map(i => i.name?.trim()).filter(Boolean).join(', ') || '—') + : (isExternal + ? ((wizardState.externalListUrls || []).filter(Boolean).join(', ') || '—') + : (parents.length ? parents.map(p => p.name).join(', ') : '—')); + const rows = [ + ['Section Name', wizardState.sectionName || '—'], + ['Section Group', resolveGroupName(wizardState, config)], + ['Based On', meta?.label || wizardState.parentType || '—'], + [isCustom ? 'Items' : (isExternal ? 'List URLs' : 'Selection'), selectionLabel] + ]; + + if (!isCustom && !isExternal) { + rows.push( + ['Item Types', wizardState.includeItemTypes.length ? formatItemTypesLabel(wizardState.includeItemTypes) : 'All types'], + ['Sort By', wizardState.sortBy === 'Default' ? 'Default' : `${humanizeLabel(wizardState.sortBy)} (${wizardState.sortOrder})`], + ['Hide Watched Items', wizardState.unwatchedOnly ? 'Yes' : 'No'] + ); + } + + rows.push(['Render Mode', wizardState.renderMode || 'Normal']); + + if (wizardState.renderMode === 'Normal') { + rows.push(['Card Format', wizardState.cardFormat || 'Poster']); + } + + if (wizardState.renderMode === 'Spotlight') { + const layoutLabel = getSpotlightLayoutOptions().find(o => o.value === wizardState.spotlightLayout)?.label || wizardState.spotlightLayout; + const sizeLabel = getSpotlightSizeOptions().find(o => o.value === wizardState.spotlightSize)?.label || wizardState.spotlightSize; + rows.push(['Spotlight Layout', layoutLabel || '—']); + rows.push(['Spotlight Size', sizeLabel || '—']); + rows.push(['Tiled Backdrops', wizardState.tileCount || '1']); + rows.push(['Animated Transitions', wizardState.panAnimation ? 'Yes' : 'No']); + } + + if (wizardState.renderMode === 'Random') { + const layoutLabel = getSpotlightLayoutOptions().find(o => o.value === wizardState.spotlightLayout)?.label || wizardState.spotlightLayout; + const sizeLabel = getSpotlightSizeOptions().find(o => o.value === wizardState.spotlightSize)?.label || wizardState.spotlightSize; + rows.push(['Card Format (random)', wizardState.cardFormat || 'Poster']); + rows.push(['Spotlight (random)', `${layoutLabel || '—'}, ${sizeLabel || '—'}, ${wizardState.tileCount || '1'} tiles, ${wizardState.panAnimation ? 'animated' : 'static'}`]); + } + + const visibility = wizardState.sectionVisibility || 'normal'; + rows.push(['Section Type', getSectionVisibilityLabel(visibility)]); + if (visibility === 'seasonal') { + rows.push(['Start Date', wizardState.startDate?.trim() || '—']); + rows.push(['End Date', wizardState.endDate?.trim() || '—']); + } + if (visibility !== 'discovery') { + rows.push(['Order', wizardState.order ? wizardState.order : 'Auto']); + } + + return ` +
+ +
+
+ ${rows.map(([label, value]) => ` +
+
${escapeHtml(label)}
+
${escapeHtml(String(value))}
+
+ `).join('')} +
+ `; + } + + function buildSectionName(parentType, items) { + const list = Array.isArray(items) ? items : (items ? [items] : []); + if (list.length === 0) return ''; + const names = list.map(i => i.name).join(' & '); + switch (parentType) { + case 'Genre': return `${names} Picks`; + case 'Collection': + case 'Playlist': return list[0].name; + case 'Studio': return `More from ${names}`; + case 'Person': return `Starring ${names}`; + case 'Tag': return `${names} Collection`; + default: return names; + } + } + + /** + * Map wizard state to a HomeScreenSection-shaped draft object. + */ + function wizardStateToSection(wizardState, baseSection) { + const { + parentType, + parents = [], + customItems = [], + includeItemTypes, + sortBy, + sortOrder, + unwatchedOnly, + renderMode, + cardFormat, + spotlightLayout, + spotlightSize, + tileCount, + panAnimation, + sectionName, + order + } = wizardState; + + if (parentType === 'Custom') { + const items = customItems.map(buildStaticItemFromDraft).filter(Boolean); + const section = { + ...baseSection, + id: baseSection.id, + name: sectionName || 'Custom Section', + enabled: true, + isCustom: true, + renderMode: renderMode || 'Normal', + cardFormat: renderMode === 'Spotlight' ? undefined : (cardFormat || 'Poster'), + items, + queries: [] + }; + + applySectionVisibilityToSection(section, wizardState, baseSection); + + const targetGroup = resolveGroupName(wizardState, wizardState._config); + if (targetGroup) { + section._targetGroupName = targetGroup; + } + + if (renderMode === 'Spotlight' || renderMode === 'Random') { + section.spotlightConfig = { + spotlightLayout: spotlightLayout || 'Border', + spotlightSize: spotlightSize || 'normal', + tileCount: Math.max(1, Math.min(3, parseInt(tileCount, 10) || 1)), + panAnimation: panAnimation !== false + }; + } + + return section; + } + + if (parentType === 'External') { + const urls = (wizardState.externalListUrls || []).map((u) => String(u || '').trim()).filter(Boolean); + const section = { + ...baseSection, + id: baseSection.id, + name: sectionName || 'External List', + enabled: true, + isCustom: true, + renderMode: renderMode || 'Normal', + cardFormat: renderMode === 'Spotlight' ? undefined : (cardFormat || 'Poster'), + externalListUrls: urls, + queries: [] + }; + + applySectionVisibilityToSection(section, wizardState, baseSection); + + const targetGroup = resolveGroupName(wizardState, wizardState._config); + if (targetGroup) { + section._targetGroupName = targetGroup; + } + + if (renderMode === 'Spotlight' || renderMode === 'Random') { + section.spotlightConfig = { + spotlightLayout: spotlightLayout || 'Border', + spotlightSize: spotlightSize || 'normal', + tileCount: Math.max(1, Math.min(3, parseInt(tileCount, 10) || 1)), + panAnimation: panAnimation !== false + }; + } + + return section; + } + + const queryOptions = { + Limit: DEFAULT_SECTION_QUERY_LIMIT + }; + + if (includeItemTypes && includeItemTypes.length > 0) { + queryOptions.IncludeItemTypes = includeItemTypes; + } + + if (sortBy && sortBy !== 'Default') { + queryOptions.SortBy = sortBy; + queryOptions.SortOrder = sortOrder || 'Descending'; + } + + if (unwatchedOnly) { + queryOptions.IsUnplayed = true; + } + + if (parentType && parents.length > 0) { + const meta = PARENT_TYPES.find(t => t.type === parentType); + const field = meta?.queryField; + switch (field) { + case 'GenreIds': + queryOptions.GenreIds = parents.map(p => p.id); + break; + case 'StudioIds': + queryOptions.StudioIds = parents.map(p => p.id); + break; + case 'PersonIds': + queryOptions.PersonIds = parents.map(p => p.id); + break; + case 'Tags': + queryOptions.Tags = parents.map(p => p.name); + break; + case 'ParentId': + queryOptions.ParentId = parents[0].id; + break; + default: + break; + } + } + + const section = { + ...baseSection, + id: baseSection.id, + name: sectionName || (parents.length ? buildSectionName(parentType, parents) : baseSection.name), + enabled: true, + isCustom: true, + renderMode: renderMode || 'Normal', + cardFormat: renderMode === 'Spotlight' ? undefined : (cardFormat || 'Poster'), + queries: [{ queryOptions }] + }; + + applySectionVisibilityToSection(section, wizardState, baseSection); + + if (section.type === 'discovery' && parentType && parentType !== 'Custom') { + section.discoveryType = parentType; + } + + const targetGroup = resolveGroupName(wizardState, wizardState._config); + if (targetGroup) { + section._targetGroupName = targetGroup; + } + + if (renderMode === 'Spotlight' || renderMode === 'Random') { + section.spotlightConfig = { + spotlightLayout: spotlightLayout || 'Border', + spotlightSize: spotlightSize || 'normal', + tileCount: Math.max(1, Math.min(3, parseInt(tileCount, 10) || 1)), + panAnimation: panAnimation !== false + }; + } + + return section; + } + + function renderModeSelectHTML() { + return ` +
+

Choose how you'd like to get started

+
+
+ + +
+
+
+ `; + } + + function renderSingleWizardStepHTML(step, i, wizardState) { + const currentStep = wizardState.step; + const maxReachedStepIndex = wizardState.maxReachedStepIndex; + const currentIndex = getStepIndex(currentStep, wizardState); + const isActive = step === currentStep; + const isDone = i < currentIndex; + const isClickable = i <= maxReachedStepIndex && !isActive; + const classes = [ + 'hsse-step', + isActive ? 'hsse-active' : '', + isDone ? 'hsse-done' : '', + isClickable ? 'hsse-clickable' : '' + ].filter(Boolean).join(' '); + + const inner = ` + ${isDone ? '✓' : i + 1} + ${WIZARD_STEP_LABELS[step]} + `; + + if (isClickable) { + return ``; + } + return `
${inner}
`; + } + + function renderWizardStepsHTML(wizardState) { + const steps = getWizardSteps(wizardState); + const parts = []; + steps.forEach((step, i) => { + if (i > 0) { + parts.push(''); + } + parts.push(renderSingleWizardStepHTML(step, i, wizardState)); + }); + return parts.join(''); + } + + function buildCustomImageFieldHTML(index, field, value, options = {}) { + const { isPoster = false } = options; + const requiredMark = field.required ? ' *' : ''; + const posterHint = isPoster + ? `

You can place images in your "Jellyfin\\Server\\jellyfin-web" folder and use the "/web/image_name.png" path to access them.

` + : ''; + return ` +
+ + + ${posterHint} +
+ `; + } + + function renderCustomItemEditorHTML(index, item, wizardState) { + const additionalExpanded = isCustomItemAdditionalExpanded(wizardState, index, item); + const posterInputId = `hsse-custom-${index}-posterUrl`; + + return ` +
+
+
+ + +
+
+ + +
+
+ + +
+ ${buildCustomImageFieldHTML(index, CUSTOM_POSTER_FIELD, item.posterUrl || '', { isPoster: true })} +
+ +
+
+ +
+
+ ${CUSTOM_ADDITIONAL_IMAGE_FIELDS.map(field => buildCustomImageFieldHTML(index, field, item[field.key] || '')).join('')} +
+ `; + } + + function renderCustomItemTileHTML(index, item, canRemove) { + const label = item.name?.trim() || `Item ${index + 1}`; + return ` +
+ +
${escapeHtml(label)}
+ +
+ `; + } + + function renderCustomItemEditorPanelHTML(wizardState) { + const editorIndex = wizardState.customItemEditorIndex; + if (editorIndex == null || Number.isNaN(editorIndex)) return ''; + const item = wizardState.customItems?.[editorIndex]; + if (!item) return ''; + + const title = item.name?.trim() + ? `Edit: ${item.name.trim()}` + : `Edit Item ${editorIndex + 1}`; + + return ` +
+ + +
+ `; + } + + function renderCustomItemsHTML(wizardState) { + const shared = window.KefinHomeScreenCustomItemsEditor; + if (shared) { + return shared.renderCustomItemsHTML(wizardState, getWizardCustomItemsOptions()); + } + ensureCustomItemsInitialized(wizardState); + const items = wizardState.customItems; + const canRemove = items.length > 1; + + return ` +
+
+ +

Name and poster required. Link and other images optional.

+
+
+ ${items.map((item, index) => renderCustomItemTileHTML(index, item, canRemove)).join('')} + +
+ ${renderCustomItemEditorPanelHTML(wizardState)} +
+ `; + } + + function renderExternalListHTML(wizardState) { + const urls = Array.isArray(wizardState.externalListUrls) && wizardState.externalListUrls.length + ? wizardState.externalListUrls + : ['']; + const apiKey = window.LibraryCacheUtils?.getLibraryCacheSettings?.()?.mdblistApiKey + || window.KefinHomeConfig2?.LIBRARY_CACHE?.mdblistApiKey + || ''; + const warn = !String(apiKey || '').trim() + ? `

MDBList API key is not configured. Add it under Home Screen → General settings before using External Lists.

` + : ''; + return ` +
+ +

Paste one or more MDBList URLs. Matched against your library by IMDb/TMDB ids.

+ ${warn} + ${urls.map((url, index) => hsseBuildTextInput(`hsse-external-list-url-${index}`, url || '', `List URL ${index + 1}`, 'url', 'https://mdblist.com/...')).join('')} + +
+ `; + } + + function renderWizardBodyHTML(wizardState, config) { + const step = wizardState.step; + + if (step === 'type') { + return ` +
+ ${PARENT_TYPES.map(t => ` + + `).join('')} + +
+ `; + } + + if (step === 'customItems') { + return renderCustomItemsHTML(wizardState); + } + + if (step === 'externalList') { + return renderExternalListHTML(wizardState); + } + + if (step === 'parent') { + const meta = PARENT_TYPES.find(t => t.type === wizardState.parentType); + const parentSearch = getParentSearch(wizardState); + const filterChips = PARENT_FILTER_CHIP_TYPES.has(wizardState.parentType) + ? renderParentItemTypeChipsHTML( + getParentFilterItemTypes(wizardState), + 'data-hsse-action', + 'wizard-toggle-parent-filter-type' + ) + : ''; + + return ` + ${filterChips} +
+ +
+
+ ${renderParentListHTML(wizardState)} +
+ `; + } + + if (step === 'query') { + return ` +
+ +

${escapeHtml(getIncludedItemTypesHintText(wizardState.includeItemTypes))}

+
+ ${ITEM_TYPE_OPTIONS.map(type => { + const active = wizardState.includeItemTypes.includes(type); + return ``; + }).join('')} +
+
+
+
+ ${hsseBuildSelect('hsse-wizard-sortBy', getSortOrders(), wizardState.sortBy, 'Sort by')} + ${hsseBuildSelect('hsse-wizard-sortOrder', getSortOrderDirections(), wizardState.sortOrder, 'Sort order')} +
+
+ ${buildToggleCard('hsse-wizard-unwatched', wizardState.unwatchedOnly === true, 'Hide Watched Items', '')} + `; + } + + if (step === 'appearance') { + const modes = [ + { value: 'Normal', title: 'Normal', desc: 'Matches the default Jellyfin horizontal row of cards.' }, + { value: 'Spotlight', title: 'Spotlight', desc: 'A customizable, full-width, featured slideshow carousel.' }, + { value: 'Random', title: 'Random', desc: 'A random layout will be used when the section is loaded.' } + ]; + return ` +
+ +
+ ${modes.map(m => ` + + `).join('')} +
+
+ ${wizardState.renderMode === 'Normal' ? ` +
+ +
+ ${getCardFormats().map(fmt => ` + + `).join('')} +
+
+ ` : ''} + ${wizardState.renderMode === 'Spotlight' ? ` +
+
+ ${hsseBuildSelect('hsse-wizard-spotlightLayout', getSpotlightLayoutOptions(), wizardState.spotlightLayout, 'Spotlight Layout')} + ${hsseBuildSelect('hsse-wizard-spotlightSize', getSpotlightSizeOptions(), wizardState.spotlightSize, 'Spotlight Size')} + ${hsseBuildSelect('hsse-wizard-tileCount', getSpotlightTileCountOptions(), wizardState.tileCount, 'Tiled Backdrop Count', 'Set the number of backdrops that will be tiled next to each other in a single slide of the Spotlight.')} +
+
+ ${buildToggleCard('hsse-wizard-animated', wizardState.panAnimation !== false, 'Animated Slides', '')} + ` : ''} + ${renderAppearancePreviewHTML()} + `; + } + + if (step === 'settings') { + return ` + ${hsseBuildTextInput('hsse-wizard-sectionName', wizardState.sectionName, 'Section Name')} + ${buildSectionGroupHTML(config, wizardState)} + ${renderSectionVisibilityHTML(wizardState)} + `; + } + + if (step === 'confirm') { + return renderConfirmOverview(wizardState, config); + } + + return ''; + } + + function renderWizardHTML(wizardState, config) { + return ` +
+
${renderWizardStepsHTML(wizardState)}
+
${renderWizardBodyHTML(wizardState, config)}
+
+ `; + } + + function getModalTitle(stage, wizardState) { + if (stage === 'mode' || stage === 'advanced') return 'Create a Section'; + if (stage === 'wizard') { + if (wizardState.step === 'confirm') return 'Review Section'; + if (wizardState.step === 'parent' && wizardState.parentType) { + const meta = PARENT_TYPES.find(t => t.type === wizardState.parentType); + return `Choose a ${meta?.label?.toLowerCase() || 'item'}`; + } + if (wizardState.step === 'customItems') return 'Create Items'; + if (wizardState.step === 'externalList') return 'External List'; + return 'Create a Home Screen Section'; + } + return 'Create a Section'; + } + + function getFooterHintText(wizardState) { + if (wizardState.step === 'type') { + return 'Select a type to continue'; + } + if (wizardState.step === 'parent' && !isMultiSelectType(wizardState.parentType)) { + const meta = PARENT_TYPES.find(t => t.type === wizardState.parentType); + const label = (meta?.label || 'item').toLowerCase(); + return `Select a ${label} to continue`; + } + return ''; + } + + function buildWizardFooterHTML(wizardState) { + const step = wizardState.step; + const parentUsesContinue = step === 'parent' && isMultiSelectType(wizardState.parentType); + + let endContent = ''; + if (step === 'type' || (step === 'parent' && !parentUsesContinue)) { + endContent = `${escapeHtml(getFooterHintText(wizardState))}`; + } else if (step === 'confirm') { + endContent = ``; + } else { + endContent = ``; + } + + const backLabel = step === 'type' ? 'Back to start' : 'Back'; + + return ` + + `; + } + + function buildAdvancedCreateFooterHTML(sectionType = 'custom') { + return ` + + `; + } + + function getFooterHTML(stage, wizardState, options = {}) { + if (stage === 'mode') { + return ``; + } + + if (stage === 'wizard') { + return buildWizardFooterHTML(wizardState); + } + + if (stage === 'advanced') { + return buildAdvancedCreateFooterHTML(options.sectionType || 'custom'); + } + + return ''; + } + + function attachToggleHandlers(root) { + if (root.dataset.hsseToggleHandlersAttached === 'true') { + if (typeof window.KefinTweaksUI?.bindToggleCards === 'function') { + window.KefinTweaksUI.bindToggleCards(root); + } + return; + } + root.dataset.hsseToggleHandlersAttached = 'true'; + + if (typeof window.KefinTweaksUI?.bindToggleCards === 'function') { + window.KefinTweaksUI.bindToggleCards(root); + } + + root.addEventListener('click', (e) => { + const switchBtn = e.target.closest('.kefin-toggle-switch'); + if (switchBtn && root.contains(switchBtn)) return; + + const btn = e.target.closest('.toggle-slider'); + if (!btn || !root.contains(btn)) return; + const checkboxId = btn.dataset.checkboxId; + if (!checkboxId) return; + e.preventDefault(); + e.stopPropagation(); + const checkbox = document.getElementById(checkboxId); + if (checkbox && root.contains(checkbox)) { + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + updateToggleSliderUI(btn, checkbox.checked); + } + }); + + root.addEventListener('change', (e) => { + const checkbox = e.target; + if (!checkbox.matches?.('input[type="checkbox"]') || !root.contains(checkbox)) return; + const switchBtn = root.querySelector(`.kefin-toggle-switch[data-checkbox-id="${checkbox.id}"]`); + if (switchBtn && typeof window.KefinTweaksUI?.updateToggleSwitchUI === 'function') { + window.KefinTweaksUI.updateToggleSwitchUI(switchBtn, checkbox.checked); + return; + } + const btn = root.querySelector(`.toggle-slider[data-checkbox-id="${checkbox.id}"]`); + if (btn) updateToggleSliderUI(btn, checkbox.checked); + }); + } + + /** + * Open the create flow (mode select or wizard) for new custom sections. + * @param {Object} section - Base section (new section stub) + * @param {Object} options + * @param {Function} options.onOpenAdvanced - Opens legacy advanced editor with section draft + * @param {Function} options.onSaveWizardSection - Saves wizard-created section via JS Injector + */ + // --- Migrated section edit modal (from homeScreen-configuration.js) --- + + const WARN = (...args) => console.warn('[KefinTweaks Section Editor]', ...args); + const ERR = (...args) => console.error('[KefinTweaks Section Editor]', ...args); + + const SECTION_EDITOR_MODAL_ID = 'kefin-homescreen-section-editor'; + const DISCOVERY_EDITOR_MODAL_ID = 'kefin-homescreen-discovery-editor'; + + // Supported additional query options (matching configuration.js) + const SUPPORTED_QUERY_OPTIONS = { + Ids: { label: 'Item IDs', type: 'array', hint: 'Comma-separated GUIDs' }, + ParentId: { label: 'Parent ID', type: 'string', hint: 'GUID of parent item' }, + ExcludeItemIds: { label: 'Exclude Item IDs', type: 'array', hint: 'Comma-separated GUIDs' }, + GenreIds: { label: 'Genre IDs', type: 'array', hint: 'Comma-separated GUIDs' }, + Person: { label: 'Person', type: 'string', hint: 'Person name' }, + PersonIds: { label: 'Person IDs', type: 'array', hint: 'Comma-separated GUIDs' }, + PersonTypes: { label: 'Person Types', type: 'array', hint: 'Actor, Director, Writer, etc.' }, + ExcludePersonTypes: { label: 'Exclude Person Types', type: 'array', hint: 'Actor, Director, Writer, etc.' }, + StudioIds: { label: 'Studio IDs', type: 'array', hint: 'Comma-separated GUIDs' }, + Fields: { label: 'Fields', type: 'array', hint: 'Comma-separated field names' }, + MinPremiereDate: { label: 'Min Premiere Date', type: 'date', hint: 'YYYY-MM-DD' }, + MaxPremiereDate: { label: 'Max Premiere Date', type: 'date', hint: 'YYYY-MM-DD' }, + MinDateLastSaved: { label: 'Min Date Last Saved', type: 'date', hint: 'YYYY-MM-DD' }, + MinDateLastSavedForUser: { label: 'Min Date Last Saved For User', type: 'date', hint: 'YYYY-MM-DD' }, + IndexNumber: { label: 'Index Number', type: 'number', hint: 'Episode/Season index' }, + ParentIndexNumber: { label: 'Parent Index Number', type: 'number', hint: 'Season index' }, + MinCommunityRating: { label: 'Min Community Rating', type: 'number', hint: '0-10' }, + MinCriticRating: { label: 'Min Critic Rating', type: 'number', hint: '0-10' }, + IsFavorite: { label: 'Is Favorite', type: 'boolean' }, + IsPlayed: { label: 'Is Played', type: 'boolean' }, + // IsUnplayed is owned by Hide watched — not offered in Additional Options + IsMissing: { label: 'Is Missing', type: 'boolean' }, + IsUnaired: { label: 'Is Unaired', type: 'boolean' }, + HasThemeSong: { label: 'Has Theme Song', type: 'boolean' }, + HasThemeVideo: { label: 'Has Theme Video', type: 'boolean' }, + HasSubtitles: { label: 'Has Subtitles', type: 'boolean' }, + HasSpecialFeature: { label: 'Has Special Feature', type: 'boolean' }, + HasTrailer: { label: 'Has Trailer', type: 'boolean' }, + HasParentalRating: { label: 'Has Parental Rating', type: 'boolean' }, + IsHd: { label: 'Is HD', type: 'boolean' }, + Is4K: { label: 'Is 4K', type: 'boolean' }, + HasOverview: { label: 'Has Overview', type: 'boolean' }, + HasOfficialRating: { label: 'Has Official Rating', type: 'boolean' }, + Recursive: { label: 'Recursive', type: 'boolean', default: true }, + MaxOfficialRating: { label: 'Max Official Rating', type: 'string', hint: 'e.g., PG-13, TV-MA' }, + MinOfficialRating: { label: 'Min Official Rating', type: 'string', hint: 'e.g., PG, TV-14' }, + NameStartsWith: { label: 'Name Starts With', type: 'string' }, + NameStartsWithOrGreater: { label: 'Name Starts With Or Greater', type: 'string' }, + NameLessThan: { label: 'Name Less Than', type: 'string' }, + LocationTypes: { label: 'Location Types', type: 'array', hint: 'FileSystem, Remote, Virtual, Offline' }, + ExcludeLocationTypes: { label: 'Exclude Location Types', type: 'array', hint: 'FileSystem, Remote, Virtual, Offline' }, + ExcludeItemTypes: { label: 'Exclude Item Types', type: 'array', hint: 'Movie, Series, Episode, etc.' }, + ImageTypes: { label: 'Image Types', type: 'array', hint: 'Primary, Backdrop, Thumb, Logo, etc.' }, + OfficialRatings: { label: 'Official Ratings', type: 'array', hint: 'PG, PG-13, R, etc.' }, + Studios: { label: 'Studios', type: 'array', hint: 'Pipe-delimited names' }, + Artists: { label: 'Artists', type: 'array', hint: 'Pipe-delimited names' }, + Albums: { label: 'Albums', type: 'array', hint: 'Pipe-delimited names' }, + SeriesStatus: { label: 'Series Status', type: 'array', hint: 'Continuing, Ended, Unreleased' }, + Years: { label: 'Years', type: 'array', hint: 'Comma-separated years' }, + EnableResumable: { label: 'Enable Resumable', type: 'boolean' }, + EnableRewatching: { label: 'Enable Rewatching', type: 'boolean' }, + EnableUserData: { label: 'Enable User Data', type: 'boolean' } + }; + + const CARD_FORMATS = ['Poster', 'Thumb', 'Backdrop', 'Square', 'Random', 'Button', 'Banner', 'Logo', 'Clear Art', 'Disc', 'Series Poster', 'Series Thumb']; + const BADGE_FIELD_DESCRIPTIONS = { + IncludeItemTypes: 'Limit results to these item types.', + Genres: 'Filter results to items matching these genres.', + Tags: 'Filter results to items with these tags.', + Collections: 'Limit results to items in these collections.', + Playlists: 'Limit results to items in these playlists.' + }; + const SORT_ORDERS = ['Default', 'Random', 'Name', 'SortName', 'DateCreated', 'PremiereDate', 'CommunityRating', 'CriticRating', 'DatePlayed', 'SortName', 'PlayCount', 'PlayedPercentage', 'StartDate', 'Runtime', 'ProductionYear', 'IsPlayed', 'IsUnplayed', 'ParentIndexNumber', 'IndexNumber', 'IsFolder', 'SimilarityScore', 'SearchScore', 'DateLastContentAdded', 'SeriesDatePlayed', 'ChildCount']; + const SORT_ORDER_DIRECTIONS = ['Ascending', 'Descending']; + const RENDER_MODE_OPTIONS = [{ value: 'Normal', label: 'Normal' }, { value: 'Spotlight', label: 'Spotlight' }, { value: 'Random', label: 'Random' }]; + const SLIDE_STATE_OPTIONS = [ + { value: 'none', label: 'None' }, + { value: 'dots', label: 'Dots' }, + { value: 'numeric', label: 'Numeric' } + ]; + + const SPOTLIGHT_LAYOUT_OPTIONS = [ + { value: 'Border', label: 'Border' }, + { value: 'Borderless', label: 'Borderless' } + ]; + const SPOTLIGHT_SIZE_OPTIONS = [ + { value: 'normal', label: 'Normal' }, + { value: 'large', label: 'Large' }, + { value: 'full', label: 'Full' } + ]; + const SPOTLIGHT_TILE_COUNT_OPTIONS = [ + { value: '1', label: '1' }, + { value: '2', label: '2' }, + { value: '3', label: '3' } + ]; + const SPOTLIGHT_ENTRANCE_OPTIONS = [ + { value: 'fadeIn', label: 'Fade in' }, + { value: 'fadeInUp', label: 'Fade in up' }, + { value: 'fadeInDown', label: 'Fade in down' } + ]; + const SPOTLIGHT_SLIDE_OPTIONS = [ + { value: 'kenBurnsZoomIn', label: 'Ken Burns zoom in' }, + { value: 'kenBurnsZoomOut', label: 'Ken Burns zoom out' }, + { value: 'kenBurnsZoomInFullscreen', label: 'Ken Burns zoom in (fullscreen)' }, + { value: 'kenBurnsZoomOutFullscreen', label: 'Ken Burns zoom out (fullscreen)' }, + { value: 'kenBurnsPanRight', label: 'Ken Burns pan right' }, + { value: 'kenBurnsPanLeft', label: 'Ken Burns pan left' }, + { value: 'kenBurnsPanUp', label: 'Ken Burns pan up' }, + { value: 'kenBurnsDiagonal', label: 'Ken Burns diagonal' }, + { value: 'fadeInScale', label: 'Fade in scale' }, + { value: 'parallaxFloat', label: 'Parallax float' }, + { value: 'depthPulse', label: 'Depth pulse' }, + { value: 'slowRotate', label: 'Slow rotate' }, + { value: 'breathe', label: 'Breathe' }, + { value: 'heatHaze', label: 'Heat haze' }, + { value: 'colorWash', label: 'Color wash' }, + { value: 'vignetteIn', label: 'Vignette in' } + ]; + + let activeEditCtx = null; + function getActiveConfig() { return activeEditCtx?.config || null; } + + function sectionEditorShowToast(message, duration = '3') { + if (window.KefinTweaksToaster?.toast) { + window.KefinTweaksToaster.toast(message, duration); + } else { + alert(message); + } + } + + function findSectionInGroupsForEditor(groups, sectionId) { + if (!Array.isArray(groups)) return null; + for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { + const group = groups[groupIndex]; + if (group?.sections) { + const sectionIndex = group.sections.findIndex(s => s.id === sectionId); + if (sectionIndex !== -1) { + return { section: group.sections[sectionIndex], group, groupIndex, sectionIndex }; + } + } + } + return null; + } + + function buildCheckbox(id, checked, label) { + return window.KefinTweaksUI.buildCheckbox(id, checked, label); + } + + function buildSelect(id, options, selectedValue, label) { + return window.KefinTweaksUI.buildSelect(id, options, selectedValue, label); + } + + function buildTextInput(id, value, label, type = 'text', placeholder = '') { + return window.KefinTweaksUI.buildTextInput(id, value, label, type, placeholder); + } + + function buildToggleCard(id, checked, label, description, options = {}) { + return window.KefinTweaksUI.buildToggleCard(id, checked, label, description, options); + } + + function updateToggleSwitchUI(btn, checked) { + return window.KefinTweaksUI.updateToggleSwitchUI(btn, checked); + } + + function buildEnabledToggleButtons(id, enabled) { + return window.KefinTweaksUI.buildEnabledToggleButtons(id, enabled); + } + + function buildToggleSlider(id, checked, label, options = {}) { + return window.KefinTweaksUI.buildToggleSlider(id, checked, label, options); + } + + function updateToggleSliderUI(toggleButton, isEnabled) { + return window.KefinTweaksUI.updateToggleSliderUI(toggleButton, isEnabled); + } + + /** + * Build filter by played status toggle button + */ + function buildFilterByPlayedStatusToggle(id, checked) { + const isChecked = checked === true; + return ` +
+ + +
+ `; + } + + /** + * Resolve backdrops count from spotlight config (migrates legacy cycleBackdropsTime). + */ + function resolveSpotlightBackdropsCount(spotlightOpts = {}) { + if (spotlightOpts.backdropsCount != null) { + return Math.max(1, parseInt(spotlightOpts.backdropsCount, 10) || 1); + } + if (spotlightOpts.cycleBackdropsTime > 0 && spotlightOpts.interval > 0) { + return Math.max(1, Math.round(spotlightOpts.interval / spotlightOpts.cycleBackdropsTime)); + } + return 1; + } + + const SPOTLIGHT_TOGGLE_DESCRIPTIONS = { + panAnimation: 'Apply pan and zoom animations to each slide.', + showNavButtons: 'Show previous and next navigation buttons on the carousel.', + showClearArt: 'Display clear art logo overlay when available.', + cycleBackdrops: 'Cycle through multiple backdrop images per slide.', + autoPlay: 'Automatically advance slides after the timer interval.' + }; + + /** + * Collect spotlightConfig from dialog fields. + */ + function collectSpotlightConfigFromDialog(dialog, idPrefix, options = {}) { + const useIntervalSeconds = options.useIntervalSeconds === true; + const slideStateVal = dialog.querySelector('#' + idPrefix + 'slideState')?.value || 'dots'; + const tileCountVal = Math.max(1, Math.min(3, parseInt(dialog.querySelector('#' + idPrefix + 'tileCount')?.value || '1', 10))); + const intervalRaw = dialog.querySelector('#' + idPrefix + 'interval')?.value; + const intervalMs = useIntervalSeconds + ? Math.round((parseFloat(intervalRaw || '10') || 10) * 1000) + : parseInt(intervalRaw || '10000', 10); + + return { + spotlightLayout: dialog.querySelector('#' + idPrefix + 'spotlightLayout')?.value || 'Border', + spotlightSize: dialog.querySelector('#' + idPrefix + 'spotlightSize')?.value || 'normal', + tileCount: tileCountVal, + autoPlay: dialog.querySelector('#' + idPrefix + 'autoPlay')?.checked !== false, + interval: intervalMs, + showSlideState: slideStateVal !== 'none', + showDots: slideStateVal === 'dots', + showNavButtons: dialog.querySelector('#' + idPrefix + 'showNavButtons')?.checked !== false, + showClearArt: dialog.querySelector('#' + idPrefix + 'showClearArt')?.checked === true, + panAnimation: dialog.querySelector('#' + idPrefix + 'panAnimation')?.checked !== false, + entranceAnimationFirst: dialog.querySelector('#' + idPrefix + 'entranceAnimationFirst')?.value || 'fadeIn', + entranceAnimationSecond: dialog.querySelector('#' + idPrefix + 'entranceAnimationSecond')?.value || 'fadeIn', + entranceAnimationThird: dialog.querySelector('#' + idPrefix + 'entranceAnimationThird')?.value || 'fadeIn', + slideAnimationFirst: dialog.querySelector('#' + idPrefix + 'slideAnimationFirst')?.value || 'kenBurnsZoomIn', + slideAnimationSecond: dialog.querySelector('#' + idPrefix + 'slideAnimationSecond')?.value || 'kenBurnsZoomIn', + slideAnimationThird: dialog.querySelector('#' + idPrefix + 'slideAnimationThird')?.value || 'kenBurnsZoomIn', + cycleBackdrops: dialog.querySelector('#' + idPrefix + 'cycleBackdrops')?.checked === true, + backdropsCount: Math.max(1, parseInt(dialog.querySelector('#' + idPrefix + 'backdropsCount')?.value || '1', 10)) + }; + } + + /** + * Shared spotlight settings fields HTML (advanced-editor layout). + */ + function buildSharedSpotlightFieldsHTML(spotlightOpts, idPrefix, options = {}) { + const useIntervalSeconds = options.useIntervalSeconds === true; + const spotlightLayout = spotlightOpts.spotlightLayout ?? (spotlightOpts.fullScreen === true ? 'Borderless' : 'Border'); + const spotlightSize = spotlightOpts.spotlightSize ?? (spotlightOpts.fullScreen === true ? 'full' : 'normal'); + const tileCount = Math.max(1, Math.min(3, parseInt( + spotlightOpts.tileCount ?? (spotlightSize === 'full' ? 1 : spotlightSize === 'large' ? 2 : 3), + 10 + ) || 1)); + const tileCountStr = String(tileCount); + const slideStateVal = spotlightOpts.showSlideState === false ? 'none' : (spotlightOpts.showDots === false ? 'numeric' : 'dots'); + const intervalValue = useIntervalSeconds + ? Math.round((spotlightOpts.interval ?? 10000) / 1000) + : (spotlightOpts.interval ?? 10000); + const intervalLabel = useIntervalSeconds ? 'Slide Timer (seconds)' : 'Auto-play interval (ms)'; + const cycleBackdropsOn = spotlightOpts.cycleBackdrops === true; + const rawBackdropsCount = spotlightOpts.backdropsCount ?? resolveSpotlightBackdropsCount(spotlightOpts); + const backdropsCount = cycleBackdropsOn + ? Math.max(2, parseInt(rawBackdropsCount, 10) || 2) + : Math.max(1, parseInt(rawBackdropsCount, 10) || 1); + const animateSlides = spotlightOpts.panAnimation !== false; + + function animRow(n) { + const ord = n === 1 ? 'First' : n === 2 ? 'Second' : 'Third'; + return ` +
+ ${buildSelect(idPrefix + 'entranceAnimation' + ord, SPOTLIGHT_ENTRANCE_OPTIONS, spotlightOpts['entranceAnimation' + ord] ?? 'fadeIn', 'Entrance animation ' + n)} + ${buildSelect(idPrefix + 'slideAnimation' + ord, SPOTLIGHT_SLIDE_OPTIONS, spotlightOpts['slideAnimation' + ord] ?? 'kenBurnsZoomIn', 'Slide animation ' + n)} +
`; + } + + function spotlightToggle(key, checked, label) { + return buildToggleCard(idPrefix + key, checked, label, SPOTLIGHT_TOGGLE_DESCRIPTIONS[key] || ''); + } + + return ` +
+
+ ${buildSelect(idPrefix + 'spotlightLayout', SPOTLIGHT_LAYOUT_OPTIONS, spotlightLayout, 'Layout')} + ${buildSelect(idPrefix + 'spotlightSize', SPOTLIGHT_SIZE_OPTIONS, spotlightSize, 'Size')} + ${buildSelect(idPrefix + 'tileCount', SPOTLIGHT_TILE_COUNT_OPTIONS, tileCountStr, 'Tiled Backdrops')} + ${buildSelect(idPrefix + 'slideState', SLIDE_STATE_OPTIONS, slideStateVal, 'Slide State')} +
+
+ ${spotlightToggle('panAnimation', animateSlides, 'Animate Slides')} + ${spotlightToggle('showNavButtons', spotlightOpts.showNavButtons !== false, 'Show Controls')} + ${spotlightToggle('showClearArt', spotlightOpts.showClearArt === true, 'Show Clear Art')} + ${spotlightToggle('cycleBackdrops', cycleBackdropsOn, 'Cycle Backdrops')} +
+
+ ${buildTextInput(idPrefix + 'backdropsCount', backdropsCount, 'Backdrops Count', 'number')} +
+ +
+
${animRow(1)}
+
${animRow(2)}
+
${animRow(3)}
+
+
+ ${spotlightToggle('autoPlay', spotlightOpts.autoPlay !== false, 'Cycle Slides Automatically')} + ${buildTextInput(idPrefix + 'interval', intervalValue, intervalLabel, 'number')} +
+
+ `; + } + + /** + * Build spotlight settings HTML for a section editor (custom or discovery). + * Shown only when Render Mode is Spotlight; call from buildSectionEditorHTML when the editor includes render mode. + * @param {Object} section - Section or discovery config (for spotlightConfig / defaults) + * @param {string} idPrefix - Input id prefix, e.g. 'section-' or 'discovery-' + * @param {boolean} isSpotlight - Initial visibility (true = show) + * @returns {string} HTML for the spotlight options container + */ + function buildSectionEditorSpotlightHTML(section, idPrefix, isSpotlight) { + const globalSpotlight = getActiveConfig()?.SPOTLIGHT_SETTINGS || {}; + const spotlightDefaults = window.KefinHomeConfig2?.SPOTLIGHT_SETTINGS || {}; + const sectionSpotlight = section.spotlightConfig || {}; + const spotlightOpts = { ...spotlightDefaults, ...globalSpotlight, ...sectionSpotlight }; + const containerId = idPrefix + 'spotlight-options-container'; + const fieldPrefix = idPrefix + 'spotlight-'; + return ` +
+
Spotlight Options
+ ${buildSharedSpotlightFieldsHTML(spotlightOpts, fieldPrefix, { useIntervalSeconds: true })} +
+ `; + } + + /** + * Attach event listeners for spotlight settings (Size -> tileCount, tileCount -> cycle row + animation dropdown visibility). + * @param {HTMLElement} container - Container that has the spotlight form (e.g. #global-settings-content or spotlight options div) + * @param {string} idPrefix - Prefix for input ids, e.g. 'spotlight-' or 'section-spotlight-' or 'discovery-spotlight-' + */ + function attachSpotlightSettingsListeners(container, idPrefix) { + if (!container) return; + window.KefinTweaksUI?.bindToggleCards?.(container); + + const sizeSelect = container.querySelector('#' + idPrefix + 'spotlightSize'); + const tileCountSelect = container.querySelector('#' + idPrefix + 'tileCount'); + const panAnimation = container.querySelector('#' + idPrefix + 'panAnimation'); + const animDropdowns = container.querySelector('#' + idPrefix + 'animation-dropdowns'); + const animDivider = container.querySelector('#' + idPrefix + 'animation-divider'); + const animSecond = container.querySelector('#' + idPrefix + 'anim-second'); + const animThird = container.querySelector('#' + idPrefix + 'anim-third'); + const cycleBackdrops = container.querySelector('#' + idPrefix + 'cycleBackdrops'); + const backdropsRow = container.querySelector('#' + idPrefix + 'backdrops-row'); + const backdropsCountInput = container.querySelector('#' + idPrefix + 'backdropsCount'); + + function updateFromTileCount(tc) { + const t = parseInt(tc, 10) || 1; + if (animSecond) animSecond.style.display = t >= 2 ? 'block' : 'none'; + if (animThird) animThird.style.display = t >= 3 ? 'block' : 'none'; + if (animDropdowns) { + animDropdowns.classList.remove('hsae-spotlight-animations--1', 'hsae-spotlight-animations--2', 'hsae-spotlight-animations--3'); + animDropdowns.classList.add(`hsae-spotlight-animations--${t}`); + } + } + function updateAnimateVisibility() { + const enabled = panAnimation?.checked !== false; + if (animDropdowns) animDropdowns.style.display = enabled ? 'grid' : 'none'; + if (animDivider) animDivider.style.display = enabled ? 'block' : 'none'; + } + function updateCycleBackdropsRow() { + const on = cycleBackdrops?.checked === true; + if (backdropsRow) backdropsRow.style.display = on ? 'block' : 'none'; + if (on && backdropsCountInput) { + const val = parseInt(backdropsCountInput.value, 10); + if (!val || val < 2) backdropsCountInput.value = '2'; + backdropsCountInput.min = '2'; + } + } + if (tileCountSelect) { + tileCountSelect.addEventListener('change', () => updateFromTileCount(tileCountSelect.value)); + updateFromTileCount(tileCountSelect.value); + } + if (panAnimation) { + panAnimation.addEventListener('change', updateAnimateVisibility); + updateAnimateVisibility(); + } + if (cycleBackdrops) { + cycleBackdrops.addEventListener('change', updateCycleBackdropsRow); + updateCycleBackdropsRow(); + } + backdropsCountInput?.addEventListener('change', () => { + if (cycleBackdrops?.checked === true) { + const val = parseInt(backdropsCountInput.value, 10); + if (!val || val < 2) backdropsCountInput.value = '2'; + } + }); + + // Toggle cards flip checkbox without always firing change; sync conditional UI after click + container.querySelectorAll('.kefin-toggle-switch').forEach((btn) => { + btn.addEventListener('click', () => { + setTimeout(() => { + updateAnimateVisibility(); + updateCycleBackdropsRow(); + }, 0); + }); + }); + } + + function resolveAdvancedEditorBuildContext(section) { + let currentGroupName = activeEditCtx?.groupContext?.currentGroupName || ''; + let groupType = activeEditCtx?.groupType || null; + + if (section.id && !currentGroupName) { + const config = getActiveConfig(); + if (config) { + const groupTypes = ['HOME_SECTION_GROUPS', 'SEASONAL_SECTION_GROUPS', 'DISCOVERY_SECTION_GROUPS', 'CUSTOM_SECTION_GROUPS']; + for (const gt of groupTypes) { + const found = findSectionInGroupsForEditor(config[gt] || [], section.id); + if (found) { + currentGroupName = found.group.name || ''; + if (!groupType) groupType = gt; + break; + } + } + } + } + + if (!currentGroupName && section._targetGroupName && section._targetGroupName !== 'New...') { + currentGroupName = section._targetGroupName; + } + + const allGroups = getActiveConfig()?.CUSTOM_SECTION_GROUPS || []; + const existingGroupNames = [...new Set(allGroups.map(g => g.name).filter(Boolean))]; + const editorProfile = activeEditCtx?.editorProfile + || window.KefinHomeScreenEditorProfiles?.resolveEditorProfile(section, { + groupType, + isCustomSection: groupType === 'CUSTOM_SECTION_GROUPS' || activeEditCtx?.sectionType === 'custom' + }) + || 'full'; + const lockVisibility = activeEditCtx?.lockVisibility + ?? window.KefinHomeScreenEditorProfiles?.getEditorProfileDefinition(editorProfile)?.lockVisibility === true; + + return { + currentGroupName, + existingGroupNames, + config: getActiveConfig(), + editorProfile, + groupType, + lockVisibility + }; + } + + function buildSectionEditorHTML(section) { + if (!window.KefinHomeScreenAdvancedEditor?.buildHTML) { + return '
Advanced section editor is not available.
'; + } + return window.KefinHomeScreenAdvancedEditor.buildHTML(section, resolveAdvancedEditorBuildContext(section)); + } + + function buildQueryEditor(query, index, canDelete) { + const queryType = query.path ? 'custom' : (query.dataSource ? 'datasource' : 'standard'); + const pathDisplay = queryType === 'custom' ? 'block' : 'none'; + const dataSourceDisplay = queryType === 'datasource' ? 'block' : 'none'; + + return ` +
+
+
Query ${index + 1}
+ ${canDelete ? ` + + ` : ''} +
+ ${buildSelect(`query-${index}-type`, [ + { value: 'standard', label: 'Standard Items Query' }, + { value: 'custom', label: 'Custom Endpoint' }, + { value: 'datasource', label: 'Data Source' } + ], queryType, 'Query Type')} +
+ ${buildTextInput(`query-${index}-path`, query.path || '', 'Endpoint Path', 'text', '/Genres, /Persons, /Shows/NextUp, /Items/Latest, etc')} +
+
+ ${buildTextInput(`query-${index}-dataSource`, query.dataSource || '', 'Data Source (e.g., MoviesCache.getImdbTop250Movies)')} +
+
+
Parent Options
+ ${buildParentOptionsEditor(query, index)} +
+
+
Query Options
+ ${buildQueryOptionsEditor(query.queryOptions || {}, index)} +
+
+ `; + } + + /** + * Build parent options editor + */ + function buildParentOptionsEditor(query, queryIndex) { + const queryOptions = query.queryOptions || {}; + // Determine parent item type from query level first, then fall back to queryOptions for backwards compatibility + let parentItemType = query._parentItemType || ''; + + // Backwards compatibility: check queryOptions._parentItemType + if (!parentItemType && (queryOptions._parentItemType === 'Collection' || queryOptions._parentItemType === 'Playlist')) { + parentItemType = queryOptions._parentItemType; + } else if (!parentItemType && queryOptions.CollectionIds && (Array.isArray(queryOptions.CollectionIds) ? queryOptions.CollectionIds.length > 0 : queryOptions.CollectionIds)) { + // Backwards compatibility: check old CollectionIds field + parentItemType = 'Collection'; + } else if (!parentItemType && queryOptions.PlaylistIds && (Array.isArray(queryOptions.PlaylistIds) ? queryOptions.PlaylistIds.length > 0 : queryOptions.PlaylistIds)) { + // Backwards compatibility: check old PlaylistIds field + parentItemType = 'Playlist'; + } else if (!parentItemType && queryOptions.ParentId) { + parentItemType = 'Generic Parent'; + } + + return ` +
+ + ${buildSelect(`query-${queryIndex}-parentItemType`, [ + { value: '', label: 'Select a Parent... (optional)' }, + { value: 'Collection', label: 'Collection' }, + { value: 'Playlist', label: 'Playlist' }, + { value: 'Generic Parent', label: 'Generic Parent' } + ], parentItemType, 'Parent Item')} + + +
+
+
+ ${buildTextInput(`query-${queryIndex}-ParentId`, queryOptions.ParentId || '', 'Parent ID (GUID)', 'text')} +
+ + + +
+ `; + } + + /** + * Build query options editor + */ + function buildQueryOptionsEditor(queryOptions, queryIndex) { + + // Regular text/number fields + const commonFields = [ + { key: 'SortBy', label: 'Sort By', value: queryOptions.SortBy || '', options: SORT_ORDERS }, + { key: 'SortOrder', label: 'Sort Order', value: queryOptions.SortOrder || '', options: SORT_ORDER_DIRECTIONS }, + { key: 'Limit', label: 'Limit', value: queryOptions.Limit || '', type: 'number' }, + { key: 'Filters', label: 'Filters', value: queryOptions.Filters || '', placeholder: 'IsUnplayed,IsResumable,IsNotFolder' }, + { key: 'SearchTerm', label: 'Search Term', value: queryOptions.SearchTerm || '' } + ]; + + // Extract additional options (not in common fields or badge fields) + const additionalOptions = []; + const badgeFieldKeys = ['IncludeItemTypes', 'Genres', 'Tags', 'CollectionIds', 'PlaylistIds', 'ParentId']; + Object.entries(queryOptions || {}).forEach(([key, value]) => { + if (!commonFields.find(f => f.key === key) && !badgeFieldKeys.includes(key)) { + // If the key doesn't start with an underscore, add it to additionalOptions + if (!key.startsWith('_')) { + additionalOptions.push({ key, value }); + } + } + }); + + return ` +
+ +
+
+
+
+
+ + +
+ ${commonFields.map(field => ` +
+ ${field.options ? buildSelect(`query-${queryIndex}-${field.key}`, field.options, field.value, field.label) : buildTextInput(`query-${queryIndex}-${field.key}`, field.value, field.label, field.type || 'text', field.placeholder || '')} +
+ `).join('')} +
+
+ Additional Options +
+ ${buildAdditionalOptionsControls(queryIndex)} + ${buildAdditionalOptionsList(queryIndex, additionalOptions)} +
+
+
+ `; + } + + /** + * Setup badge autocomplete for a query field + */ + async function setupQueryBadgeAutocomplete(modalInstance, queryIndex, type, queryOptions = {}, includeItemTypes = null, parentItemType = null) { + if (!window.KefinTweaksUI || !window.KefinTweaksUI.createAutoCompleteInput) { + WARN('KefinTweaksUI.createAutoCompleteInput not available'); + return; + } + + const container = modalInstance.dialogContent.querySelector(`#query-${queryIndex}-${type}-container`); + if (!container) return; + + // Calculate itemTypesForFilters early (used for Genres/Tags) + const rawItemTypes = includeItemTypes || queryOptions.IncludeItemTypes; + let itemTypesForFilters = null; + if (type === 'Genres' || type === 'Tags') { + if (Array.isArray(rawItemTypes)) { + itemTypesForFilters = rawItemTypes.filter(Boolean); + } else if (rawItemTypes) { + itemTypesForFilters = [rawItemTypes]; + } else { + itemTypesForFilters = []; + } + } + + // Extract current values from query options + let currentValues = []; + if (type === 'IncludeItemTypes') { + currentValues = Array.isArray(queryOptions.IncludeItemTypes) ? queryOptions.IncludeItemTypes : (queryOptions.IncludeItemTypes ? [queryOptions.IncludeItemTypes] : []); + } else if (type === 'Genres') { + // Get selected genre names from queryOptions + const selectedGenres = queryOptions.Genres || ''; + const selectedGenreNames = selectedGenres ? (Array.isArray(selectedGenres) ? selectedGenres : selectedGenres.split(',').map(s => s.trim()).filter(s => s)) : []; + + if (window.dataHelper?.getFilterGenreItems) { + try { + const filterTypes = itemTypesForFilters?.length ? itemTypesForFilters : null; + const availableGenres = await window.dataHelper.getFilterGenreItems(filterTypes, { useCache: true }); + const genreNames = availableGenres.map(g => g.name); + const genreMap = new Map(genreNames.map(g => [g.toLowerCase(), g])); + currentValues = selectedGenreNames + .map(name => { + const exactMatch = genreNames.find(g => g === name); + if (exactMatch) return exactMatch; + return genreMap.get(name.toLowerCase()) || name; + }) + .filter(Boolean); + } catch (err) { + WARN('Error fetching genres from dataHelper:', err); + currentValues = selectedGenreNames; + } + } else { + currentValues = selectedGenreNames; + } + } else if (type === 'Tags') { + // Get selected tag names from queryOptions + const selectedTags = queryOptions.Tags || ''; + const selectedTagNames = selectedTags ? (Array.isArray(selectedTags) ? selectedTags : selectedTags.split(',').map(s => s.trim()).filter(s => s)) : []; + + if (window.dataHelper?.getFilterTagItems) { + try { + const filterTypes = itemTypesForFilters?.length ? itemTypesForFilters : null; + const availableTags = await window.dataHelper.getFilterTagItems(filterTypes, { useCache: true }); + const tagNames = availableTags.map(t => t.name); + const tagMap = new Map(tagNames.map(t => [t.toLowerCase(), t])); + currentValues = selectedTagNames + .map(name => { + const exactMatch = tagNames.find(t => t === name); + if (exactMatch) return exactMatch; + return tagMap.get(name.toLowerCase()) || name; + }) + .filter(Boolean); + } catch (err) { + WARN('Error fetching tags from dataHelper:', err); + currentValues = selectedTagNames; + } + } else { + currentValues = selectedTagNames; + } + } else if (type === 'Collections') { + // Check if ParentId contains a collection ID (when _parentItemType is 'Collection') + let collectionIds = []; + // Check query level first, then queryOptions for backwards compatibility + const effectiveParentItemType = parentItemType || queryOptions._parentItemType; + if (effectiveParentItemType === 'Collection' && queryOptions.ParentId) { + collectionIds = [queryOptions.ParentId]; + } else { + // Backwards compatibility: check old CollectionIds field + collectionIds = Array.isArray(queryOptions.CollectionIds) ? queryOptions.CollectionIds : (queryOptions.CollectionIds ? [queryOptions.CollectionIds] : []); + } + + if (collectionIds.length ===0) { + currentValues = []; + } else { + // For Collections, we need to fetch names from IDs + if (window.apiHelper && window.ApiClient) { + try { + const userId = window.ApiClient.getCurrentUserId(); + const serverAddress = window.ApiClient.serverAddress(); + if (userId && serverAddress) { + const data = await window.apiHelper.getItems( + { + IncludeItemTypes: 'BoxSet,CollectionFolder', + Recursive: true, + Fields: 'ItemCounts', + Ids: collectionIds.join(',') + }, + true, + 300000 + ); + // Convert to objects with id and name + currentValues = (data?.Items || []).map(item => ({ + id: item.Id, + name: item.Name + })); + // Add any IDs that weren't found (shouldn't happen, but just in case) + const foundIds = new Set(currentValues.map(v => v.id)); + collectionIds.forEach(id => { + if (!foundIds.has(id)) { + currentValues.push({ id: id, name: id }); // Fallback to ID if not found + } + }); + } else { + // Fallback: convert IDs to objects + currentValues = collectionIds.map(id => ({ id: id, name: id })); + } + } catch (err) { + WARN('Error fetching collection names:', err); + // Fallback: convert IDs to objects + currentValues = collectionIds.map(id => ({ id: id, name: id })); + } + } else { + // Convert IDs to objects for consistency + currentValues = collectionIds.map(id => ({ id: id, name: id })); + } + } + } else if (type === 'Playlists') { + // Check if ParentId contains a playlist ID (when _parentItemType is 'Playlist') + let playlistIds = []; + // Check query level first, then queryOptions for backwards compatibility + const effectiveParentItemType = parentItemType || queryOptions._parentItemType; + if (effectiveParentItemType === 'Playlist' && queryOptions.ParentId) { + playlistIds = [queryOptions.ParentId]; + } else { + // Backwards compatibility: check old PlaylistIds field + playlistIds = Array.isArray(queryOptions.PlaylistIds) ? queryOptions.PlaylistIds : (queryOptions.PlaylistIds ? [queryOptions.PlaylistIds] : []); + } + + if (playlistIds.length ===0) { + currentValues = []; + } else { + // For Playlists, we need to fetch names from IDs + if (window.apiHelper && window.ApiClient) { + try { + const userId = window.ApiClient.getCurrentUserId(); + const serverAddress = window.ApiClient.serverAddress(); + if (userId && serverAddress) { + const data = await window.apiHelper.getItems( + { + IncludeItemTypes: 'Playlist', + Recursive: true, + Fields: 'ItemCounts', + Ids: playlistIds.join(',') + }, + true, + 300000 + ); + // Convert to objects with id and name + currentValues = (data?.Items || []).map(item => ({ + id: item.Id, + name: item.Name + })); + // Add any IDs that weren't found + const foundIds = new Set(currentValues.map(v => v.id)); + playlistIds.forEach(id => { + if (!foundIds.has(id)) { + currentValues.push({ id: id, name: id }); // Fallback to ID if not found + } + }); + } else { + // Fallback: convert IDs to objects + currentValues = playlistIds.map(id => ({ id: id, name: id })); + } + } catch (err) { + WARN('Error fetching playlist names:', err); + // Fallback: convert IDs to objects + currentValues = playlistIds.map(id => ({ id: id, name: id })); + } + } else { + // Convert IDs to objects for consistency + currentValues = playlistIds.map(id => ({ id: id, name: id })); + } + } + } + + // Get available item types for IncludeItemTypes + const allAvailableItemTypes = [ + 'AggregateFolder', 'Audio', 'AudioBook', 'BasePluginFolder', 'Book', 'BoxSet', 'Channel', + 'ChannelFolderItem', 'CollectionFolder', 'Episode', 'Folder', 'Genre', 'ManualPlaylistsFolder', + 'Movie', 'LiveTvChannel', 'LiveTvProgram', 'MusicAlbum', 'MusicArtist', 'MusicGenre', 'MusicVideo', + 'Person', 'Photo', 'PhotoAlbum', 'Playlist', 'PlaylistsFolder', 'Program', 'Recording', 'Season', + 'Series', 'Studio', 'Trailer', 'TvChannel', 'TvProgram', 'UserRootFolder', 'UserView', 'Video', 'Year' + ]; + + // Normalize type: Collections->Collection, Playlists->Playlist, Genres->Genre, Tags->Tag + const normalizedType = type === 'IncludeItemTypes' ? 'IncludeItemTypes' + : (type === 'Collections' ? 'Collection' + : (type === 'Playlists' ? 'Playlist' + : (type === 'Genres' ? 'Genre' + : (type === 'Tags' ? 'Tag' : type)))); + + const autocomplete = window.KefinTweaksUI.createAutoCompleteInput({ + type: normalizedType, + prefix: 'query', + sectionIndex: queryIndex, + container: container, + label: type === 'IncludeItemTypes' ? 'Include Item Types' : (type === 'Collections' ? 'Collections' : (type === 'Playlists' ? 'Playlists' : type)), + description: BADGE_FIELD_DESCRIPTIONS[type] || '', + currentValuesArray: currentValues, + availableTypes: type === 'IncludeItemTypes' ? allAvailableItemTypes : null, + includeItemTypes: itemTypesForFilters, + onChange: (values) => { + // Values are updated automatically via hidden inputs + } + }); + + if (autocomplete && autocomplete.element) { + container.innerHTML = ''; + container.appendChild(autocomplete.element); + await autocomplete.setup(); + } + } + + /** + * Build additional options controls (dropdown + add button) + */ + function buildAdditionalOptionsControls(queryIndex) { + const dropdownOptions = Object.entries(SUPPORTED_QUERY_OPTIONS) + .map(([key, meta]) => ``) + .join(''); + + return ` +
+
Additional Options
+
+ + +
+
+ `; + } + + /** + * Build additional options list + */ + function buildAdditionalOptionsList(queryIndex, currentOptions) { + const optionsHtml = (currentOptions || []).map((option, index) => { + const meta = SUPPORTED_QUERY_OPTIONS[option.key] || { label: option.key, type: 'string' }; + const inputId = `query-${queryIndex}-additionalOption-${index}`; + + let inputField = ''; + if (meta.type === 'boolean') { + const isChecked = option.value === true || option.value === 'true'; + inputField = ` +
+ +
+ `; + } else { + const displayValue = Array.isArray(option.value) ? option.value.join(',') : (option.value || ''); + inputField = ` +
+
${meta.label}
+ +
+ `; + } + + return ` +
+ ${inputField} + +
+ `; + }).join(''); + + return ` +
+
+ ${optionsHtml} +
+
+ `; + } + + /** + * Build discovery section editor HTML (simplified) + */ + function buildDiscoveryEditorHTML(section) { + const isDiscoverySpotlight = section.renderMode === 'Spotlight'; + const discoverySpotlightHTML = buildSectionEditorSpotlightHTML(section, 'discovery-', isDiscoverySpotlight); + return ` +
+
+ Note: Discovery sections use dynamic sources that are determined at runtime. You can only modify basic settings here. +
+ ${buildTextInput('discovery-name', section.name || '', 'Name Template (use placeholders like {Genre}, {Person}, {Title})')} + ${buildCheckbox('discovery-enabled', section.enabled !== false, 'Enabled')} + ${buildTextInput('discovery-itemLimit', section.itemLimit || 20, 'Item Limit', 'number')} + ${buildSelect('discovery-sortOrder', SORT_ORDERS, section.sortOrder || 'Random', 'Sort Order')} + ${section.sortOrderDirection ? buildSelect('discovery-sortOrderDirection', SORT_ORDER_DIRECTIONS, section.sortOrderDirection || 'Ascending', 'Sort Order Direction') : ''} + ${buildSelect('discovery-cardFormat', CARD_FORMATS, section.cardFormat || 'Poster', 'Card Format')} + ${buildTextInput('discovery-ttl', section.ttl !== undefined ? section.ttl : '', 'TTL (ms)', 'number')} + ${buildSelect('discovery-renderMode', RENDER_MODE_OPTIONS, section.renderMode || 'Normal', 'Render Mode')} + + ${discoverySpotlightHTML} +
+ `; + } + + /** + * Collect form data from section editor + * @param {Object} originalSection - Original section data (for preserving hidden fields in default sections) + */ + function collectSectionEditorData(originalSection = null) { + const dialog = document.querySelector(`.dialogContainer[data-modal-id="${SECTION_EDITOR_MODAL_ID}"]`) || document.querySelector(`.dialogContainer[data-modal-id="${DISCOVERY_EDITOR_MODAL_ID}"]`); + if (!dialog) return null; + + if (dialog.querySelector('.hsae-root') && window.KefinHomeScreenAdvancedEditor?.collectData) { + const sectionData = window.KefinHomeScreenAdvancedEditor.collectData(dialog, originalSection); + if (!sectionData) return null; + + const groupSelect = dialog.querySelector('#section-group'); + const groupNew = dialog.querySelector('#section-group-new'); + if (groupSelect) { + const selectedGroup = groupSelect.value; + if (selectedGroup === 'New...' && groupNew?.value?.trim()) { + sectionData._targetGroupName = groupNew.value.trim(); + } else if (selectedGroup && selectedGroup !== 'New...') { + sectionData._targetGroupName = selectedGroup; + } + } + + if (sectionData.renderMode !== 'Spotlight') { + delete sectionData.spotlightConfig; + } + + if (sectionData.ttl === '') { + sectionData.ttl = undefined; + } + + return sectionData; + } + + console.warn('[KefinTweaks Section Editor] Advanced editor markup not found'); + return null; + } + + function buildAdvancedAttachContext(section, originalSection) { + const buildCtx = resolveAdvancedEditorBuildContext(section); + return { + section, + originalSection: originalSection || JSON.parse(JSON.stringify(section)), + ...buildCtx, + ...(activeEditCtx?.groupContext || {}) + }; + } + + function buildAdvancedPersistCallbacks(modalInstance) { + return { + persistSection: async (sectionData, options = {}) => { + if (typeof activeEditCtx?.onSave !== 'function') { + throw new Error('No save handler available'); + } + await activeEditCtx.onSave(sectionData, options); + }, + remountAfterPersist: (sectionData) => { + remountOpenSectionEditor(modalInstance, sectionData); + } + }; + } + + function remountOpenSectionEditor(modalInstance, sectionData) { + if (!modalInstance?.dialogContent || !window.KefinHomeScreenAdvancedEditor) return; + const section = JSON.parse(JSON.stringify(sectionData || {})); + const originalSection = JSON.parse(JSON.stringify(section)); + if (activeEditCtx) { + activeEditCtx.originalSection = originalSection; + } + + window.KefinHomeScreenAdvancedEditor.destroyAppearancePickrs?.(modalInstance); + modalInstance.dialogContent.innerHTML = buildSectionEditorHTML(section); + + const titleEl = modalInstance.dialogHeader?.querySelector('.dialogTitle, h2'); + if (titleEl) { + // Keep the "Edit Section:" chrome; badge shows the live name + titleEl.textContent = `Edit Section: ${section.name || 'Unnamed'}`; + } + + // Root listeners stay on dialogContent across innerHTML rebuilds — do not reset + // _hsaeRootListenersAttached or they will double-bind. + window.KefinHomeScreenAdvancedEditor.attachListeners( + modalInstance, + buildAdvancedAttachContext(section, originalSection), + buildAdvancedPersistCallbacks(modalInstance) + ); + } + + function openSectionEditorModal(section, ctx) { + const modalId = SECTION_EDITOR_MODAL_ID; + + // Close if already open + if (window.ModalSystem.isOpen(modalId)) { + window.ModalSystem.close(modalId); + } + + const originalSection = ctx.originalSection || JSON.parse(JSON.stringify(section)); + if (ctx) ctx.originalSection = originalSection; + if (activeEditCtx) activeEditCtx.originalSection = originalSection; + + const content = document.createElement('div'); + content.innerHTML = buildSectionEditorHTML(section); + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.style.gap = '0.5em'; + footer.style.display = 'flex'; + footer.innerHTML = ` + + + + + `; + + const modalInstance = window.ModalSystem.create({ + id: modalId, + title: `Edit Section: ${section.name || 'Unnamed'}`, + content: content, + footer: footer, + closeOnBackdrop: false, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '1400px', height: '90vh' } + : undefined, + onClose: () => { + window.KefinHomeScreenAdvancedEditor?.destroyAppearancePickrs?.(modalInstance); + activeEditCtx = null; + }, + onOpen: (modalInstance) => { + if (modalInstance.dialogContent?.querySelector('.hsae-root') && window.KefinHomeScreenAdvancedEditor) { + window.KefinHomeScreenAdvancedEditor.setupModalHeader(modalInstance); + window.KefinHomeScreenAdvancedEditor.attachListeners( + modalInstance, + buildAdvancedAttachContext(section, originalSection), + buildAdvancedPersistCallbacks(modalInstance) + ); + } + + // Attach event listeners + const saveSectionFromEditor = async () => { + const liveOriginal = activeEditCtx?.originalSection || originalSection; + const sectionData = collectSectionEditorData(liveOriginal); + if (!sectionData) { + sectionEditorShowToast('Error collecting section data'); + return null; + } + sectionData.id = sectionData.id || liveOriginal?.id || section.id; + if (typeof ctx.onSave === 'function') { + await ctx.onSave(sectionData); + } + return sectionData; + }; + + const saveBtn = modalInstance.dialogFooter.querySelector('.section-save-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + try { + const sectionData = await saveSectionFromEditor(); + if (!sectionData) return; + window.ModalSystem.close(modalId); + } finally { + saveBtn.disabled = false; + } + }); + } + + const updateBtn = modalInstance.dialogFooter.querySelector('.section-update-btn'); + if (updateBtn) { + updateBtn.addEventListener('click', async () => { + updateBtn.disabled = true; + if (saveBtn) saveBtn.disabled = true; + try { + const sectionData = await saveSectionFromEditor(); + if (!sectionData) return; + const storedId = window.KefinUserHomeScreenConfig?.getStoredSectionPrefId?.(sectionData) + || sectionData.id; + sectionEditorShowToast('Section saved. Updating users…'); + await window.KefinUserHomeScreenConfig?.updateUserHomeScreenSectionConfiguration?.(storedId); + window.ModalSystem.close(modalId); + } catch (e) { + console.warn('[KefinTweaks] Failed to update section for all users:', e); + sectionEditorShowToast('Error updating users'); + } finally { + updateBtn.disabled = false; + if (saveBtn) saveBtn.disabled = false; + } + }); + } + + // Delegated toggle-slider handler for section editor (Spotlight Options, Discovery Enabled, etc.) + // Single listener on content root so it works when controls are re-rendered and is not re-added + const contentRoot = modalInstance.dialogContent; + if (contentRoot) { + contentRoot.addEventListener('click', (e) => { + const btn = e.target.closest('.toggle-slider'); + if (!btn || !contentRoot.contains(btn)) return; + // Advanced editor attaches its own toggle handlers on .hsae-root + if (btn.closest('.hsae-root')) return; + const checkboxId = btn.dataset.checkboxId; + if (!checkboxId) return; + e.stopPropagation(); + const checkbox = document.getElementById(checkboxId); + if (checkbox && contentRoot.contains(checkbox)) { + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + updateToggleSliderUI(btn, checkbox.checked); + } + }); + } + + // Preview button handler + const previewBtn = modalInstance.dialogFooter.querySelector('.section-preview-btn'); + if (previewBtn && typeof ctx.onPreview === 'function') { + previewBtn.addEventListener('click', async () => { + const liveOriginal = activeEditCtx?.originalSection || originalSection; + const sectionData = collectSectionEditorData(liveOriginal); + if (!sectionData) { + sectionEditorShowToast('Error collecting section data for preview'); + return; + } + sectionData.id = sectionData.id || liveOriginal?.id || section.id; + await ctx.onPreview(sectionData, ctx.sectionType, previewBtn); + }); + } + + // Add query button handler (legacy editor only; advanced editor uses .hsae-add-query-btn) + const addQueryBtn = modalInstance.dialogContent.querySelector('.add-query-btn'); + if (addQueryBtn && !modalInstance.dialogContent.querySelector('.hsae-root')) { + addQueryBtn.addEventListener('click', () => { + const queriesList = modalInstance.dialogContent.querySelector('#queries-list'); + const newIndex = queriesList.querySelectorAll('.query-editor').length; + const newQueryHTML = buildQueryEditor({}, newIndex, true); + queriesList.insertAdjacentHTML('beforeend', newQueryHTML); + + // Setup additional options listeners for new query + setupAdditionalOptionsListeners(modalInstance, newIndex); + + // Setup badge autocomplete for new query + (async () => { + const emptyQueryOptions = {}; + await setupQueryBadgeAutocomplete(modalInstance, newIndex, 'IncludeItemTypes', emptyQueryOptions); + await setupQueryBadgeAutocomplete(modalInstance, newIndex, 'Genres', emptyQueryOptions, ['Movie']); + await setupQueryBadgeAutocomplete(modalInstance, newIndex, 'Tags', emptyQueryOptions, ['Movie']); + // Collections/Playlists will be set up when parent item type is selected + })(); + + // Setup query type change handler for new query + const newQueryEditor = queriesList.querySelector(`.query-editor[data-query-index="${newIndex}"]`); + if (newQueryEditor) { + const typeSelect = newQueryEditor.querySelector(`#query-${newIndex}-type`); + if (typeSelect) { + const pathContainer = newQueryEditor.querySelector(`#query-${newIndex}-path-container`); + const dataSourceContainer = newQueryEditor.querySelector(`#query-${newIndex}-dataSource-container`); + + typeSelect.addEventListener('change', () => { + const newQueryType = typeSelect.value; + if (pathContainer) pathContainer.style.display = newQueryType === 'custom' ? 'block' : 'none'; + if (dataSourceContainer) dataSourceContainer.style.display = newQueryType === 'datasource' ? 'block' : 'none'; + }); + } + } + + // Re-attach delete handlers for all queries + modalInstance.dialogContent.querySelectorAll('.delete-query-btn').forEach(btn => { + if (!btn.dataset.listenerAttached) { + btn.dataset.listenerAttached = 'true'; + btn.addEventListener('click', () => { + const index = parseInt(btn.dataset.queryIndex, 10); + const queryEditor = modalInstance.dialogContent.querySelector(`.query-editor[data-query-index="${index}"]`); + if (queryEditor) { + queryEditor.remove(); + // Re-index remaining queries + modalInstance.dialogContent.querySelectorAll('.query-editor').forEach((editor, newIndex) => { + editor.dataset.queryIndex = newIndex; + const titleEl = editor.querySelector('.listItemBodyText'); + if (titleEl) titleEl.textContent = `Query ${newIndex + 1}`; + }); + // Show/hide merge sorting based on query count + const queriesCount = modalInstance.dialogContent.querySelectorAll('.query-editor').length; + const mergeSorting = modalInstance.dialogContent.querySelector('[data-merge-sorting]'); + if (mergeSorting) { + mergeSorting.style.display = queriesCount > 1 ? 'block' : 'none'; + } + } + }); + } + }); + + // Show merge sorting if multiple queries + const queriesCount = modalInstance.dialogContent.querySelectorAll('.query-editor').length; + const mergeSorting = modalInstance.dialogContent.querySelector('[data-merge-sorting]'); + if (mergeSorting) { + mergeSorting.style.display = queriesCount > 1 ? 'block' : 'none'; + } + }); + } + + // Delete query button handlers + modalInstance.dialogContent.querySelectorAll('.delete-query-btn').forEach(btn => { + btn.addEventListener('click', () => { + const index = parseInt(btn.dataset.queryIndex, 10); + const queryEditor = modalInstance.dialogContent.querySelector(`.query-editor[data-query-index="${index}"]`); + if (queryEditor) { + queryEditor.remove(); + // Re-index remaining queries + modalInstance.dialogContent.querySelectorAll('.query-editor').forEach((editor, newIndex) => { + editor.dataset.queryIndex = newIndex; + const titleEl = editor.querySelector('.listItemBodyText'); + if (titleEl) titleEl.textContent = `Query ${newIndex + 1}`; + // Update all input IDs + editor.querySelectorAll('input, select, textarea').forEach(input => { + const oldId = input.id; + if (oldId && oldId.includes(`query-${index}-`)) { + input.id = oldId.replace(`query-${index}-`, `query-${newIndex}-`); + const label = editor.querySelector(`label[for="${oldId}"]`); + if (label) label.setAttribute('for', input.id); + } + }); + }); + // Show/hide merge sorting based on query count + const queriesCount = modalInstance.dialogContent.querySelectorAll('.query-editor').length; + const mergeSorting = modalInstance.dialogContent.querySelector('[data-merge-sorting]'); + if (mergeSorting) { + mergeSorting.style.display = queriesCount > 1 ? 'block' : 'none'; + } + } + }); + }); + + // Handle query type changes to show/hide relevant fields + modalInstance.dialogContent.querySelectorAll('[id^="query-"][id$="-type"]').forEach(select => { + const queryEditor = select.closest('.query-editor'); + const queryIndex = queryEditor.dataset.queryIndex; + + // Set initial state + const queryType = select.value; + const pathContainer = queryEditor.querySelector(`#query-${queryIndex}-path-container`); + const dataSourceContainer = queryEditor.querySelector(`#query-${queryIndex}-dataSource-container`); + + if (pathContainer) pathContainer.style.display = queryType === 'custom' ? 'block' : 'none'; + if (dataSourceContainer) dataSourceContainer.style.display = queryType === 'datasource' ? 'block' : 'none'; + + // Handle changes + select.addEventListener('change', () => { + const newQueryType = select.value; + if (pathContainer) pathContainer.style.display = newQueryType === 'custom' ? 'block' : 'none'; + if (dataSourceContainer) dataSourceContainer.style.display = newQueryType === 'datasource' ? 'block' : 'none'; + }); + }); + + // Handle parent item type dropdown changes + modalInstance.dialogContent.querySelectorAll('[id^="query-"][id$="-parentItemType"]').forEach(select => { + const queryEditor = select.closest('.query-editor'); + const queryIndex = parseInt(queryEditor.dataset.queryIndex, 10); + + const collectionsContainer = queryEditor.querySelector(`#query-${queryIndex}-Collections-container`); + const playlistsContainer = queryEditor.querySelector(`#query-${queryIndex}-Playlists-container`); + const genericParentContainer = queryEditor.querySelector(`#query-${queryIndex}-GenericParent-container`); + const hiddenParentItemType = queryEditor.querySelector(`#query-${queryIndex}-ParentItemType`); + + // Helper function to setup autocomplete for a container + const setupAutocompleteForType = async (containerType, container) => { + if (!container) { + WARN(`Container not found for ${containerType} at query index ${queryIndex}`); + return; + } + // Check if autocomplete is already set up by looking for the autocomplete input + const existingInput = container.querySelector('.autocomplete-input'); + if (existingInput) { + LOG(`Autocomplete already set up for ${containerType} at query index ${queryIndex}`); + return; // Already set up + } + LOG(`Setting up autocomplete for ${containerType} at query index ${queryIndex}`); + + // Get query and queryOptions from the section's queries array (passed to openSectionEditor) + // Fallback to empty object if not available + const query = (section.queries && section.queries[queryIndex]) ? section.queries[queryIndex] : {}; + const queryOptions = query.queryOptions || {}; + const queryParentItemType = query._parentItemType || null; + await setupQueryBadgeAutocomplete(modalInstance, queryIndex, containerType, queryOptions, null, queryParentItemType); + }; + + // Set initial state and setup autocomplete if needed + const parentItemType = select.value; + + // Set parent item type data attribute + const parentElement = select.parentElement?.parentElement; + if (parentElement) { + parentElement.dataset.parentItemType = parentItemType; + } + + // Set initial display states + if (collectionsContainer) { + collectionsContainer.style.display = parentItemType === 'Collection' ? 'block' : 'none'; + } + if (playlistsContainer) { + playlistsContainer.style.display = parentItemType === 'Playlist' ? 'block' : 'none'; + } + if (genericParentContainer) { + genericParentContainer.style.display = parentItemType === 'Generic Parent' ? 'block' : 'none'; + } + if (hiddenParentItemType) hiddenParentItemType.value = parentItemType; + + // Setup autocomplete for initially visible containers + if (collectionsContainer && parentItemType === 'Collection') { + setupAutocompleteForType('Collections', collectionsContainer).catch(err => { + ERR('Error setting up Collections autocomplete:', err); + }); + } + if (playlistsContainer && parentItemType === 'Playlist') { + setupAutocompleteForType('Playlists', playlistsContainer).catch(err => { + ERR('Error setting up Playlists autocomplete:', err); + }); + } + + // Handle changes + select.addEventListener('change', () => { + const newParentItemType = select.value; + + // Select list immediate parent element + const parentElement = select.parentElement?.parentElement; + if (parentElement) { + parentElement.dataset.parentItemType = newParentItemType; + } + + // Update display styles synchronously + if (collectionsContainer) { + collectionsContainer.style.display = newParentItemType === 'Collection' ? 'block' : 'none'; + } + if (playlistsContainer) { + playlistsContainer.style.display = newParentItemType === 'Playlist' ? 'block' : 'none'; + } + if (genericParentContainer) { + genericParentContainer.style.display = newParentItemType === 'Generic Parent' ? 'block' : 'none'; + } + if (hiddenParentItemType) hiddenParentItemType.value = newParentItemType; + + // Force synchronous repaint by accessing layout property + if (collectionsContainer) void collectionsContainer.offsetHeight; + if (playlistsContainer) void playlistsContainer.offsetHeight; + if (genericParentContainer) void genericParentContainer.offsetHeight; + + // Defer async autocomplete setup to allow browser to paint first + requestAnimationFrame(async () => { + if (collectionsContainer && newParentItemType === 'Collection') { + await setupAutocompleteForType('Collections', collectionsContainer); + } + if (playlistsContainer && newParentItemType === 'Playlist') { + await setupAutocompleteForType('Playlists', playlistsContainer); + } + }); + }); + }); + + // Setup additional options listeners for all queries + modalInstance.dialogContent.querySelectorAll('.query-editor').forEach(queryEditor => { + const queryIndex = parseInt(queryEditor.dataset.queryIndex, 10); + setupAdditionalOptionsListeners(modalInstance, queryIndex); + }); + + // Setup badge autocomplete for all queries (only for custom sections, not default sections) + const queriesList = modalInstance.dialogContent.querySelector('#queries-list'); + if (queriesList) { + // This is a custom section with queries editor + const queries = section.queries || [{}]; + queries.forEach(async (query, queryIndex) => { + const queryOptions = query.queryOptions || {}; + const includeItemTypes = queryOptions.IncludeItemTypes; + // Always setup: IncludeItemTypes, Genres, Tags + await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'IncludeItemTypes', queryOptions, null, query._parentItemType); + await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'Genres', queryOptions, includeItemTypes, query._parentItemType); + await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'Tags', queryOptions, includeItemTypes, query._parentItemType); + + // Conditionally setup Collections/Playlists based on parent item type + const queryEditor = queriesList.querySelector(`.query-editor[data-query-index="${queryIndex}"]`); + + // Check query level first, then queryOptions for backwards compatibility + let parentItemType = query._parentItemType || queryOptions._parentItemType || ''; + + if (parentItemType === 'Collection') { + await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'Collections', queryOptions, null, query._parentItemType); + } else if (parentItemType === 'Playlist') { + await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'Playlists', queryOptions, null, query._parentItemType); + } + }); + } + + // Render Mode dropdown - show/hide Card Format and Spotlight Options (section editor) + const renderModeSelect = modalInstance.dialogContent.querySelector('#section-renderMode'); + const cardFormatContainer = modalInstance.dialogContent.querySelector('#section-cardFormat-container'); + const spotlightOptionsContainer = modalInstance.dialogContent.querySelector('#section-spotlight-options-container'); + if (renderModeSelect) { + const updateRenderModeDependentVisibility = () => { + const isSpotlight = renderModeSelect.value === 'Spotlight'; + if (cardFormatContainer) cardFormatContainer.style.display = isSpotlight ? 'none' : 'block'; + if (spotlightOptionsContainer) spotlightOptionsContainer.style.display = isSpotlight ? 'block' : 'none'; + if (isSpotlight && spotlightOptionsContainer) attachSpotlightSettingsListeners(spotlightOptionsContainer, 'section-spotlight-'); + }; + renderModeSelect.addEventListener('change', updateRenderModeDependentVisibility); + updateRenderModeDependentVisibility(); // Set initial state + } + + // Render Mode dropdown - show/hide Spotlight Options (discovery editor) + const discoveryRenderModeSelect = modalInstance.dialogContent.querySelector('#discovery-renderMode'); + const discoverySpotlightOptionsContainer = modalInstance.dialogContent.querySelector('#discovery-spotlight-options-container'); + if (discoveryRenderModeSelect && discoverySpotlightOptionsContainer) { + const updateDiscoverySpotlightVisibility = () => { + const isSpotlight = discoveryRenderModeSelect.value === 'Spotlight'; + discoverySpotlightOptionsContainer.style.display = isSpotlight ? 'block' : 'none'; + if (isSpotlight) attachSpotlightSettingsListeners(discoverySpotlightOptionsContainer, 'discovery-spotlight-'); + }; + discoveryRenderModeSelect.addEventListener('change', updateDiscoverySpotlightVisibility); + updateDiscoverySpotlightVisibility(); // Set initial state + } + + // Section Group dropdown - show/hide new group input + const groupSelect = modalInstance.dialogContent.querySelector('#section-group'); + const newGroupContainer = modalInstance.dialogContent.querySelector('#section-group-new-container'); + if (groupSelect && newGroupContainer) { + // Set initial state + if (groupSelect.value === 'New...') { + newGroupContainer.style.display = 'block'; + } + groupSelect.addEventListener('change', () => { + newGroupContainer.style.display = groupSelect.value === 'New...' ? 'block' : 'none'; + }); + } + + // Enabled toggle buttons handler + modalInstance.dialogContent.querySelectorAll('.enabled-toggle-btn').forEach(btn => { + btn.addEventListener('click', () => { + const enabled = btn.dataset.enabled === 'true'; + // Update all buttons + modalInstance.dialogContent.querySelectorAll('.enabled-toggle-btn').forEach(b => { + const isActive = b.dataset.enabled === String(enabled); + b.classList.toggle('active', isActive); + if (isActive) { + b.style.borderColor = enabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.8)'; + b.style.background = enabled ? 'rgba(0, 164, 220, 0.2)' : 'rgba(158, 158, 158, 0.2)'; + b.style.color = enabled ? 'var(--theme-primary-color, #00a4dc)' : 'rgba(255,255,255,0.9)'; + b.style.fontWeight = '600'; + } else { + b.style.borderColor = 'rgba(255,255,255,0.2)'; + b.style.background = 'transparent'; + b.style.color = 'rgba(255,255,255,0.7)'; + b.style.fontWeight = '400'; + } + }); + }); + }); + + // Filter by played status toggle handler + const filterToggle = modalInstance.dialogContent.querySelector('.filter-played-toggle'); + if (filterToggle) { + filterToggle.addEventListener('click', () => { + const currentChecked = filterToggle.dataset.checked === 'true'; + const newChecked = !currentChecked; + filterToggle.dataset.checked = newChecked; + + // Update toggle visual state + filterToggle.style.background = newChecked ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)'; + + const onText = filterToggle.querySelector('span:first-of-type'); + if (onText) { + onText.style.opacity = newChecked ? '1' : '0'; + onText.style.left = newChecked ? '8px' : '6px'; + } + + const offText = filterToggle.querySelector('span:nth-of-type(2)'); + if (offText) { + offText.style.opacity = newChecked ? '0' : '1'; + offText.style.right = newChecked ? '6px' : '8px'; + } + + const knob = filterToggle.querySelector('span:last-of-type'); + if (knob) { + knob.style.left = newChecked ? 'calc(100% - 26px)' : '3px'; + } + }); + } + + const discoveryEnabledToggle = modalInstance.dialogContent.querySelector('.section-discoveryEnabled-toggle'); + if (discoveryEnabledToggle) { + discoveryEnabledToggle.addEventListener('click', () => { + const currentChecked = discoveryEnabledToggle.dataset.enabled === 'true'; + const newChecked = !currentChecked; + discoveryEnabledToggle.dataset.enabled = newChecked; + + updateToggleSliderUI(discoveryEnabledToggle, newChecked); + }); + } + } + }); + } + + function setupAdditionalOptionsListeners(modalInstance, queryIndex) { + const dialog = modalInstance.dialogContainer; + const controls = dialog.querySelector(`.query-${queryIndex}-additional-options-controls[data-query-index="${queryIndex}"]`); + const listContainer = dialog.querySelector(`.query-${queryIndex}-additional-options-list-container[data-query-index="${queryIndex}"]`); + + if (controls) { + const addBtn = controls.querySelector('.add-additional-option'); + const select = controls.querySelector('.additional-option-select'); + const list = listContainer ? listContainer.querySelector('.additional-options-list') : null; + + if (addBtn && select && list) { + addBtn.addEventListener('click', () => { + const key = select.value; + if (!key) return; + + const meta = SUPPORTED_QUERY_OPTIONS[key]; + if (!meta) return; + + const index = list.children.length; + const inputId = `query-${queryIndex}-additionalOption-${Date.now()}_${index}`; + + let inputField = ''; + if (meta.type === 'boolean') { + inputField = ` +
+ +
+ `; + } else { + inputField = ` +
+
${meta.label}
+ +
+ `; + } + + const row = document.createElement('div'); + row.className = 'additional-option-row'; + row.style.cssText = 'display: flex; align-items: flex-end; gap: 0.5em; margin-top: 0.5em; padding: 0.5em; background: rgba(255,255,255,0.05); border-radius: 4px;'; + row.innerHTML = ` + ${inputField} + + `; + + // Add delete listener for new row + row.querySelector('.remove-additional-option').addEventListener('click', () => { + row.remove(); + }); + + list.appendChild(row); + + // Reset select + select.value = ''; + }); + } + } + + // Add listeners for existing delete buttons + if (listContainer) { + listContainer.querySelectorAll('.remove-additional-option').forEach(btn => { + btn.addEventListener('click', (e) => { + const row = e.target.closest('.additional-option-row'); + if (row) row.remove(); + }); + }); + } + } + + function buildGlobalSpotlightSettingsHTML(config) { + if (!config) return ''; + const spotlight = config.SPOTLIGHT_SETTINGS || {}; + const defaults = window.KefinHomeConfig2?.SPOTLIGHT_SETTINGS || {}; + const spotlightOpts = { ...defaults, ...spotlight }; + return ` +
+
+

Default behavior for spotlight sections on the home screen. Per-section overrides can be set when editing a section.

+
+ ${buildSharedSpotlightFieldsHTML(spotlightOpts, 'spotlight-', { useIntervalSeconds: true })} +
+ `; + } + + function attachGlobalSpotlightSettings(container, config) { + if (!container) return; + container.innerHTML = buildGlobalSpotlightSettingsHTML(config); + attachSpotlightSettingsListeners(container, 'spotlight-'); + } + + function openEditSection(section, ctx) { + activeEditCtx = ctx; + return openSectionEditorModal(section, ctx); + } + + function openCreateSection(section, ctx = {}) { + if (!window.ModalSystem) { + console.error('[KefinTweaks Section Editor] ModalSystem not available'); + return null; + } + + const { sectionType = 'custom', config = {}, onSave, onPreview } = ctx; + + if (typeof onSave !== 'function') { + console.error('[KefinTweaks Section Editor] onSave callback is required'); + return null; + } + + if (window.ModalSystem.isOpen(CREATE_FLOW_MODAL_ID)) { + window.ModalSystem.close(CREATE_FLOW_MODAL_ID); + } + + let stage = 'mode'; + let modalInstance = null; + + const wizardState = { + step: 'type', + maxReachedStepIndex: 0, + parentType: null, + parents: [], + search: '', + parentSearchByType: {}, + parentFilterItemTypesByType: {}, + parentFetchKeyByType: {}, + parentItemsByType: {}, + parentListStatus: 'idle', + parentListError: null, + personSearchResults: [], + customItems: [], + externalListUrls: [''], + customItemAdditionalExpanded: {}, + customItemEditorIndex: null, + includeItemTypes: [], + sortBy: 'Default', + sortOrder: 'Descending', + unwatchedOnly: false, + renderMode: 'Normal', + cardFormat: 'Poster', + spotlightLayout: 'Border', + spotlightSize: 'normal', + tileCount: '1', + panAnimation: true, + sectionName: '', + sectionGroupSelect: '', + sectionGroupNew: '', + order: '', + sectionVisibility: 'normal', + startDate: '', + endDate: '', + _config: config + }; + + let parentSearchDebounceTimer = null; + let customImagePreviewTimer = null; + let appearancePreviewDebounceTimer = null; + let appearancePreviewRequestId = 0; + let parentLoadRequestId = 0; + let appearancePreviewCache = { queryKey: null, items: null, resolvedSection: null }; + + async function loadParentItemsForType(wizardState, root) { + if (!wizardState.parentType) return; + + const requestId = ++parentLoadRequestId; + wizardState.parentListStatus = 'loading'; + wizardState.parentListError = null; + + const list = root?.querySelector('#hsse-parent-list'); + if (list) list.innerHTML = renderParentListHTML(wizardState); + + try { + const parentSearch = getParentSearch(wizardState); + const parentFilterTypes = getParentFilterItemTypes(wizardState); + if (wizardState.parentType === 'Person') { + const results = await fetchPersons(parentSearch); + if (requestId !== parentLoadRequestId) return; + wizardState.personSearchResults = results; + } else if (SERVER_SEARCH_PARENT_TYPES.has(wizardState.parentType)) { + const items = await fetchWizardParentItems(wizardState.parentType, { + search: parentSearch, + includeItemTypes: parentFilterTypes + }); + if (requestId !== parentLoadRequestId) return; + wizardState.parentItemsByType[wizardState.parentType] = items; + } else { + const items = await fetchWizardParentItems(wizardState.parentType, { + includeItemTypes: parentFilterTypes + }); + if (requestId !== parentLoadRequestId) return; + wizardState.parentItemsByType[wizardState.parentType] = items; + } + markParentFetchKey(wizardState); + wizardState.parentListStatus = 'idle'; + } catch (err) { + if (requestId !== parentLoadRequestId) return; + wizardState.parentListStatus = 'error'; + wizardState.parentListError = err.message || 'Failed to load items'; + console.error('[KefinTweaks Section Editor] Parent load error:', err); + } + + const listEl = root?.querySelector('#hsse-parent-list'); + if (listEl) listEl.innerHTML = renderParentListHTML(wizardState); + if (wizardState.parents?.length) scrollSelectedParentIntoView(root); + } + + function refreshParentListDisplay(root) { + const list = root?.querySelector('#hsse-parent-list'); + if (list) list.innerHTML = renderParentListHTML(wizardState); + } + + function scheduleParentSearch(root) { + if (parentSearchDebounceTimer) clearTimeout(parentSearchDebounceTimer); + + if (SERVER_SEARCH_PARENT_TYPES.has(wizardState.parentType)) { + parentSearchDebounceTimer = setTimeout(() => { + loadParentItemsForType(wizardState, root); + }, WIZARD_PARENT_DATA.SEARCH_DEBOUNCE_MS); + } else { + refreshParentListDisplay(root); + } + } + + function scheduleAppearancePreview(root) { + if (wizardState.step !== 'appearance') return; + if (appearancePreviewDebounceTimer) clearTimeout(appearancePreviewDebounceTimer); + + appearancePreviewDebounceTimer = setTimeout(async () => { + const container = root?.querySelector('#hsse-appearance-preview-container'); + if (!container) return; + + const fetchPreview = window.KefinHomeScreen?.fetchSectionPreviewItems; + const renderPreview = window.KefinHomeScreen?.renderSectionPreviewInto; + const mergeResolved = window.KefinHomeScreen?.mergeDraftWithResolvedPreview; + if (typeof fetchPreview !== 'function' || typeof renderPreview !== 'function') { + container.innerHTML = '

Preview unavailable.

'; + return; + } + + syncCurrentStepFromForm(root, wizardState); + const draft = wizardStateToSection(wizardState, section); + const queryKey = getAppearancePreviewQueryKey(wizardState); + const requestId = ++appearancePreviewRequestId; + const cacheHit = appearancePreviewCache.queryKey === queryKey + && appearancePreviewCache.items !== null; + + if (!cacheHit) { + container.innerHTML = '

Loading preview…

'; + } + + try { + if (!cacheHit) { + const { items, section: resolvedSection } = await fetchPreview(draft, { useSpotlightFields: true }); + if (requestId !== appearancePreviewRequestId) return; + appearancePreviewCache = { queryKey, items, resolvedSection: resolvedSection || null }; + } + + if (requestId !== appearancePreviewRequestId) return; + const previewSection = typeof mergeResolved === 'function' + ? mergeResolved(draft, appearancePreviewCache.resolvedSection) + : draft; + renderPreview(previewSection, appearancePreviewCache.items, container); + } catch (err) { + if (requestId !== appearancePreviewRequestId) return; + appearancePreviewCache = { queryKey: null, items: null, resolvedSection: null }; + container.innerHTML = `

${escapeHtml(err.message || 'Preview failed')}

`; + } + }, WIZARD_PARENT_DATA.APPEARANCE_PREVIEW_DEBOUNCE_MS); + } + + function navigateToStep(root, targetStep) { + const targetIndex = getStepIndex(targetStep, wizardState); + if (targetIndex < 0 || targetIndex > wizardState.maxReachedStepIndex) return; + if (targetStep === wizardState.step) return; + + syncCurrentStepFromForm(root, wizardState); + if (wizardState.step === 'customItems' && targetStep !== 'customItems') { + wizardState.customItemEditorIndex = null; + } + wizardState.step = targetStep; + refreshModal(root); + } + + function advanceToStep(root, nextStep) { + if (wizardState.step === 'customItems' && nextStep !== 'customItems') { + wizardState.customItemEditorIndex = null; + } + const nextIndex = getStepIndex(nextStep, wizardState); + markStepReached(wizardState, nextIndex); + wizardState.step = nextStep; + refreshModal(root); + } + + function resetModalHeader() { + if (!modalInstance?.dialogHeader) return; + + const header = modalInstance.dialogHeader; + const closeBtn = header.querySelector('.btnClose'); + const titleText = header.querySelector('.dialogTitle, h2')?.textContent || 'Create a Section'; + + header.querySelector('.hsse-header-main')?.remove(); + header.querySelector('.hsae-header-main')?.remove(); + delete header.dataset.hsseHeaderSetup; + delete header.dataset.hsaeHeaderSetup; + + if (!header.querySelector('h2')) { + const titleEl = document.createElement('h2'); + titleEl.className = 'dialogTitle'; + titleEl.textContent = titleText; + if (closeBtn) { + header.insertBefore(titleEl, closeBtn); + } else { + header.prepend(titleEl); + } + } + } + + function buildAdvancedGroupContext(sectionDraft) { + return { + currentGroupName: sectionDraft._targetGroupName || '', + existingGroupNames: getExistingGroupNames(config), + config, + editorProfile: 'full', + lockVisibility: false + }; + } + + function persistAdvancedDraft() { + const adv = window.KefinHomeScreenAdvancedEditor; + if (!adv?.collectData || stage !== 'advanced' || !modalInstance) return; + + const dialog = modalInstance.dialogContainer || modalInstance.dialogContent; + const collected = adv.collectData(dialog, section); + if (collected) { + Object.assign(section, collected); + } + } + + function mountAdvancedStage(root) { + const adv = window.KefinHomeScreenAdvancedEditor; + if (!adv?.buildHTML || !modalInstance) return; + + stage = 'advanced'; + resetModalHeader(); + + const groupContext = buildAdvancedGroupContext(section); + const originalSection = JSON.parse(JSON.stringify(section)); + + modalInstance.dialogContent.innerHTML = adv.buildHTML(section, groupContext); + + if (modalInstance.dialogFooter) { + modalInstance.dialogFooter.innerHTML = buildAdvancedCreateFooterHTML(sectionType); + modalInstance.dialogFooter.style.display = 'flex'; + } + + adv.attachListeners(modalInstance, { + section, + ...groupContext, + originalSection, + headerTitle: 'Create a Section' + }, { + onSave: async (sectionData) => { + Object.assign(section, sectionData); + await onSave(sectionData); + window.ModalSystem.close(CREATE_FLOW_MODAL_ID); + } + }); + + const previewBtn = modalInstance.dialogFooter?.querySelector('.section-preview-btn'); + if (previewBtn && typeof onPreview === 'function' && previewBtn.dataset.listenerAttached !== 'true') { + previewBtn.dataset.listenerAttached = 'true'; + previewBtn.addEventListener('click', async () => { + const dialog = modalInstance.dialogContainer || modalInstance.dialogContent; + const sectionData = adv.collectData(dialog, section); + if (!sectionData) return; + await onPreview(sectionData, sectionType, previewBtn); + }); + } + } + + function setupModalHeader() { + if (!modalInstance?.dialogHeader) return; + if (modalInstance.dialogHeader.dataset.hsseHeaderSetup === 'true') return; + modalInstance.dialogHeader.dataset.hsseHeaderSetup = 'true'; + + const titleEl = modalInstance.dialogHeader.querySelector('h2'); + const closeBtn = modalInstance.dialogHeader.querySelector('.btnClose'); + + const headerMain = document.createElement('div'); + headerMain.className = 'hsse-header-main'; + + const titleWrap = document.createElement('div'); + titleWrap.className = 'hsse-header-title-wrap'; + if (titleEl) { + titleEl.classList.add('dialogTitle'); + titleWrap.appendChild(titleEl); + } + + headerMain.appendChild(titleWrap); + + if (closeBtn) { + modalInstance.dialogHeader.insertBefore(headerMain, closeBtn); + } else { + modalInstance.dialogHeader.prepend(headerMain); + } + } + + function updateModalHeader() { + if (!modalInstance?.dialogHeader) return; + setupModalHeader(); + + const titleEl = modalInstance.dialogHeader.querySelector('.dialogTitle, h2'); + if (titleEl) titleEl.textContent = getModalTitle(stage, wizardState); + } + + function refreshModal(root) { + if (!modalInstance) return; + + const content = document.createElement('div'); + content.className = 'hsse-root'; + content.innerHTML = stage === 'mode' ? renderModeSelectHTML() : renderWizardHTML(wizardState, config); + + modalInstance.dialogContent.innerHTML = ''; + modalInstance.dialogContent.appendChild(content); + + updateModalHeader(); + + if (modalInstance.dialogFooter) { + const footerHTML = getFooterHTML(stage, wizardState); + modalInstance.dialogFooter.innerHTML = footerHTML; + modalInstance.dialogFooter.style.display = footerHTML.trim() ? 'flex' : 'none'; + } + + const activeRoot = root || modalInstance.dialogContent; + attachToggleHandlers(activeRoot); + + if (stage === 'wizard' && wizardState.step === 'parent' && wizardState.parentType) { + const cached = wizardState.parentItemsByType[wizardState.parentType]; + const keyMatches = parentFetchKeyMatches(wizardState); + const needsLoad = wizardState.parentType === 'Person' + ? !keyMatches || (wizardState.parentListStatus === 'idle' && wizardState.personSearchResults.length === 0) + : !keyMatches || (!cached && wizardState.parentListStatus !== 'loading'); + if (needsLoad || wizardState.parentListStatus === 'error') { + loadParentItemsForType(wizardState, activeRoot); + } else if (wizardState.parents?.length) { + scrollSelectedParentIntoView(activeRoot); + } + } + + if (stage === 'wizard' && wizardState.step === 'appearance') { + scheduleAppearancePreview(activeRoot); + } + + if (stage === 'wizard' && wizardState.step === 'customItems') { + window.KefinHomeScreenCustomItemsEditor?.refreshPreviews(activeRoot, wizardState, getWizardCustomItemsOptions()); + } + } + + function handleAction(action, el, root) { + const wizardCustomActions = new Set([ + 'wizard-open-custom-item-editor', + 'wizard-close-custom-item-editor', + 'wizard-add-custom-item', + 'wizard-remove-custom-item', + 'wizard-toggle-custom-images', + 'wizard-toggle-custom-item-hide', + 'wizard-move-custom-item-left', + 'wizard-move-custom-item-right' + ]); + if (wizardCustomActions.has(action) && window.KefinHomeScreenCustomItemsEditor) { + window.KefinHomeScreenCustomItemsEditor.handleAction( + action, + el, + root, + wizardState, + getWizardCustomItemsOptions(), + () => refreshModal(root) + ); + return; + } + + switch (action) { + case 'cancel': + window.ModalSystem.close(CREATE_FLOW_MODAL_ID); + break; + + case 'select-wizard': + stage = 'wizard'; + wizardState.step = 'type'; + wizardState.maxReachedStepIndex = 0; + refreshModal(root); + break; + + case 'select-advanced': + mountAdvancedStage(root); + break; + + case 'advanced-back': + persistAdvancedDraft(); + resetModalHeader(); + delete modalInstance._hsaeState; + stage = 'mode'; + refreshModal(root); + break; + + case 'wizard-select-type': { + const selected = el.dataset.type; + if (selected !== wizardState.parentType) { + wizardState.parentType = selected; + wizardState.parents = []; + // Restore per-type search; do not share chips/search across Genre/Tag/Studio + wizardState.search = getParentSearch(wizardState, selected); + wizardState.parentListStatus = 'idle'; + wizardState.parentListError = null; + if (selected !== 'Person') { + wizardState.personSearchResults = []; + } + wizardState.maxReachedStepIndex = 0; + if (selected === 'Custom') { + wizardState.customItems = [createBlankCustomItem()]; + wizardState.customItemEditorIndex = null; + wizardState.externalListUrls = ['']; + advanceToStep(root, 'customItems'); + } else if (selected === 'External') { + wizardState.customItems = []; + wizardState.externalListUrls = wizardState.externalListUrls?.length + ? wizardState.externalListUrls + : ['']; + advanceToStep(root, 'externalList'); + } else { + wizardState.customItems = []; + wizardState.externalListUrls = ['']; + advanceToStep(root, 'parent'); + } + } else if (selected === 'Custom') { + wizardState.customItemEditorIndex = null; + advanceToStep(root, 'customItems'); + } else if (selected === 'External') { + advanceToStep(root, 'externalList'); + } else { + advanceToStep(root, 'parent'); + } + break; + } + + case 'wizard-add-external-list-url': { + collectExternalListFromForm(root, wizardState); + if (!Array.isArray(wizardState.externalListUrls)) wizardState.externalListUrls = ['']; + wizardState.externalListUrls.push(''); + refreshModal(root); + break; + } + + case 'wizard-open-custom-item-editor': { + const openIndex = parseInt(el.dataset.itemIndex, 10); + if (Number.isNaN(openIndex)) break; + if (wizardState.customItemEditorIndex != null && wizardState.customItemEditorIndex !== openIndex) { + collectCustomItemFromForm(root, wizardState, wizardState.customItemEditorIndex); + } + wizardState.customItemEditorIndex = openIndex; + refreshModal(root); + break; + } + + case 'wizard-close-custom-item-editor': { + const discard = el.dataset.discard === 'true'; + if (!discard && wizardState.customItemEditorIndex != null) { + collectCustomItemFromForm(root, wizardState, wizardState.customItemEditorIndex); + } + wizardState.customItemEditorIndex = null; + refreshModal(root); + break; + } + + case 'wizard-add-custom-item': { + collectCustomItemsFromForm(root, wizardState); + wizardState.customItems.push(createBlankCustomItem()); + wizardState.customItemEditorIndex = wizardState.customItems.length - 1; + refreshModal(root); + break; + } + + case 'wizard-remove-custom-item': { + const removeIndex = parseInt(el.dataset.itemIndex, 10); + if (wizardState.customItems.length <= 1 || Number.isNaN(removeIndex)) break; + if (wizardState.customItemEditorIndex === removeIndex) { + collectCustomItemFromForm(root, wizardState, removeIndex); + } else { + collectCustomItemsFromForm(root, wizardState); + } + const itemToRemove = wizardState.customItems[removeIndex]; + if (hasCustomItemContent(itemToRemove) && !confirm('Are you sure you want to remove this item?')) { + break; + } + wizardState.customItems.splice(removeIndex, 1); + reindexCustomItemAdditionalExpanded(wizardState, removeIndex); + if (wizardState.customItemEditorIndex === removeIndex) { + wizardState.customItemEditorIndex = null; + } else if (wizardState.customItemEditorIndex > removeIndex) { + wizardState.customItemEditorIndex -= 1; + } + refreshModal(root); + break; + } + + case 'wizard-toggle-custom-images': { + const toggleIndex = parseInt(el.dataset.itemIndex, 10); + if (Number.isNaN(toggleIndex)) break; + collectCustomItemFromForm(root, wizardState, toggleIndex); + if (!wizardState.customItemAdditionalExpanded) { + wizardState.customItemAdditionalExpanded = {}; + } + const item = wizardState.customItems[toggleIndex]; + const currentlyExpanded = isCustomItemAdditionalExpanded(wizardState, toggleIndex, item); + wizardState.customItemAdditionalExpanded[toggleIndex] = !currentlyExpanded; + refreshModal(root); + break; + } + + case 'wizard-select-parent': { + const parentId = el.dataset.parentId; + const parentName = el.dataset.parentName; + const item = { id: parentId, name: parentName }; + + if (isMultiSelectType(wizardState.parentType)) { + const idx = wizardState.parents.findIndex(p => p.id === parentId); + if (idx >= 0) { + wizardState.parents.splice(idx, 1); + } else { + wizardState.parents.push(item); + } + el.classList.toggle('hsse-active'); + break; + } + + if (wizardState.parents[0]?.id === parentId) { + advanceToStep(root, 'query'); + break; + } + wizardState.parents = [item]; + wizardState.sectionName = buildSectionName(wizardState.parentType, wizardState.parents); + advanceToStep(root, 'query'); + break; + } + + case 'wizard-retry-parent-load': + loadParentItemsForType(wizardState, root); + break; + + case 'wizard-navigate-step': + navigateToStep(root, el.dataset.step); + break; + + case 'wizard-toggle-item-type': { + const type = el.dataset.itemType; + const idx = wizardState.includeItemTypes.indexOf(type); + if (idx >= 0) { + wizardState.includeItemTypes.splice(idx, 1); + } else { + wizardState.includeItemTypes.push(type); + } + el.classList.toggle('hsse-active'); + updateIncludedItemTypesHint(root, wizardState.includeItemTypes); + break; + } + + case 'wizard-toggle-parent-filter-type': { + const type = el.dataset.itemType; + const filterTypes = ensureParentFilterItemTypes(wizardState); + const idx = filterTypes.indexOf(type); + if (idx >= 0) { + filterTypes.splice(idx, 1); + } else { + filterTypes.push(type); + } + el.classList.toggle('hsse-active'); + updateIncludedItemTypesHint(root, filterTypes); + // Invalidate cached list for this parent type so chips stay in sync with results + delete wizardState.parentItemsByType[wizardState.parentType]; + if (wizardState.parentFetchKeyByType) { + delete wizardState.parentFetchKeyByType[wizardState.parentType]; + } + if (wizardState.parentType === 'Person') { + wizardState.personSearchResults = []; + } + loadParentItemsForType(wizardState, root); + break; + } + + case 'wizard-set-render-mode': + wizardState.renderMode = el.dataset.renderMode; + if (wizardState.renderMode === 'Random') { + applyRandomAppearanceOptions(wizardState); + } + refreshModal(root); + if (wizardState.step === 'appearance') { + scheduleAppearancePreview(root); + } + break; + + case 'wizard-set-section-visibility': + syncCurrentStepFromForm(root, wizardState); + wizardState.sectionVisibility = el.dataset.visibility || 'normal'; + refreshModal(root); + break; + + case 'wizard-set-card-format': + wizardState.cardFormat = el.dataset.cardFormat; + root.querySelectorAll('[data-hsse-action="wizard-set-card-format"]').forEach(btn => { + btn.classList.toggle('hsse-active', btn === el); + }); + scheduleAppearancePreview(root); + break; + + case 'wizard-back': + syncCurrentStepFromForm(root, wizardState); + if (wizardState.step === 'type') { + stage = 'mode'; + refreshModal(root); + } else { + const steps = getWizardSteps(wizardState); + const idx = getStepIndex(wizardState.step, wizardState); + const prevStep = steps[idx - 1]; + if (wizardState.step === 'customItems') { + wizardState.customItemEditorIndex = null; + } + wizardState.step = prevStep; + refreshModal(root); + } + break; + + case 'wizard-continue': { + syncCurrentStepFromForm(root, wizardState); + if (wizardState.step === 'parent' && isMultiSelectType(wizardState.parentType)) { + if (!wizardState.parents.length) break; + wizardState.sectionName = buildSectionName(wizardState.parentType, wizardState.parents); + } + if (wizardState.step === 'customItems') { + if (!hasValidCustomItems(wizardState.customItems)) break; + if (!wizardState.sectionName?.trim()) { + wizardState.sectionName = 'Custom Section'; + } + } + if (wizardState.step === 'externalList') { + const urls = (wizardState.externalListUrls || []).map((u) => String(u || '').trim()).filter(Boolean); + if (!urls.length) break; + wizardState.externalListUrls = urls; + if (!wizardState.sectionName?.trim()) { + wizardState.sectionName = 'External List'; + } + } + if (wizardState.step === 'settings' && wizardState.sectionVisibility === 'seasonal') { + if (!hasValidSeasonalDates(wizardState)) break; + } + const steps = getWizardSteps(wizardState); + const idx = getStepIndex(wizardState.step, wizardState); + if (idx < steps.length - 1) { + advanceToStep(root, steps[idx + 1]); + } + break; + } + + case 'wizard-finish': { + syncCurrentStepFromForm(root, wizardState); + const draft = wizardStateToSection(wizardState, section); + Promise.resolve(onSave(draft)).then(() => { + if (modalInstance) { + window.ModalSystem.close(CREATE_FLOW_MODAL_ID); + } + }).catch(err => { + console.error('[KefinTweaks Section Editor] Save failed:', err); + }); + break; + } + + default: + break; + } + } + + const content = document.createElement('div'); + content.className = 'hsse-root'; + content.innerHTML = renderModeSelectHTML(); + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.5em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = getFooterHTML('mode', wizardState); + + modalInstance = window.ModalSystem.create({ + id: CREATE_FLOW_MODAL_ID, + title: getModalTitle('mode', wizardState), + content: content, + footer: footer, + closeOnBackdrop: false, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '1400px', height: '90vh' } + : undefined, + onClose: () => { + window.KefinHomeScreenAdvancedEditor?.destroyAppearancePickrs?.(modalInstance); + }, + onOpen: (instance) => { + const root = instance.dialogContent; + + attachToggleHandlers(root); + setupModalHeader(); + updateModalHeader(); + + root.addEventListener('click', (e) => { + const actionEl = e.target.closest('[data-hsse-action]'); + if (!actionEl || !root.contains(actionEl)) return; + handleAction(actionEl.dataset.hsseAction, actionEl, root); + }); + + root.addEventListener('change', (e) => { + if (e.target.id === 'hsse-wizard-sectionGroup') { + const container = root.querySelector('#hsse-wizard-sectionGroup-new-container'); + if (container) { + container.style.display = e.target.value === 'New...' ? 'block' : 'none'; + } + } + if (wizardState.step === 'appearance' && ( + e.target.id === 'hsse-wizard-spotlightLayout' || + e.target.id === 'hsse-wizard-spotlightSize' || + e.target.id === 'hsse-wizard-tileCount' || + e.target.id === 'hsse-wizard-animated' + )) { + collectAppearanceStepFromForm(root, wizardState); + scheduleAppearancePreview(root); + } + }); + + root.addEventListener('input', (e) => { + if (e.target.id === 'hsse-parent-search') { + setParentSearch(wizardState, e.target.value); + scheduleParentSearch(root); + } + if (e.target.matches('[data-hsse-custom-preview]')) { + clearTimeout(customImagePreviewTimer); + const input = e.target; + customImagePreviewTimer = setTimeout(() => { + const img = root.querySelector(`img[data-preview-for="${input.id}"]`); + updateCustomImagePreview(img, input.value); + }, 300); + } + }); + + instance.dialogFooter.addEventListener('click', (e) => { + const actionEl = e.target.closest('[data-hsse-action]'); + if (!actionEl) return; + handleAction(actionEl.dataset.hsseAction, actionEl, root); + }); + + instance.dialogHeader.addEventListener('click', (e) => { + const actionEl = e.target.closest('[data-hsse-action]'); + if (!actionEl) return; + handleAction(actionEl.dataset.hsseAction, actionEl, root); + }); + } + }); + + LOG('Create flow opened for section', section.id); + return modalInstance; + } + + window.KefinHomeScreenSectionEditor = { + openCreateSection, + openEditSection, + renderHomeScreenSectionEditor: openCreateSection, + attachGlobalSpotlightSettings, + buildGlobalSpotlightSettingsHTML, + collectSpotlightConfigFromDialog, + attachSpotlightSettingsListeners, + setupAdditionalOptionsListeners, + openSelectionPopover, + openLibraryItemPickerPopover, + fetchLibrarySearchItems, + fetchSelectionItems, + resolveSelectionItemLabels, + getSelectionTypeMeta, + ALL_ITEM_TYPES, + PARENT_SELECTION_TYPES, + FACET_SELECTION_TYPES, + buildSelect, + buildTextInput, + buildToggleSlider, + buildToggleCard, + buildCheckbox, + updateToggleSliderUI, + updateToggleSwitchUI + }; + + window.KefinHomeScreenEditorConstants = { + DEFAULT_SECTION_QUERY_LIMIT, + SORT_ORDERS, + SORT_ORDER_DIRECTIONS, + CARD_FORMATS, + RENDER_MODE_OPTIONS, + SPOTLIGHT_LAYOUT_OPTIONS, + SPOTLIGHT_SIZE_OPTIONS, + SPOTLIGHT_TILE_COUNT_OPTIONS, + SPOTLIGHT_ENTRANCE_OPTIONS, + SPOTLIGHT_SLIDE_OPTIONS, + SLIDE_STATE_OPTIONS, + SUPPORTED_QUERY_OPTIONS + }; + + LOG('Home Screen Section Editor loaded'); + +})(); diff --git a/configuration/homescreenBenchmark.js b/configuration/homescreenBenchmark.js new file mode 100644 index 0000000..c1d3bda --- /dev/null +++ b/configuration/homescreenBenchmark.js @@ -0,0 +1,981 @@ +// Home Screen Section Benchmarks — timed query overview for admin config +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinHomeScreenBenchmark]', ...args); + const STORAGE_KEY = 'kefin.homeScreen.benchmark.selectedIds'; + const DEFAULT_QUERY_LIMIT = window.KefinHomeScreenEditorConstants?.DEFAULT_SECTION_QUERY_LIMIT ?? 16; + + const CATEGORIES = [ + { id: 'default', label: 'Default', groupsKey: 'HOME_SECTION_GROUPS' }, + { id: 'custom', label: 'Custom', groupsKey: 'CUSTOM_SECTION_GROUPS' }, + { id: 'discovery', label: 'Discovery', groupsKey: 'DISCOVERY_SECTION_GROUPS' }, + { id: 'seasonal', label: 'Seasonal', groupsKey: 'SEASONAL_SECTION_GROUPS' } + ]; + + const MS_BANDS = [ + { max: 500, className: 'kefin-bench-ms-fast' }, + { max: 1000, className: 'kefin-bench-ms-mid' }, + { max: 2000, className: 'kefin-bench-ms-slow' }, + { max: Infinity, className: 'kefin-bench-ms-worst' } + ]; + + /** @type {{ aborted: boolean, controller: AbortController|null, onSkipRemaining?: Function } | null} */ + let activeRun = null; + let navCancelBound = false; + + /** + * Survives re-renders of the tab so results are not lost when the user + * switches tabs or reopens the config modal. + * @type {{ entries: Array, results: Map } | null} + */ + let lastRun = null; + let lastViewMode = 'picker'; + + function hasStoredResults() { + return !!lastRun?.entries?.length; + } + + function flattenGroups(groups) { + if (!Array.isArray(groups)) return []; + const out = []; + groups.forEach((group) => { + if (group && Array.isArray(group.sections)) { + out.push(...group.sections); + } + }); + return out; + } + + function getSectionsForCategories(config, enabledCategoryIds) { + const enabled = new Set(enabledCategoryIds); + const sections = []; + CATEGORIES.forEach((cat) => { + if (!enabled.has(cat.id)) return; + const groups = config?.[cat.groupsKey] || []; + flattenGroups(groups).forEach((section) => { + if (!section?.id) return; + sections.push({ + section, + category: cat.label, + categoryId: cat.id + }); + }); + }); + return sections; + } + + function loadStoredSelectedIds() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : null; + } catch (e) { + return null; + } + } + + function saveStoredSelectedIds(ids) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(ids)); + } catch (e) { + LOG('Could not persist selection', e); + } + } + + function msBandClass(ms) { + if (ms == null || Number.isNaN(ms)) return ''; + for (const band of MS_BANDS) { + if (ms < band.max) return band.className; + } + return MS_BANDS[MS_BANDS.length - 1].className; + } + + function withBenchmarkQueryOptions(query) { + const queryOptions = { ...(query?.queryOptions || {}) }; + if (queryOptions.Limit == null || queryOptions.Limit === '') { + queryOptions.Limit = DEFAULT_QUERY_LIMIT; + } + return { ...query, queryOptions }; + } + + function uniqueItemsById(items) { + const seen = new Set(); + const out = []; + (items || []).forEach((item) => { + const id = item?.Id ?? item?.id; + if (id != null) { + if (seen.has(id)) return; + seen.add(id); + } + out.push(item); + }); + return out; + } + + function analyzeItemFields(items) { + let hasMediaStreams = false; + let hasPeople = false; + let hasStudios = false; + (items || []).forEach((item) => { + if (item?.MediaStreams?.length) hasMediaStreams = true; + if (item?.People?.length) hasPeople = true; + if (item?.Studios?.length) hasStudios = true; + }); + return { hasMediaStreams, hasPeople, hasStudios }; + } + + function jsonByteSize(value) { + try { + const json = typeof value === 'string' ? value : JSON.stringify(value); + return new TextEncoder().encode(json || '').byteLength; + } catch (e) { + return 0; + } + } + + function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return '—'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + } + + function getAuthHeaders() { + if (window.apiHelper?.getAuthHeader) { + return { + Authorization: window.apiHelper.getAuthHeader(), + Accept: 'application/json' + }; + } + return { Accept: 'application/json' }; + } + + async function timedFetchJson(url, signal) { + const start = performance.now(); + const response = await fetch(url, { headers: getAuthHeaders(), signal }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const text = await response.text(); + const data = JSON.parse(text); + return { data, ms: performance.now() - start, responseBytes: jsonByteSize(text) }; + } + + async function timedDataSource(dataSource, options) { + const start = performance.now(); + const result = await window.apiHelper.fetchFromDataSource(dataSource, options || {}, false); + const ms = performance.now() - start; + const items = result?.Items || result || []; + return { + items: Array.isArray(items) ? items : [], + ms, + responseBytes: jsonByteSize(result) + }; + } + + async function fetchQueryItems(query, section, signal) { + const userId = window.ApiClient?.getCurrentUserId?.(); + const serverUrl = window.ApiClient?.serverAddress?.(); + if (!userId || !serverUrl) { + throw new Error('ApiClient not ready'); + } + + const previewQuery = withBenchmarkQueryOptions(query); + const useSpotlight = + section.renderMode === 'Spotlight' || section.spotlight === true; + + if (previewQuery.dataSource) { + return await timedDataSource(previewQuery.dataSource, previewQuery.queryOptions); + } + + if (!window.apiHelper?.buildQueryFromSection) { + throw new Error('apiHelper.buildQueryFromSection not available'); + } + + const built = window.apiHelper.buildQueryFromSection( + previewQuery, + userId, + serverUrl, + useSpotlight + ); + + if (built && typeof built === 'object' && built.dataSource) { + return await timedDataSource(built.dataSource, built.options); + } + + if (!built || typeof built !== 'string') { + throw new Error('Could not build query URL'); + } + + const { data, ms, responseBytes } = await timedFetchJson(built, signal); + const items = data?.Items || (Array.isArray(data) ? data : []); + return { items, ms, responseBytes }; + } + + /** + * Discovery sections ship as templates without queries: their source (genre, person, + * studio, collection, ...) is picked at render time. Reuse the home screen resolver so + * the benchmark measures the same queries the home screen would actually issue. + */ + async function resolveSectionQueries(entry) { + const section = entry.section; + const isTemplate = !section.queries?.length && !section.items?.length; + if (!isTemplate || !section.type) return { section, error: null }; + + const resolve = window.homeScreen3?.resolveDiscoverySection; + if (!resolve) { + return { section, error: 'Discovery resolver unavailable' }; + } + + try { + const resolved = await resolve(section, { + pairSpotlight: false, + resolveViewMore: false + }); + if (!resolved?.queries?.length) { + return { section, error: `Could not resolve ${section.type} source` }; + } + return { section: resolved, error: null }; + } catch (e) { + return { section, error: `Source resolve failed: ${e?.message || e}` }; + } + } + + async function benchmarkSection(section, signal) { + if (section.items?.length) { + return { + totalMs: 0, + items: section.items, + responseBytes: 0, + queryMetrics: [], + error: null, + skippedQueries: true + }; + } + + const queries = section.queries || []; + if (!queries.length) { + return { + totalMs: 0, + items: [], + responseBytes: 0, + queryMetrics: [], + error: 'No queries', + skippedQueries: true + }; + } + + let totalMs = 0; + let responseBytes = 0; + const allItems = []; + const queryMetrics = []; + + for (const query of queries) { + if (signal?.aborted) { + return { + totalMs, + items: allItems, + responseBytes, + queryMetrics, + error: 'Cancelled', + cancelled: true + }; + } + const result = await fetchQueryItems(query, section, signal); + const { items, ms } = result; + const queryBytes = result.responseBytes || 0; + totalMs += ms; + responseBytes += queryBytes; + allItems.push(...items); + queryMetrics.push({ ms, itemCount: items.length, responseBytes: queryBytes }); + } + + return { + totalMs, + items: uniqueItemsById(allItems), + responseBytes, + queryMetrics, + error: null + }; + } + + function chipHtml(on, label) { + const cls = on ? 'kefin-bench-chip-on' : 'kefin-bench-chip-off'; + const text = on ? 'On' : 'Off'; + return `${label}: ${text}`; + } + + function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function ensureBenchmarkStyles() { + if (document.getElementById('kefin-bench-styles')) return; + const style = document.createElement('style'); + style.id = 'kefin-bench-styles'; + style.textContent = ` + .kefin-bench-wrap { font-size: 0.95em; } + .kefin-bench-cats { + display: flex; + flex-wrap: wrap; + gap: 1em; + margin: 1.25em 0; + } + .kefin-bench-cat-btn { + padding: 0.75em 1.75em; + font-size: 1em; + line-height: 1.2; + border-radius: 999px; + border: 2px solid rgba(255,255,255,0.18); + background: rgba(255,255,255,0.04); + color: rgba(255,255,255,0.5); + cursor: pointer; + font-weight: 600; + transition: background 0.15s, border-color 0.15s, color 0.15s; + } + .kefin-bench-cat-btn:hover:not(:disabled) { + color: rgba(255,255,255,0.85); + border-color: rgba(255,255,255,0.4); + } + .kefin-bench-cat-btn.is-active { + border-color: var(--theme-primary-color, #00a4dc); + color: #fff; + background: var(--theme-primary-color, #00a4dc); + } + .kefin-bench-cat-btn.is-active:hover:not(:disabled) { + border-color: var(--theme-primary-color, #00a4dc); + color: #fff; + filter: brightness(1.1); + } + .kefin-bench-cat-btn:disabled { opacity: 0.5; cursor: default; } + .kefin-bench-actions { + display: flex; gap: 0.75em; align-items: center; flex-wrap: wrap; margin-top: 1em; + } + .kefin-bench-wrap [hidden] { display: none !important; } + .kefin-bench-panel { min-height: 120px; } + .kefin-bench-picker-toolbar { + display: flex; align-items: center; gap: 0.5em; margin-bottom: 0.65em; flex-wrap: wrap; + } + .kefin-bench-picker-count { + font-size: 0.85em; color: rgba(255,255,255,0.55); margin-left: 0.25em; + } + #kefin-bench-picker-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + gap: 0.4em; + overflow-y: auto; + padding: 0.5em; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 6px; + background: rgba(0,0,0,0.15); + } + @media (max-width: 1100px) { + #kefin-bench-picker-grid { grid-template-columns: 1fr 1fr 1fr; } + } + @media (max-width: 800px) { + #kefin-bench-picker-grid { grid-template-columns: 1fr 1fr; } + } + @media (max-width: 520px) { + #kefin-bench-picker-grid { grid-template-columns: 1fr; } + } + .kefin-bench-picker-cell { + height: 24px; + overflow: hidden; + display: flex; + align-items: center; + gap: 0.5em; + padding: 0 0.4em; + border-radius: 4px; + min-width: 0; + cursor: pointer; + } + .kefin-bench-picker-cell:hover { background: rgba(255,255,255,0.07); } + .kefin-bench-picker-cell input { flex-shrink: 0; margin: 0; } + .kefin-bench-picker-label { + flex: 1; min-width: 0; + display: flex; align-items: center; gap: 0.4em; + overflow: hidden; + } + .kefin-bench-picker-name { + font-size: 1em; + color: rgba(255,255,255,0.92); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .kefin-bench-picker-badge { + flex-shrink: 0; + font-size: 0.65em; + padding: 0.1em 0.4em; + border-radius: 3px; + background: rgba(0,164,220,0.2); + color: rgba(255,255,255,0.7); + } + .kefin-bench-table-wrap { overflow-x: auto; border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; } + .kefin-bench-summary-wrap { + overflow-x: auto; + margin-bottom: 1em; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + } + .kefin-bench-summary-table td { + font-size: 1.1em; + font-weight: 600; + text-align: center; + } + .kefin-bench-summary-table th { text-align: center; white-space: nowrap; } + .kefin-bench-summary-table td[title] { cursor: help; } + .kefin-bench-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9em; + /* Fixed layout keeps column widths stable as rows fill in */ + table-layout: fixed; + } + .kefin-bench-table:not(.kefin-bench-summary-table) { min-width: 780px; } + .kefin-bench-table th, .kefin-bench-table td { + padding: 0.55em 0.65em; text-align: left; border-bottom: 1px solid rgba(255,255,255,0.08); + overflow: hidden; text-overflow: ellipsis; + } + .kefin-bench-table tbody tr { height: 2.9em; } + .kefin-bench-col-name, .kefin-bench-col-plain { white-space: nowrap; } + .kefin-bench-table th { font-weight: 600; color: rgba(255,255,255,0.85); background: rgba(0,0,0,0.2); } + .kefin-bench-table tr.kefin-bench-row-running td { color: rgba(255,255,255,0.6); } + .kefin-bench-ms-fast { background: rgba(47,158,68,0.35); } + .kefin-bench-ms-mid { background: rgba(255,212,59,0.25); } + .kefin-bench-ms-slow { background: rgba(253,126,20,0.3); } + .kefin-bench-ms-worst { background: rgba(224,49,49,0.35); } + .kefin-bench-chips { display: flex; flex-wrap: nowrap; gap: 0.35em; } + .kefin-bench-chip { + font-size: 0.75em; padding: 0.15em 0.45em; border-radius: 4px; + border: 1px solid rgba(255,255,255,0.2); + white-space: nowrap; + } + .kefin-bench-chip-on { background: rgba(47,158,68,0.25); } + .kefin-bench-chip-off { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5); } + `; + document.head.appendChild(style); + } + + function buildPickerGridHtml(sections, checkedSet) { + if (!sections.length) { + return '

No sections in the selected categories.

'; + } + return sections + .map((entry) => { + const id = entry.section.id; + const checked = checkedSet ? checkedSet.has(id) : true; + const name = escapeHtml(entry.section.name || id); + const cat = escapeHtml(entry.category); + return ` + + `; + }) + .join(''); + } + + function buildTableShell() { + return ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
<500ms500–1000ms1000–2000ms2000ms+>100 items>5 MB
000000
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
SectionCategoryTime (ms)Response sizeItemsFieldsStatus
+
+ `; + } + + function rowHtml(entry, result) { + const name = result?.resolvedName || entry.section.name || entry.section.id; + const status = result?.status || 'pending'; + const ms = result?.totalMs; + const msClass = status === 'done' ? msBandClass(ms) : ''; + const msText = + status === 'running' ? '…' : + status === 'skipped' ? '—' : + status === 'error' ? '—' : + status === 'done' ? Math.round(ms) : '—'; + + const items = result?.itemCount ?? '—'; + const responseSize = status === 'done' ? formatBytes(result.responseBytes) : '—'; + const fields = + status === 'done' + ? `
+ ${chipHtml(result.hasMediaStreams, 'MediaStreams')} + ${chipHtml(result.hasPeople, 'People')} + ${chipHtml(result.hasStudios, 'Studios')} +
` + : '—'; + + const statusText = + status === 'running' ? 'Running…' : + status === 'skipped' ? 'Skipped' : + status === 'error' ? (result.error || 'Error') : + status === 'done' ? (result.skippedQueries ? 'Static / no query' : 'OK') : 'Pending'; + + return ` + + ${escapeHtml(name)} + ${escapeHtml(entry.category)} + ${msText} + ${responseSize} + ${items} + ${fields} + ${escapeHtml(statusText)} + + `; + } + + function cancelActiveRun(markSkipped) { + if (!activeRun) return; + activeRun.aborted = true; + if (activeRun.controller) { + try { + activeRun.controller.abort(); + } catch (e) { /* ignore */ } + } + if (markSkipped && activeRun.onSkipRemaining) { + activeRun.onSkipRemaining(); + } + activeRun = null; + } + + function bindNavCancel() { + if (navCancelBound) return; + navCancelBound = true; + + const onNavigate = () => { + if (!activeRun) return; + if (window.location.hash === activeRun.startHash) return; + cancelActiveRun(true); + }; + + window.addEventListener('hashchange', onNavigate); + if (window.KefinTweaksUtils?.onViewPage) { + window.KefinTweaksUtils.onViewPage(() => onNavigate()); + } + } + + function updateSummaryCells(scopeEl, entries, results) { + const buckets = { + fast: new Map(), + mid: new Map(), + slow: new Map(), + worst: new Map(), + 'large-items': new Map(), + 'large-response': new Map() + }; + + entries.forEach((entry) => { + const result = results.get(entry.section.id); + if (!result?.queryMetrics?.length) return; + const name = result.resolvedName || entry.section.name || entry.section.id; + + const tally = (key) => { + buckets[key].set(name, (buckets[key].get(name) || 0) + 1); + }; + + result.queryMetrics.forEach((query) => { + if (query.ms < 500) tally('fast'); + else if (query.ms < 1000) tally('mid'); + else if (query.ms < 2000) tally('slow'); + else tally('worst'); + if (query.itemCount > 100) tally('large-items'); + if (query.responseBytes > 5 * 1024 * 1024) tally('large-response'); + }); + }); + + const summary = scopeEl?.querySelector('.kefin-bench-summary-row'); + if (!summary) return; + + Object.keys(buckets).forEach((key) => { + const cell = summary.querySelector(`[data-summary="${key}"]`); + if (!cell) return; + const tallied = [...buckets[key].entries()]; + const total = tallied.reduce((sum, [, count]) => sum + count, 0); + cell.textContent = total; + if (total) { + // One line per section, with a query count when a section contributes more than once + cell.title = tallied + .map(([name, count]) => (count > 1 ? `${name} (${count} queries)` : name)) + .join('\n'); + } else { + cell.removeAttribute('title'); + } + }); + } + + function renderResultsTable(resultsEl, entries, results) { + resultsEl.innerHTML = buildTableShell(); + const tbody = resultsEl.querySelector('.kefin-bench-tbody'); + tbody.innerHTML = entries + .map((entry) => rowHtml(entry, results.get(entry.section.id) || { status: 'pending' })) + .join(''); + updateSummaryCells(resultsEl, entries, results); + return tbody; + } + + async function runBenchmarks(selectedEntries, tbody, ui, results) { + const controller = new AbortController(); + const signal = controller.signal; + + const updateSummary = () => { + updateSummaryCells(tbody.closest('#kefin-bench-results'), selectedEntries, results); + }; + + const updateRow = (entry, result) => { + results.set(entry.section.id, result); + const tr = tbody.querySelector(`tr[data-section-id="${entry.section.id}"]`); + if (tr) { + tr.outerHTML = rowHtml(entry, result); + } + updateSummary(); + }; + + const skipRemaining = () => { + selectedEntries.forEach((entry) => { + if (results.get(entry.section.id)?.status === 'running' || + !results.has(entry.section.id)) { + updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' }); + } + }); + ui.setRunning(false); + }; + + activeRun = { + aborted: false, + controller, + startHash: window.location.hash, + onSkipRemaining: skipRemaining + }; + + ui.setRunning(true); + + for (const entry of selectedEntries) { + if (activeRun?.aborted || signal.aborted) { + updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' }); + continue; + } + + updateRow(entry, { status: 'running' }); + + try { + const resolved = await resolveSectionQueries(entry); + if (resolved.error) { + updateRow(entry, { + status: 'error', + error: resolved.error, + totalMs: null, + itemCount: '—' + }); + continue; + } + + const bench = await benchmarkSection(resolved.section, signal); + if (activeRun?.aborted || signal.aborted || bench.cancelled) { + updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' }); + continue; + } + const fields = analyzeItemFields(bench.items); + updateRow(entry, { + status: bench.error && !bench.skippedQueries ? 'error' : 'done', + resolvedName: resolved.section !== entry.section ? resolved.section.name : null, + totalMs: bench.totalMs, + itemCount: bench.items.length, + hasMediaStreams: fields.hasMediaStreams, + hasPeople: fields.hasPeople, + hasStudios: fields.hasStudios, + responseBytes: bench.responseBytes, + queryMetrics: bench.queryMetrics, + skippedQueries: bench.skippedQueries, + error: bench.error + }); + } catch (e) { + if (signal.aborted || activeRun?.aborted) { + updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' }); + } else { + updateRow(entry, { + status: 'error', + error: e?.message || String(e), + totalMs: null, + itemCount: '—' + }); + } + } + + await new Promise((r) => setTimeout(r, 0)); + } + + activeRun = null; + ui.setRunning(false); + } + + /** + * Render benchmark UI into host element. + * @param {HTMLElement} hostEl + * @param {{ getConfig?: () => Object }} options + */ + function renderHomeScreenBenchmarkUI(hostEl, options = {}) { + if (!hostEl) return; + + cancelActiveRun(true); + + const getConfig = options.getConfig || (() => window.KefinHomeScreen?.getConfig?.() || {}); + + ensureBenchmarkStyles(); + + hostEl.innerHTML = ` +
+
+ Home Screen Section Benchmarks +
+
+ Test your home sections for slow queries or otherwise unintended results. + Timings reflect server round-trip per query (not full card render). +
+
+
+
+ + + + +
+
+
+ `; + + const catsEl = hostEl.querySelector('#kefin-bench-cats'); + const runBtn = hostEl.querySelector('#kefin-bench-run'); + const stopBtn = hostEl.querySelector('#kefin-bench-stop'); + const viewResultsBtn = hostEl.querySelector('#kefin-bench-view-results'); + const editBtn = hostEl.querySelector('#kefin-bench-edit'); + const panelEl = hostEl.querySelector('#kefin-bench-panel'); + const resultsEl = hostEl.querySelector('#kefin-bench-results'); + + const enabledCats = new Set(CATEGORIES.map((c) => c.id)); + + function getCurrentSections() { + const config = typeof getConfig === 'function' ? getConfig() : {}; + return getSectionsForCategories(config, [...enabledCats]); + } + + function getCheckedIdsFromPanel() { + return Array.from(panelEl.querySelectorAll('.kefin-bench-pick:checked')) + .map((cb) => cb.value); + } + + function updatePickerCount() { + const countEl = panelEl.querySelector('.kefin-bench-picker-count'); + if (!countEl) return; + const total = panelEl.querySelectorAll('.kefin-bench-pick').length; + const checked = panelEl.querySelectorAll('.kefin-bench-pick:checked').length; + countEl.textContent = `${checked} of ${total} selected`; + } + + function bindPickerToolbar() { + panelEl.querySelector('#kefin-bench-run-top') + ?.addEventListener('click', () => startRun()); + const selectAllBtn = panelEl.querySelector('#kefin-bench-select-all'); + const deselectAllBtn = panelEl.querySelector('#kefin-bench-deselect-all'); + selectAllBtn?.addEventListener('click', () => { + panelEl.querySelectorAll('.kefin-bench-pick').forEach((cb) => { cb.checked = true; }); + updatePickerCount(); + }); + deselectAllBtn?.addEventListener('click', () => { + panelEl.querySelectorAll('.kefin-bench-pick').forEach((cb) => { cb.checked = false; }); + updatePickerCount(); + }); + panelEl.querySelectorAll('.kefin-bench-pick').forEach((cb) => { + cb.addEventListener('change', () => updatePickerCount()); + }); + } + + function showPickerPanel(preserveCheckedIds) { + const sections = getCurrentSections(); + const stored = loadStoredSelectedIds(); + const storedSet = stored ? new Set(stored) : null; + const checkedSet = preserveCheckedIds instanceof Set ? preserveCheckedIds : storedSet; + + panelEl.innerHTML = ` +
+ + + + +
+
+ ${buildPickerGridHtml(sections, checkedSet)} +
+ `; + bindPickerToolbar(); + updatePickerCount(); + } + + function showPicker() { + cancelActiveRun(true); + lastViewMode = 'picker'; + resultsEl.innerHTML = ''; + catsEl.hidden = false; + panelEl.hidden = false; + editBtn.hidden = true; + viewResultsBtn.hidden = !hasStoredResults(); + ui.setRunning(false); + } + + function showResults(entries, results) { + lastViewMode = 'results'; + catsEl.hidden = true; + panelEl.hidden = true; + editBtn.hidden = false; + viewResultsBtn.hidden = true; + return renderResultsTable(resultsEl, entries, results); + } + + const ui = { + setRunning(running) { + stopBtn.hidden = !running; + hostEl.querySelectorAll('.kefin-bench-run-btn').forEach((btn) => { + btn.disabled = running; + }); + } + }; + + function startRun() { + if (activeRun) return; + + const sections = getCurrentSections(); + if (!sections.length) { + window.KefinTweaksToaster?.toast?.('No sections in the selected categories.'); + return; + } + + const selectedIds = getCheckedIdsFromPanel(); + if (!selectedIds.length) { + window.KefinTweaksToaster?.toast?.('Select at least one section to benchmark.'); + return; + } + + saveStoredSelectedIds(selectedIds); + const idSet = new Set(selectedIds); + const selected = sections.filter((e) => idSet.has(e.section.id)); + if (!selected.length) return; + + lastRun = { entries: selected, results: new Map() }; + const tbody = showResults(lastRun.entries, lastRun.results); + runBenchmarks(lastRun.entries, tbody, ui, lastRun.results); + } + + CATEGORIES.forEach((cat) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'kefin-bench-cat-btn is-active'; + btn.dataset.cat = cat.id; + btn.setAttribute('aria-pressed', 'true'); + btn.textContent = cat.label; + btn.addEventListener('click', () => { + if (activeRun) return; + const prevChecked = new Set(getCheckedIdsFromPanel()); + if (enabledCats.has(cat.id)) { + enabledCats.delete(cat.id); + btn.classList.remove('is-active'); + btn.setAttribute('aria-pressed', 'false'); + } else { + enabledCats.add(cat.id); + btn.classList.add('is-active'); + btn.setAttribute('aria-pressed', 'true'); + } + showPickerPanel(prevChecked); + }); + catsEl.appendChild(btn); + }); + + stopBtn.addEventListener('click', () => cancelActiveRun(true)); + editBtn.addEventListener('click', () => showPicker()); + runBtn.addEventListener('click', () => startRun()); + viewResultsBtn.addEventListener('click', () => { + if (!hasStoredResults()) return; + showResults(lastRun.entries, lastRun.results); + }); + + showPickerPanel(); + + if (lastViewMode === 'results' && hasStoredResults()) { + showResults(lastRun.entries, lastRun.results); + } else { + showPicker(); + } + } + + window.KefinHomeScreenBenchmark = { + renderHomeScreenBenchmarkUI, + cancelActiveRun + }; + + bindNavCancel(); + + LOG('loaded'); +})(); diff --git a/configuration/search-configuration.js b/configuration/search-configuration.js new file mode 100644 index 0000000..85d23fe --- /dev/null +++ b/configuration/search-configuration.js @@ -0,0 +1,155 @@ +// KefinTweaks Search Configuration UI +// Modular configuration modal for managing search settings + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks Search Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks Search Config]', ...args); + + const MODAL_ID = 'kefin-search-config'; + + /** + * Load configuration from window.KefinTweaksConfig + */ + async function loadConfig() { + const config = window.KefinTweaksConfig || {}; + return config.search || {}; + } + + /** + * Save configuration to JS Injector + */ + async function saveConfig(searchConfig) { + try { + // Ensure window.KefinTweaksConfig exists + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + + // Update search config + window.KefinTweaksConfig.search = searchConfig; + + // Save to JS Injector using utils + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } else { + // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js + if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + /** + * Build configuration HTML + */ + function buildConfigHTML(config) { + return ` +
+
+
+
Enable Seerr results from JellyfinEnhanced
+
If you are using Seerr from Jellyfin Enhanced, you can enable this to add an additional search toggle for Requests
+ +
+
+
+ `; + } + + /** + * Collect configuration from form + */ + function collectConfig() { + const enableJellyseerr = document.getElementById('search_enableJellyseerr')?.checked === true; + return { + enableJellyseerr: enableJellyseerr + }; + } + + /** + * Open configuration modal + */ + async function openConfigModal() { + try { + // Load config + const config = await loadConfig(); + + // Build content + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + // Build footer + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + // Create modal + window.ModalSystem.create({ + id: MODAL_ID, + title: 'Search Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '1400px', height: '90vh' } + : undefined, + onOpen: (modal) => { + // Save button + const saveBtn = modal.dialogFooter.querySelector('#save-search-config-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const searchConfig = collectConfig(); + const success = await saveConfig(searchConfig); + if (success) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Search configuration saved!'); + } + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + } + }); + + LOG('Search configuration modal opened'); + } catch (error) { + ERR('Error opening search config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + // Export + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.search = { openConfigModal }; + + console.log('[KefinTweaks Search Config] Script loaded'); + +})(); + diff --git a/configuration/seriesEpisodes-configuration.js b/configuration/seriesEpisodes-configuration.js new file mode 100644 index 0000000..824fb1e --- /dev/null +++ b/configuration/seriesEpisodes-configuration.js @@ -0,0 +1,156 @@ +// KefinTweaks Series Episodes Configuration UI +// Modular configuration modal for managing series episodes settings + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks SeriesEpisodes Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks SeriesEpisodes Config]', ...args); + + const MODAL_ID = 'kefin-seriesepisodes-config'; + + /** + * Load configuration from window.KefinTweaksConfig + */ + async function loadConfig() { + const config = window.KefinTweaksConfig || {}; + return config.flattenSingleSeasonShows || {}; + } + + /** + * Save configuration to JS Injector + */ + async function saveConfig(flattenShowsConfig) { + try { + // Ensure window.KefinTweaksConfig exists + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + + // Update flattenSingleSeasonShows config + window.KefinTweaksConfig.flattenSingleSeasonShows = flattenShowsConfig; + + // Save to JS Injector using utils + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } else { + // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js + if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + /** + * Build configuration HTML + */ + function buildConfigHTML(config) { + const hideSingleSeasonContainer = config.hideSingleSeasonContainer === true; + return ` +
+
+
+
Flatten Single Season Shows
+
When enabled, hides the season container section when a series has only one season, since episodes are already displayed directly.
+ +
+
+
+ `; + } + + /** + * Collect configuration from form + */ + function collectConfig() { + const hideSingleSeasonContainer = document.getElementById('flattenSingleSeasonShows_hideSingleSeasonContainer')?.checked === true; + return { + hideSingleSeasonContainer: hideSingleSeasonContainer + }; + } + + /** + * Open configuration modal + */ + async function openConfigModal() { + try { + // Load config + const config = await loadConfig(); + + // Build content + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + // Build footer + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + // Create modal + window.ModalSystem.create({ + id: MODAL_ID, + title: 'Episodes On Series Page Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '1400px', height: '90vh' } + : undefined, + onOpen: (modal) => { + // Save button + const saveBtn = modal.dialogFooter.querySelector('#save-seriesepisodes-config-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const flattenShowsConfig = collectConfig(); + const success = await saveConfig(flattenShowsConfig); + if (success) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Configuration saved!'); + } + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + } + }); + + LOG('Series Episodes configuration modal opened'); + } catch (error) { + ERR('Error opening series episodes config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + // Export + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.seriesEpisodes = { openConfigModal }; + + console.log('[KefinTweaks SeriesEpisodes Config] Script loaded'); + +})(); + diff --git a/configuration/seriesInfo-configuration.js b/configuration/seriesInfo-configuration.js new file mode 100644 index 0000000..eaca85a --- /dev/null +++ b/configuration/seriesInfo-configuration.js @@ -0,0 +1,156 @@ +// KefinTweaks Series Episodes Configuration UI +// Modular configuration modal for managing series episodes settings + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks SeriesInfo Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks SeriesInfo Config]', ...args); + + const MODAL_ID = 'kefin-seriesinfo-config'; + + /** + * Load configuration from window.KefinTweaksConfig + */ + async function loadConfig() { + const config = window.KefinTweaksConfig || {}; + return config.seriesInfo || {}; + } + + /** + * Save configuration to JS Injector + */ + async function saveConfig(seriesInfoConfig) { + try { + // Ensure window.KefinTweaksConfig exists + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + + // Update flattenSingleSeasonShows config + window.KefinTweaksConfig.seriesInfo = seriesInfoConfig; + + // Save to JS Injector using utils + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } else { + // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js + if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + /** + * Build configuration HTML + */ + function buildConfigHTML(config) { + const use24HourTimeFormat = config.use24HourTimeFormat === true; + return ` +
+
+
+
Use 24 Hour Time Format
+
When enabled, uses a 24 hour time format for the series info.
+ +
+
+
+ `; + } + + /** + * Collect configuration from form + */ + function collectConfig() { + const use24HourTimeFormat = document.getElementById('seriesInfo_use24HourTimeFormat')?.checked === true; + return { + use24HourTimeFormat: use24HourTimeFormat + }; + } + + /** + * Open configuration modal + */ + async function openConfigModal() { + try { + // Load config + const config = await loadConfig(); + + // Build content + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + // Build footer + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + // Create modal + window.ModalSystem.create({ + id: MODAL_ID, + title: 'Series Info Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '1400px', height: '90vh' } + : undefined, + onOpen: (modal) => { + // Save button + const saveBtn = modal.dialogFooter.querySelector('#save-seriesinfo-config-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const seriesInfoConfig = collectConfig(); + const success = await saveConfig(seriesInfoConfig); + if (success) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Configuration saved!'); + } + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + } + }); + + LOG('Series Info configuration modal opened'); + } catch (error) { + ERR('Error opening series info config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + // Export + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.seriesInfo = { openConfigModal }; + + console.log('[KefinTweaks SeriesInfo Config] Script loaded'); + +})(); + diff --git a/configuration/skinManager-configuration.js b/configuration/skinManager-configuration.js new file mode 100644 index 0000000..2e4d7e5 --- /dev/null +++ b/configuration/skinManager-configuration.js @@ -0,0 +1,699 @@ +// KefinTweaks Skin Manager Configuration UI +// Modular configuration modal for managing skins, themes, and optional includes + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks SkinManager Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks SkinManager Config]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks SkinManager Config]', ...args); + + const MODAL_ID = 'kefin-skinmanager-config'; + + // Store loaded config for use in listeners + let currentLoadedConfig = null; + + function hasDefaultSkinsCatalog() { + const skins = window.KefinTweaksDefaultSkinsConfig?.skins; + return Array.isArray(skins) && skins.length > 0; + } + + function notifySkinManagerConfig(message) { + if (window.KefinTweaksToaster?.toast) { + window.KefinTweaksToaster.toast(message); + } else { + alert(message); + } + } + + // Helper function to build Jellyfin checkbox + function buildJellyfinCheckbox(id, checked, label, dataAttributes = {}) { + let className = 'emby-checkbox'; + const otherAttrs = {}; + + for (const [key, value] of Object.entries(dataAttributes)) { + if (key === 'class') { + className += ' ' + value; + } else { + otherAttrs[key] = value; + } + } + + const dataAttrs = Object.entries(otherAttrs).map(([key, value]) => `${key}="${value}"`).join(' '); + const checkedAttr = checked ? 'checked' : ''; + return ` +
+ +
+ `; + } + + // Extract GitHub repository URL from skin configuration + function getSkinGitHubUrl(skin) { + if (skin.github) { + return skin.github; + } + + if (skin.url && Array.isArray(skin.url)) { + for (const urlObj of skin.url) { + if (urlObj.urls && Array.isArray(urlObj.urls)) { + for (const url of urlObj.urls) { + const match = url.match(/https:\/\/cdn\.jsdelivr\.net\/gh\/([^\/]+)\/([^\/@]+)/); + if (match) { + const owner = match[1]; + const repo = match[2]; + return `https://github.com/${owner}/${repo}`; + } + } + } + } + } + + return null; + } + + // Build skin toggle switches + function buildSkinToggles(skins, skinSources = {}) { + return skins.map(skin => { + const isEnabled = skin.enabled !== false; + const githubUrl = getSkinGitHubUrl(skin); + const source = skinSources[skin.name] || 'default'; + + let borderStyle = '1px solid rgba(255,255,255,0.1)'; + let backgroundStyle = 'rgba(255,255,255,0.02)'; + let badgeText = ''; + let badgeStyle = ''; + let badgeIcon = ''; + + if (source === 'default') { + backgroundStyle = 'rgba(255,255,255,0.01)'; + badgeText = 'D'; + badgeStyle = 'background: rgba(0,122,255,0.2); color: rgba(0,122,255,0.9);'; + } else if (source === 'overridden') { + borderStyle = '2px solid rgba(0, 122, 255, 0.5)'; + backgroundStyle = 'rgba(0,122,255,0.05)'; + badgeIcon = 'warning'; + badgeStyle = 'color: rgba(255,165,0,0.9);'; + } + + let tooltipText = ''; + if (source === 'default') { + tooltipText = 'This skin is included automatically by KefinTweaks'; + } else if (source === 'overridden') { + tooltipText = 'This default skin has been overridden by a custom configuration'; + } else if (githubUrl) { + tooltipText = 'View on GitHub'; + } + + return ` +
+
+
+ ${githubUrl ? ` + + ${skin.name} + info + + ` : ` +
${skin.name}
+ `} + ${badgeIcon ? `${badgeIcon}` : ''} + ${badgeText ? `${badgeText}` : ''} +
+ ${buildJellyfinCheckbox(`skin_${skin.name.replace(/[^a-zA-Z0-9]/g, '_')}`, isEnabled, '', { 'data-skin-name': skin.name })} +
+
+ `; + }).join(''); + } + + // Build optional includes skin options + function buildOptionalIncludesSkinOptions(skins) { + const skinsWithOptionalIncludes = skins.filter(skin => + skin.optionalIncludes && skin.optionalIncludes.length > 0 + ); + + return skinsWithOptionalIncludes.map(skin => + `` + ).join(''); + } + + // Extract author from URL + function extractAuthorFromUrl(url) { + if (!url) return ''; + try { + const jsdelivrMatch = url.match(/cdn\.jsdelivr\.net\/gh\/([^\/]+)\//); + if (jsdelivrMatch) { + return jsdelivrMatch[1]; + } + } catch (e) { + // Ignore errors + } + return ''; + } + + // Generate optional include key + function generateOptionalIncludeKey(skinName, author, url) { + const normalizedSkinName = skinName === 'global' ? 'global' : skinName.replace(/\s+/g, '_'); + const filename = url ? url.split('?')[0].split('#')[0].split('/').pop() : ''; + const normalizedAuthor = (author || '').replace(/\s+/g, '_'); + return `${normalizedSkinName}-${normalizedAuthor}-${filename}`; + } + + // Extract filename from URL + function extractFilenameFromUrl(url) { + if (!url) return ''; + try { + const urlWithoutParams = url.split('?')[0].split('#')[0]; + const pathParts = urlWithoutParams.split('/'); + return pathParts[pathParts.length - 1] || ''; + } catch (e) { + return ''; + } + } + + // Build optional includes editor (simplified version) + function buildOptionalIncludesEditor(category, config, skins) { + let optionalIncludes = []; + + if (category === 'global') { + const defaultGlobalIncludes = window.KefinTweaksDefaultSkinsConfig?.globalOptionalIncludes || []; + optionalIncludes = defaultGlobalIncludes.map(include => ({ ...include })); + + const adminOptionalIncludes = config.optionalIncludes || []; + adminOptionalIncludes.forEach(adminEntry => { + if (typeof adminEntry === 'object' && adminEntry.key) { + const keyParts = adminEntry.key.split('-'); + if (keyParts[0] === 'global' && keyParts.length >= 3) { + const author = keyParts[1]; + const filename = keyParts.slice(2).join('-'); + + const matchingInclude = optionalIncludes.find(inc => { + const incAuthor = extractAuthorFromUrl(inc.url); + const incFilename = extractFilenameFromUrl(inc.url); + return incAuthor === author && incFilename === filename; + }); + + if (matchingInclude) { + matchingInclude.enabled = adminEntry.enabled; + } + } + } + }); + } else { + const skin = skins.find(s => s.name === category); + if (skin && skin.optionalIncludes) { + optionalIncludes = skin.optionalIncludes.map(include => { + const author = extractAuthorFromUrl(include.url); + const key = generateOptionalIncludeKey(category, author, include.url); + + const adminOptionalIncludes = config.optionalIncludes || []; + const adminEntry = adminOptionalIncludes.find(entry => + typeof entry === 'object' && entry.key === key + ); + + return { + ...include, + key: key, + enabled: adminEntry ? adminEntry.enabled : (include.enabled || false) + }; + }); + } + } + + if (optionalIncludes.length === 0) { + return ` +
+ No optional CSS modules available for ${category === 'global' ? 'global options' : category}. +
+ `; + } + + return ` +
+ Configure which optional CSS modules are enabled by default. Users can override these settings individually. +
+
+ ${optionalIncludes.map((include, index) => { + const author = extractAuthorFromUrl(include.url); + const key = include.key || generateOptionalIncludeKey(category, author, include.url); + const filename = include.url ? extractFilenameFromUrl(include.url) : ''; + + return ` +
+
+
+
+
${include.name}
+ ${author ? `` : ''} +
+ +
+ ${filename ? `
${filename}
` : ''} +
+
+ `; + }).join('')} +
+ `; + } + + /** + * Load configuration from window.KefinTweaksConfig + */ + async function loadConfig() { + const config = window.KefinTweaksConfig || {}; + + // Reconstruct full skin list (enabled + disabled) for configuration + const defaultSkins = window.KefinTweaksDefaultSkinsConfig?.skins || []; + const adminSkins = config.skins || []; + + const mergedSkins = []; + + // Add all default skins first + defaultSkins.forEach(skin => { + mergedSkins.push({ ...skin, enabled: true }); + }); + + // Override/Add with admin skins + adminSkins.forEach(adminSkin => { + const existingIndex = mergedSkins.findIndex(s => s.name === adminSkin.name); + if (existingIndex >= 0) { + mergedSkins[existingIndex] = { ...mergedSkins[existingIndex], ...adminSkin }; + } else { + mergedSkins.push({ ...adminSkin }); + } + }); + + return { + skins: mergedSkins, + themes: config.themes || [], + defaultSkin: config.defaultSkin || null, + optionalIncludes: config.optionalIncludes || [] + }; + } + + /** + * Save configuration to JS Injector + */ + async function saveConfig(skinManagerConfig) { + try { + if (!hasDefaultSkinsCatalog()) { + WARN('Refusing to save Skin Manager config: default skins catalog not loaded'); + notifySkinManagerConfig('Default skins are not loaded yet. Try again in a moment.'); + return false; + } + + const existingSkins = window.KefinTweaksConfig?.skins; + const existingNonEmpty = Array.isArray(existingSkins) && existingSkins.length > 0; + const nextSkins = skinManagerConfig?.skins; + const nextEmpty = !Array.isArray(nextSkins) || nextSkins.length === 0; + if (nextEmpty && existingNonEmpty) { + WARN('Refusing to save Skin Manager config: would wipe non-empty skins array'); + notifySkinManagerConfig('Save blocked: collected skins list is empty but existing config has skins. Reload and try again.'); + return false; + } + + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + + // Update skin manager config + window.KefinTweaksConfig.skins = skinManagerConfig.skins; + window.KefinTweaksConfig.themes = skinManagerConfig.themes; + window.KefinTweaksConfig.defaultSkin = skinManagerConfig.defaultSkin; + window.KefinTweaksConfig.optionalIncludes = skinManagerConfig.optionalIncludes; + + // Save to JS Injector using utils + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } else { + // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js + if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + /** + * Build configuration HTML + */ + function buildConfigHTML(config) { + const allSkinNames = config.skins.map(skin => skin.name).filter(Boolean); + const skinSources = window.KefinTweaksSkinManager?.getAllSkinSources?.() || {}; + const hasOverriddenSkins = Object.values(skinSources).some(source => source === 'overridden'); + + return ` +
+ +
+
+
Default Skin
+ +
Select a default skin for all users
+
+
+ + +
+
+

Enable/Disable Skins

+
Disabled skins will not appear in the appearance dropdowns for users
+
+ All themes are created with love by community members like you. Don't forget to thank them for their work and time if you appreciate their creations. + KefinTweaks has not contributed to the creation of any of the skins below and is only attempting to facilitate their accessibility. +
+
+ ${buildSkinToggles(config.skins, skinSources)} +
+
+
+ + ${hasOverriddenSkins ? ` +
+
+
Some default skins have been overridden by custom configurations. Click the button below to remove custom skins that override defaults.
+ +
+
+ ` : ''} + + +
+
+

Optional CSS Modules

+
Configure default enabled/disabled state for optional CSS modules. These settings will be used when users haven't specified their own preferences.
+
Select Skin
+ +
+ ${buildOptionalIncludesEditor('global', { optionalIncludes: config.optionalIncludes }, config.skins)} +
+
+
+ + +
+ + chevron_right + Skins JSON + +
+
+
Add additional skins that will be available to all users. Each skin can have multiple CSS files for different server versions. You can override the Default skins from KefinTweaks by specifying a custom configuration with that Skin Name in the configuration JSON below. Any skins that appear in this JSON and are named the same as the KefinTweaks default skins will override the default functionality. Default skins are not shown here as they are managed separately.
+ +
+
+
+ + +
+ + chevron_right + Themes JSON + +
+
+
Add additional themes that will be available to all users. Each theme should have a name and URL pointing to a CSS file.
+ +
+
+
+
+ `; + } + + /** + * Collect configuration from form + * @returns {Object|null} Collected config, or null if unsafe to save + */ + function collectConfig() { + if (!hasDefaultSkinsCatalog()) { + WARN('Cannot collect Skin Manager config: default skins catalog not loaded'); + notifySkinManagerConfig('Default skins are not loaded yet. Try again in a moment.'); + return null; + } + + // Get default skin + let defaultSkin = document.getElementById('defaultSkin')?.value || null; + if (defaultSkin === '') { + defaultSkin = null; + } + + // Get skin enabled states + const skinEnabledStates = {}; + document.querySelectorAll('[data-skin-name]').forEach(checkbox => { + const skinName = checkbox.getAttribute('data-skin-name'); + skinEnabledStates[skinName] = checkbox.checked; + }); + + // Get default skin names to filter them out + const defaultSkinNames = new Set((window.KefinTweaksDefaultSkinsConfig?.skins || []).map(s => s.name)); + + // Parse skins JSON + let skinsFromJson = []; + try { + const skinsJsonText = document.getElementById('skinsJson')?.value || '[]'; + skinsFromJson = JSON.parse(skinsJsonText); + } catch (e) { + ERR('Error parsing skins JSON:', e); + } + + // Only save admin skins (from JSON textarea) - filter out any that match default names + const customSkins = skinsFromJson + .filter(skin => !defaultSkinNames.has(skin.name)) + .map(skin => ({ + ...skin, + enabled: skinEnabledStates[skin.name] !== false, + hidden: false + })); + + // Handle disabled default skins + const disabledDefaultSkins = []; + defaultSkinNames.forEach(name => { + if (skinEnabledStates[name] === false) { + disabledDefaultSkins.push({ + name: name, + enabled: false + }); + } + }); + + // Combine custom skins and disabled default skins + const skins = [...customSkins, ...disabledDefaultSkins]; + + // Parse themes JSON + let themes = []; + try { + const themesJsonText = document.getElementById('themesJson')?.value || '[]'; + themes = JSON.parse(themesJsonText); + } catch (e) { + ERR('Error parsing themes JSON:', e); + } + + // Collect optional includes for the currently selected category only + const categorySelect = document.getElementById('optionalIncludesCategory'); + const currentCategory = categorySelect ? categorySelect.value : 'global'; + + // Get existing optional includes from config + const existingOptionalIncludes = (window.KefinTweaksConfig?.optionalIncludes || []).slice(); + + // Collect only the optional includes for the current category + const currentCategoryOptionalIncludes = []; + document.querySelectorAll('.optionalIncludeCheckbox').forEach(checkbox => { + const category = checkbox.getAttribute('data-category'); + const key = checkbox.getAttribute('data-key'); + const enabled = checkbox.checked; + + // Only collect if it matches the current category + if (category === currentCategory) { + currentCategoryOptionalIncludes.push({ + key: key, + enabled: enabled + }); + } + }); + + // Merge: remove existing entries for this category, then add new ones + const optionalIncludes = existingOptionalIncludes.filter(entry => { + if (typeof entry === 'object' && entry.key) { + const keyParts = entry.key.split('-'); + const entryCategory = keyParts[0]; + return entryCategory !== currentCategory; + } + return true; + }); + + // Add the current category's optional includes + optionalIncludes.push(...currentCategoryOptionalIncludes); + + const existingSkins = window.KefinTweaksConfig?.skins; + const existingNonEmpty = Array.isArray(existingSkins) && existingSkins.length > 0; + if (skins.length === 0 && existingNonEmpty) { + WARN('Cannot collect Skin Manager config: would wipe non-empty skins array'); + notifySkinManagerConfig('Save blocked: collected skins list is empty but existing config has skins. Reload and try again.'); + return null; + } + + return { + skins: skins, + themes: themes, + defaultSkin: defaultSkin, + optionalIncludes: optionalIncludes + }; + } + + /** + * Attach event listeners + */ + function attachListeners(modalInstance) { + // Optional includes category change + const categorySelect = modalInstance.dialogContent.querySelector('#optionalIncludesCategory'); + if (categorySelect) { + categorySelect.addEventListener('change', () => { + const category = categorySelect.value; + const editor = modalInstance.dialogContent.querySelector('#optionalIncludesEditor'); + if (editor) { + // Use saved config from window.KefinTweaksConfig to get the actual saved optionalIncludes + // This ensures the UI reflects the saved state, not just what's currently visible + const savedOptionalIncludes = window.KefinTweaksConfig?.optionalIncludes || []; + const config = { optionalIncludes: savedOptionalIncludes }; + const mergedSkins = currentLoadedConfig ? currentLoadedConfig.skins : collectConfig().skins; + editor.innerHTML = buildOptionalIncludesEditor(category, config, mergedSkins); + attachListeners(modalInstance); // Re-attach listeners for new checkboxes + } + }); + } + + // Remove duplicate defaults button + const removeBtn = modalInstance.dialogContent.querySelector('#removeDuplicateDefaultsBtn'); + if (removeBtn) { + removeBtn.addEventListener('click', () => { + // This would need to be implemented to remove overridden skins + alert('This feature will remove custom skins that override defaults. Implementation needed.'); + }); + } + } + + /** + * Open configuration modal + */ + async function openConfigModal() { + try { + if (!hasDefaultSkinsCatalog()) { + notifySkinManagerConfig('Default skins are not loaded yet. Try again in a moment.'); + return; + } + + // Load config + const config = await loadConfig(); + + // Store loaded config for use in listeners + currentLoadedConfig = config; + + // Build content + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + // Build footer + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + // Create modal + window.ModalSystem.create({ + id: MODAL_ID, + title: 'Skin Manager Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '1400px', height: '90vh' } + : undefined, + onOpen: (modal) => { + attachListeners(modal); + + // Save button + const saveBtn = modal.dialogFooter.querySelector('#save-skinmanager-config-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const skinManagerConfig = collectConfig(); + if (!skinManagerConfig) return; + const success = await saveConfig(skinManagerConfig); + if (success) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Skin Manager configuration saved!'); + } + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + } + }); + + LOG('Skin Manager configuration modal opened'); + } catch (error) { + ERR('Error opening skin manager config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + // Export + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.skinManager = { openConfigModal }; + + console.log('[KefinTweaks SkinManager Config] Script loaded'); + +})(); + diff --git a/configuration/thumbnailScrubber-configuration.js b/configuration/thumbnailScrubber-configuration.js new file mode 100644 index 0000000..e89540b --- /dev/null +++ b/configuration/thumbnailScrubber-configuration.js @@ -0,0 +1,294 @@ +// KefinTweaks Thumbnail Scrubber Configuration UI +// Modular configuration modal for managing thumbnail scrubber settings + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks ThumbnailScrubber Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks ThumbnailScrubber Config]', ...args); + + const MODAL_ID = 'kefin-thumbnailscrubber-config'; + + /** + * Load configuration from window.KefinTweaksConfig + */ + async function loadConfig() { + const config = window.KefinTweaksConfig || {}; + return config.thumbnailScrubber || {}; + } + + /** + * Save configuration to JS Injector + */ + async function saveConfig(thumbnailScrubberConfig) { + try { + // Ensure window.KefinTweaksConfig exists + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + + // Update thumbnailScrubber config + window.KefinTweaksConfig.thumbnailScrubber = thumbnailScrubberConfig; + + // Save to JS Injector using utils + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } else { + // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js + if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + /** + * Build configuration HTML + */ + function buildConfigHTML(config) { + const previewOnHover = config.PreviewOnHover === true; + const useVideoStream = config.UseVideoStream !== false; + let fallbackAfterMs = 500; + if (Number.isFinite(config.FallbackAfterMs * 1) && config.FallbackAfterMs >= 0) { + fallbackAfterMs = config.FallbackAfterMs * 1; + } else if (Number.isFinite(config.FallbackAfterSeconds * 1) && config.FallbackAfterSeconds >= 0) { + fallbackAfterMs = config.FallbackAfterSeconds * 1000; + } + const hoverDelay = Number.isFinite(config.HOVER_PREVIEW_TIMEOUT_MS * 1) + ? config.HOVER_PREVIEW_TIMEOUT_MS * 1 + : (Number.isFinite(config.HOVER_TIMEOUT * 1) ? config.HOVER_TIMEOUT * 1 : 800); + const hoverSpeed = Number.isFinite(config.HOVER_PREVIEW_FRAME_MS * 1) + ? config.HOVER_PREVIEW_FRAME_MS * 1 + : 250; + const scrubDelay = Number.isFinite(config.SCRUB_ACTIVATION_DELAY_MS * 1) + ? config.SCRUB_ACTIVATION_DELAY_MS * 1 + : 300; + + return ` +
+
+
+
Preview On Hover
+
+ When enabled, hovering over an eligible card will automatically cycle trickplay thumbnails + as the card background after a short delay, without needing to scrub in the progress zone. +
+ +
+
+ +
+
+
Use Video Stream Preview
+
+ When enabled, hover preview plays a muted video stream (/Videos/{id}/stream) + instead of cycling trickplay images as the card background. +
+ +
+
+ +
+
+
Fallback After (ms)
+
+ When Use Video Stream is on, how long to wait for the direct stream to start playing + before falling back to trickplay images. + Set to 0 to disable the timeout (play errors still fall back to trickplay). +
+ +
+
+ +
+
+
Delay Before Previewing On Hover (ms)
+
+ How long the cursor must stay over a card before the passive preview starts. + Set to 0 to start immediately when hovering a card. +
+ +
+
+ +
+
+
Hover Preview Speed (ms between frames)
+
+ Interval in milliseconds between each trickplay frame during hover preview. + Leave at 0 to use the default trickplay interval from the server. +
+ +
+
+ +
+
+
Delay Before Activating Scrubbing (ms)
+
+ How long the cursor must stay in the scrub zone at the bottom of the card before + the scrubber UI activates. +
+ +
+
+
+ `; + } + + /** + * Collect configuration from form + */ + function collectConfig() { + const previewOnHover = document.getElementById('thumbnailScrubber_PreviewOnHover')?.checked === true; + const useVideoStream = document.getElementById('thumbnailScrubber_UseVideoStream')?.checked === true; + const hoverDelayInput = document.getElementById('thumbnailScrubber_HOVER_PREVIEW_TIMEOUT_MS'); + const scrubDelayInput = document.getElementById('thumbnailScrubber_SCRUB_ACTIVATION_DELAY_MS'); + const hoverSpeedInput = document.getElementById('thumbnailScrubber_HOVER_PREVIEW_FRAME_MS'); + const fallbackAfterInput = document.getElementById('thumbnailScrubber_FallbackAfterMs'); + + let hoverDelay = hoverDelayInput ? parseInt(hoverDelayInput.value, 10) : 800; + if (!Number.isFinite(hoverDelay) || hoverDelay < 0) { + hoverDelay = 800; + } + + let scrubDelay = scrubDelayInput ? parseInt(scrubDelayInput.value, 10) : 300; + if (!Number.isFinite(scrubDelay) || scrubDelay < 0) { + scrubDelay = 300; + } + + let hoverSpeed = hoverSpeedInput ? parseInt(hoverSpeedInput.value, 10) : 0; + if (!Number.isFinite(hoverSpeed) || hoverSpeed < 0) { + hoverSpeed = 0; + } + + let fallbackAfterMs = fallbackAfterInput ? parseInt(fallbackAfterInput.value, 10) : 500; + if (!Number.isFinite(fallbackAfterMs) || fallbackAfterMs < 0) { + fallbackAfterMs = 500; + } + + return { + PreviewOnHover: previewOnHover, + UseVideoStream: useVideoStream, + FallbackAfterMs: fallbackAfterMs, + // Delay before previewing on hover + HOVER_PREVIEW_TIMEOUT_MS: hoverDelay, + HOVER_TIMEOUT: hoverDelay, // legacy key + // Hover preview speed (ms between frames) + HOVER_PREVIEW_FRAME_MS: hoverSpeed, + // Scrub activation delay + SCRUB_ACTIVATION_DELAY_MS: scrubDelay + }; + } + + /** + * Open configuration modal + */ + async function openConfigModal() { + try { + // Load config + const config = await loadConfig(); + + // Build content + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + // Build footer + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + // Create modal + window.ModalSystem.create({ + id: MODAL_ID, + title: 'Thumbnail Scrubber Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '800px', height: 'auto' } + : undefined, + onOpen: (modal) => { + // Save button + const saveBtn = modal.dialogFooter.querySelector('#save-thumbnailscrubber-config-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const thumbnailScrubberConfig = collectConfig(); + const success = await saveConfig(thumbnailScrubberConfig); + if (success) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Thumbnail Scrubber configuration saved!'); + } + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + } + }); + + LOG('Thumbnail Scrubber configuration modal opened'); + } catch (error) { + ERR('Error opening thumbnail scrubber config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + // Export + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.thumbnailScrubber = { openConfigModal }; + + console.log('[KefinTweaks ThumbnailScrubber Config] Script loaded'); + +})(); + diff --git a/configuration/userManager-configuration.js b/configuration/userManager-configuration.js new file mode 100644 index 0000000..782f7a3 --- /dev/null +++ b/configuration/userManager-configuration.js @@ -0,0 +1,868 @@ +// KefinTweaks User Manager Configuration UI — User Templates + +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks UserManager Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks UserManager Config]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks UserManager Config]', ...args); + + const CONFIG_MODAL_ID = 'kefin-usermanager-config'; + const EDITOR_MODAL_ID = 'kefin-usermanager-template-editor'; + const TAG_MODAL_ID = 'kefin-usermanager-tag-modal'; + const SCHEDULE_MODAL_ID = 'kefin-usermanager-schedule-modal'; + + const DEFAULT_AUTH = 'Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider'; + const DEFAULT_PASSWORD_RESET = 'Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider'; + + const UNRATED_TYPES = [ + { type: 'Book', label: 'Books' }, + { type: 'ChannelContent', label: 'Channels' }, + { type: 'LiveTvChannel', label: 'Live TV' }, + { type: 'Movie', label: 'Movies' }, + { type: 'Music', label: 'Music' }, + { type: 'Trailer', label: 'Trailers' }, + { type: 'Series', label: 'Shows' } + ]; + + const DAYS = [ + 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', + 'Everyday', 'Weekday', 'Weekend' + ]; + + function escapeHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function clone(obj) { + return JSON.parse(JSON.stringify(obj ?? {})); + } + + function getSeedPolicy() { + return { + IsAdministrator: false, + IsHidden: false, + EnableCollectionManagement: false, + EnableSubtitleManagement: false, + EnableLyricManagement: false, + IsDisabled: false, + BlockedTags: [], + AllowedTags: [], + EnableUserPreferenceAccess: true, + AccessSchedules: [], + BlockUnratedItems: [], + EnableRemoteControlOfOtherUsers: false, + EnableSharedDeviceControl: true, + EnableRemoteAccess: true, + EnableLiveTvManagement: true, + EnableLiveTvAccess: true, + EnableMediaPlayback: true, + EnableAudioPlaybackTranscoding: true, + EnableVideoPlaybackTranscoding: true, + EnablePlaybackRemuxing: true, + ForceRemoteSourceTranscoding: false, + EnableContentDeletion: false, + EnableContentDeletionFromFolders: [], + EnableContentDownloading: true, + EnableSyncTranscoding: true, + EnableMediaConversion: true, + EnabledDevices: [], + EnableAllDevices: true, + EnabledChannels: [], + EnableAllChannels: true, + EnabledFolders: [], + EnableAllFolders: true, + InvalidLoginAttemptCount: 0, + LoginAttemptsBeforeLockout: -1, + MaxActiveSessions: 0, + BlockedMediaFolders: [], + BlockedChannels: [], + RemoteClientBitrateLimit: 0, + AuthenticationProviderId: DEFAULT_AUTH, + PasswordResetProviderId: DEFAULT_PASSWORD_RESET, + SyncPlayAccess: 'CreateAndJoinGroups' + }; + } + + function normalizeConfig(raw) { + const cfg = raw && typeof raw === 'object' ? raw : {}; + const templates = Array.isArray(cfg.templates) + ? cfg.templates.filter((t) => t && typeof t.name === 'string' && t.name.trim()) + : []; + let defaultTemplateName = typeof cfg.defaultTemplateName === 'string' ? cfg.defaultTemplateName : ''; + if (defaultTemplateName && !templates.some((t) => t.name === defaultTemplateName)) { + defaultTemplateName = ''; + } + return { defaultTemplateName, templates }; + } + + function loadConfig() { + return normalizeConfig(window.KefinTweaksConfig?.userManager); + } + + async function saveConfig(next) { + try { + if (!window.KefinTweaksConfig) window.KefinTweaksConfig = {}; + window.KefinTweaksConfig.userManager = normalizeConfig(next); + + if (window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved'); + return true; + } + if (window.KefinTweaksConfiguration?.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved (fallback)'); + return true; + } + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } catch (e) { + ERR('Error saving config:', e); + return false; + } + } + + async function fetchMediaFolders() { + try { + const url = ApiClient.getUrl('Library/MediaFolders'); + const data = await ApiClient.getJSON(url); + return Array.isArray(data?.Items) ? data.Items : (Array.isArray(data) ? data : []); + } catch (e) { + WARN('Failed to load media folders', e); + return []; + } + } + + function buildCheckbox(id, checked, label, options) { + return window.KefinTweaksUI?.buildCheckbox?.(id, checked, label, options) || ''; + } + + function hourOptions(selected) { + const opts = []; + for (let h = 0; h <= 24; h += 0.5) { + const hours = Math.floor(h) % 12 || 12; + const mins = h % 1 ? '30' : '00'; + const ampm = h < 12 || h === 24 ? 'AM' : 'PM'; + const label = h === 24 ? '12:00 AM' : `${hours}:${mins} ${ampm}`; + opts.push(``); + } + return opts.join(''); + } + + function formatScheduleTime(h) { + const n = Number(h); + if (!Number.isFinite(n)) return ''; + if (n === 24) return '12:00 AM'; + const hours = Math.floor(n) % 12 || 12; + const mins = n % 1 ? '30' : '00'; + const ampm = n < 12 ? 'AM' : 'PM'; + return `${hours}:${mins} ${ampm}`; + } + + function bitrateToMbps(bps) { + const n = Number(bps) || 0; + if (n <= 0) return ''; + return String(Math.round((n / 1000000) * 100) / 100); + } + + function mbpsToBitrate(mbps) { + const n = parseFloat(mbps); + if (!Number.isFinite(n) || n <= 0) return 0; + return Math.round(n * 1000000); + } + + function buildTagListHTML(tags, listClass) { + if (!tags?.length) { + return `
No tags
`; + } + return tags.map((tag, i) => ` +
+

${escapeHtml(tag)}

+ +
`).join(''); + } + + function buildScheduleListHTML(schedules) { + if (!schedules?.length) { + return `
No access schedules
`; + } + return schedules.map((s, i) => ` +
+
+

${escapeHtml(s.DayOfWeek)}

+
${formatScheduleTime(s.StartHour)} - ${formatScheduleTime(s.EndHour)}
+
+ +
`).join(''); + } + + function buildEditorHTML(template, folders) { + const policy = { ...getSeedPolicy(), ...(template?.policy || {}) }; + const name = template?.name || ''; + const enabledFolders = new Set(policy.EnabledFolders || []); + const deleteFolders = new Set(policy.EnableContentDeletionFromFolders || []); + const blockUnrated = new Set(policy.BlockUnratedItems || []); + const allowedTags = policy.AllowedTags || []; + const blockedTags = policy.BlockedTags || []; + const schedules = policy.AccessSchedules || []; + + const folderChecks = folders.map((f) => { + const id = f.Id || f.id; + const label = f.Name || f.name || id; + return buildCheckbox('', enabledFolders.has(id), label, { + className: 'um-folder', + wrapInSection: true, + dataAttributes: { id } + }); + }).join(''); + + const deleteFolderChecks = folders.map((f) => { + const id = f.Id || f.id; + const label = f.Name || f.name || id; + return buildCheckbox('', deleteFolders.has(id), label, { + className: 'um-delete-folder', + wrapInSection: true, + dataAttributes: { id } + }); + }).join(''); + + const unratedChecks = UNRATED_TYPES.map((u) => + buildCheckbox('', blockUnrated.has(u.type), u.label, { + className: 'um-unrated', + wrapInSection: true, + dataAttributes: { itemtype: u.type } + }) + ).join(''); + + return ` +
+
+ + +
+ +
+

Permissions

+
+ ${buildCheckbox('', !!policy.EnableRemoteAccess, 'Allow remote connections to this server', { className: 'um-chk-remote', wrapInSection: true })} + ${buildCheckbox('', !!policy.IsAdministrator, 'Allow this user to manage the server', { className: 'um-chk-admin', wrapInSection: true })} + ${buildCheckbox('', !!policy.EnableCollectionManagement, 'Allow this user to manage collections', { className: 'um-chk-collections', wrapInSection: true })} + ${buildCheckbox('', !!policy.EnableSubtitleManagement, 'Allow this user to edit subtitles', { className: 'um-chk-subtitles', wrapInSection: true })} + ${buildCheckbox('', !!policy.EnableLyricManagement, 'Allow this user to manage lyrics', { className: 'um-chk-lyrics', wrapInSection: true })} +
+
+ +
+

Feature access

+
+ ${buildCheckbox('', !!policy.EnableLiveTvAccess, 'Allow Live TV access', { className: 'um-chk-livetv', wrapInSection: true })} + ${buildCheckbox('', !!policy.EnableLiveTvManagement, 'Allow Live TV recording management', { className: 'um-chk-livetv-mgmt', wrapInSection: true })} +
+
+ +
+

Media playback

+
+ ${buildCheckbox('', !!policy.EnableMediaPlayback, 'Allow media playback', { className: 'um-chk-playback', wrapInSection: true })} + ${buildCheckbox('', !!policy.EnableAudioPlaybackTranscoding, 'Allow audio playback that requires transcoding', { className: 'um-chk-audio-tc', wrapInSection: true })} + ${buildCheckbox('', !!policy.EnableVideoPlaybackTranscoding, 'Allow video playback that requires transcoding', { className: 'um-chk-video-tc', wrapInSection: true })} + ${buildCheckbox('', !!policy.EnablePlaybackRemuxing, 'Allow video playback that requires conversion without re-encoding', { className: 'um-chk-remux', wrapInSection: true })} + ${buildCheckbox('', !!policy.ForceRemoteSourceTranscoding, 'Force transcoding of remote media sources such as Live TV', { className: 'um-chk-force-remote-tc', wrapInSection: true })} + ${buildCheckbox('', !!policy.EnableSyncTranscoding, 'Allow sync transcoding', { className: 'um-chk-sync-tc', wrapInSection: true })} + ${buildCheckbox('', !!policy.EnableMediaConversion, 'Allow media conversion', { className: 'um-chk-conversion', wrapInSection: true })} +
+
+ + +
Leave empty or 0 for no per-user limit.
+
+
+ + +
+
+ +
+

Allow media deletion from

+
+ ${buildCheckbox('', !!policy.EnableContentDeletion, 'All libraries', { className: 'um-chk-delete-all', wrapInSection: true })} +
+ ${deleteFolderChecks || '
No libraries found
'} +
+
+
+ +
+

Remote Control

+
+ ${buildCheckbox('', !!policy.EnableRemoteControlOfOtherUsers, 'Allow remote control of other users', { className: 'um-chk-remote-others', wrapInSection: true })} + ${buildCheckbox('', !!policy.EnableSharedDeviceControl, 'Allow remote control of shared devices', { className: 'um-chk-remote-shared', wrapInSection: true })} +
+
+ +
+

Other

+ ${buildCheckbox('', !!policy.EnableContentDownloading, 'Allow media downloads', { className: 'um-chk-download', wrapInSection: true })} + ${buildCheckbox('', !!policy.EnableUserPreferenceAccess, 'Allow user preference access', { className: 'um-chk-prefs', wrapInSection: true })} + ${buildCheckbox('', !!policy.IsDisabled, 'Disable this user', { className: 'um-chk-disabled', wrapInSection: true })} + ${buildCheckbox('', !!policy.IsHidden, 'Hide this user from login screens', { className: 'um-chk-hidden', wrapInSection: true })} +
+ + +
-1 disables lockout. 0 inherits server default.
+
+
+ + +
0 disables the limit.
+
+
+ +
+

Library Access

+ ${buildCheckbox('', !!policy.EnableAllFolders, 'Enable access to all libraries', { className: 'um-chk-all-folders', wrapInSection: true })} +
+

Libraries

+
+ ${folderChecks || '
No libraries found
'} +
+
+
+ +
+

Block items with no or unrecognized rating information

+
${unratedChecks}
+
+ +
+
+

Allow items with tags

+ +
+
Only show media with at least one of the specified tags.
+
${buildTagListHTML(allowedTags, 'allowed')}
+
+ +
+
+

Block items with tags

+ +
+
Hide media with at least one of the specified tags.
+
${buildTagListHTML(blockedTags, 'blocked')}
+
+ +
+
+

Access Schedule

+ +
+

Limit access to certain hours.

+
${buildScheduleListHTML(schedules)}
+
+
+ `; + } + + function collectCheckedIds(root, selector) { + return Array.from(root.querySelectorAll(`${selector}:checked`)) + .map((el) => el.getAttribute('data-id')) + .filter(Boolean); + } + + function collectPolicyFromEditor(root, state) { + const enableAllFolders = !!root.querySelector('.um-chk-all-folders')?.checked; + const enableContentDeletion = !!root.querySelector('.um-chk-delete-all')?.checked; + + return { + ...getSeedPolicy(), + IsAdministrator: !!root.querySelector('.um-chk-admin')?.checked, + IsHidden: !!root.querySelector('.um-chk-hidden')?.checked, + EnableCollectionManagement: !!root.querySelector('.um-chk-collections')?.checked, + EnableSubtitleManagement: !!root.querySelector('.um-chk-subtitles')?.checked, + EnableLyricManagement: !!root.querySelector('.um-chk-lyrics')?.checked, + IsDisabled: !!root.querySelector('.um-chk-disabled')?.checked, + BlockedTags: state.blockedTags.slice(), + AllowedTags: state.allowedTags.slice(), + EnableUserPreferenceAccess: !!root.querySelector('.um-chk-prefs')?.checked, + AccessSchedules: state.schedules.map((s) => ({ ...s })), + BlockUnratedItems: Array.from(root.querySelectorAll('.um-unrated:checked')) + .map((el) => el.getAttribute('data-itemtype')) + .filter(Boolean), + EnableRemoteControlOfOtherUsers: !!root.querySelector('.um-chk-remote-others')?.checked, + EnableSharedDeviceControl: !!root.querySelector('.um-chk-remote-shared')?.checked, + EnableRemoteAccess: !!root.querySelector('.um-chk-remote')?.checked, + EnableLiveTvManagement: !!root.querySelector('.um-chk-livetv-mgmt')?.checked, + EnableLiveTvAccess: !!root.querySelector('.um-chk-livetv')?.checked, + EnableMediaPlayback: !!root.querySelector('.um-chk-playback')?.checked, + EnableAudioPlaybackTranscoding: !!root.querySelector('.um-chk-audio-tc')?.checked, + EnableVideoPlaybackTranscoding: !!root.querySelector('.um-chk-video-tc')?.checked, + EnablePlaybackRemuxing: !!root.querySelector('.um-chk-remux')?.checked, + ForceRemoteSourceTranscoding: !!root.querySelector('.um-chk-force-remote-tc')?.checked, + EnableContentDeletion: enableContentDeletion, + EnableContentDeletionFromFolders: enableContentDeletion + ? [] + : collectCheckedIds(root, '.um-delete-folder'), + EnableContentDownloading: !!root.querySelector('.um-chk-download')?.checked, + EnableSyncTranscoding: !!root.querySelector('.um-chk-sync-tc')?.checked, + EnableMediaConversion: !!root.querySelector('.um-chk-conversion')?.checked, + EnabledDevices: [], + EnableAllDevices: true, + EnabledChannels: [], + EnableAllChannels: true, + EnabledFolders: enableAllFolders ? [] : collectCheckedIds(root, '.um-folder'), + EnableAllFolders: enableAllFolders, + LoginAttemptsBeforeLockout: parseInt(root.querySelector('#um-lockout')?.value ?? '-1', 10), + MaxActiveSessions: parseInt(root.querySelector('#um-max-sessions')?.value ?? '0', 10), + RemoteClientBitrateLimit: mbpsToBitrate(root.querySelector('#um-bitrate')?.value), + AuthenticationProviderId: DEFAULT_AUTH, + PasswordResetProviderId: DEFAULT_PASSWORD_RESET, + SyncPlayAccess: root.querySelector('#um-syncplay')?.value || 'CreateAndJoinGroups' + }; + } + + function promptText(title, placeholder) { + return new Promise((resolve) => { + const content = document.createElement('div'); + content.innerHTML = ` +
+ + +
`; + const footer = document.createElement('div'); + footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;'; + footer.innerHTML = ` + + `; + + const modal = window.ModalSystem.create({ + id: TAG_MODAL_ID, + title, + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: { maxWidth: '420px', width: '90vw' }, + onOpen: (m) => { + const input = m.dialogContent.querySelector('#um-tag-input'); + const finish = (val) => { + window.ModalSystem.close(TAG_MODAL_ID); + resolve(val); + }; + m.dialogFooter.querySelector('#um-tag-cancel')?.addEventListener('click', () => finish(null)); + m.dialogFooter.querySelector('#um-tag-ok')?.addEventListener('click', () => { + finish(input?.value?.trim() || null); + }); + input?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + finish(input.value.trim() || null); + } + }); + setTimeout(() => input?.focus(), 50); + }, + onClose: () => resolve(null) + }); + }); + } + + function promptSchedule() { + return new Promise((resolve) => { + const dayOpts = DAYS.map((d) => ``).join(''); + const content = document.createElement('div'); + content.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
`; + const footer = document.createElement('div'); + footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;'; + footer.innerHTML = ` + + `; + + let settled = false; + const finish = (val) => { + if (settled) return; + settled = true; + window.ModalSystem.close(SCHEDULE_MODAL_ID); + resolve(val); + }; + + const modal = window.ModalSystem.create({ + id: SCHEDULE_MODAL_ID, + title: 'Access Schedule', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: { maxWidth: '420px', width: '90vw' }, + onOpen: (m) => { + m.dialogFooter.querySelector('#um-sched-cancel')?.addEventListener('click', () => finish(null)); + m.dialogFooter.querySelector('#um-sched-ok')?.addEventListener('click', () => { + const DayOfWeek = m.dialogContent.querySelector('#um-sched-day')?.value; + const StartHour = parseFloat(m.dialogContent.querySelector('#um-sched-start')?.value); + const EndHour = parseFloat(m.dialogContent.querySelector('#um-sched-end')?.value); + if (!DayOfWeek || !Number.isFinite(StartHour) || !Number.isFinite(EndHour) || EndHour <= StartHour) { + window.KefinTweaksToaster?.toast?.('End time must be after start time'); + return; + } + finish({ DayOfWeek, StartHour, EndHour }); + }); + }, + onClose: () => finish(null) + }); + }); + } + + function refreshTagLists(root, state) { + const allowedEl = root.querySelector('.um-allowed-tags'); + const blockedEl = root.querySelector('.um-blocked-tags'); + if (allowedEl) allowedEl.innerHTML = buildTagListHTML(state.allowedTags, 'allowed'); + if (blockedEl) blockedEl.innerHTML = buildTagListHTML(state.blockedTags, 'blocked'); + } + + function refreshSchedules(root, state) { + const el = root.querySelector('.um-schedules'); + if (el) el.innerHTML = buildScheduleListHTML(state.schedules); + } + + function setCheckboxesChecked(root, selector, checked) { + root.querySelectorAll(selector).forEach((el) => { + el.checked = checked; + if (checked) el.setAttribute('checked', ''); + else el.removeAttribute('checked'); + }); + } + + function bindEditorInteractions(root, state) { + const bindAllLibrariesToggle = (chkSel, listSel, itemSel) => { + const chk = root.querySelector(chkSel); + const list = root.querySelector(listSel); + const sync = () => { + const allChecked = !!chk?.checked; + if (list) list.style.display = allChecked ? 'none' : ''; + // When leaving "all", select every library by default + if (!allChecked) setCheckboxesChecked(root, itemSel, true); + }; + chk?.addEventListener('change', sync); + // Initial visibility only — do not force-select on load (preserve saved template) + if (list) list.style.display = chk?.checked ? 'none' : ''; + }; + + bindAllLibrariesToggle('.um-chk-all-folders', '.um-folder-list', '.um-folder'); + bindAllLibrariesToggle('.um-chk-delete-all', '.um-delete-folders', '.um-delete-folder'); + + root.addEventListener('click', async (e) => { + if (e.target.closest('.um-add-allowed-tag')) { + const tag = await promptText('Allow tag', 'Enter a tag'); + if (tag && !state.allowedTags.includes(tag)) { + state.allowedTags.push(tag); + refreshTagLists(root, state); + } + return; + } + if (e.target.closest('.um-add-blocked-tag')) { + const tag = await promptText('Block tag', 'Enter a tag'); + if (tag && !state.blockedTags.includes(tag)) { + state.blockedTags.push(tag); + refreshTagLists(root, state); + } + return; + } + if (e.target.closest('.um-add-schedule')) { + const schedule = await promptSchedule(); + if (schedule) { + state.schedules.push(schedule); + refreshSchedules(root, state); + } + return; + } + const delTag = e.target.closest('.um-delete-tag'); + if (delTag) { + const list = delTag.dataset.list; + const index = parseInt(delTag.dataset.index, 10); + if (list === 'allowed') state.allowedTags.splice(index, 1); + if (list === 'blocked') state.blockedTags.splice(index, 1); + refreshTagLists(root, state); + return; + } + const delSched = e.target.closest('.um-delete-schedule'); + if (delSched) { + const index = parseInt(delSched.dataset.index, 10); + state.schedules.splice(index, 1); + refreshSchedules(root, state); + } + }); + } + + function buildMainConfigHTML(config) { + const options = [``] + .concat(config.templates.map((t) => + `` + )).join(''); + + const list = config.templates.length + ? config.templates.map((t) => ` +
+
+
${escapeHtml(t.name)}
+
+ Modified ${escapeHtml(t.dateModified ? new Date(t.dateModified).toLocaleString() : '—')} +
+
+ + +
`).join('') + : `
No templates yet. Create one to get started.
`; + + return ` +
+
+ Create reusable user policy templates and apply them to new or existing accounts. + Import / Export user data will be added here later. +
+
+ + +
Used as the initial selection on the Add User page.
+
+
+ +
+
${list}
+
`; + } + + async function persistTemplates(mutator) { + const config = loadConfig(); + mutator(config); + const ok = await saveConfig(config); + if (!ok) { + alert('Error saving configuration. Ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + return ok; + } + + async function openTemplateEditor(existingTemplate) { + const isEdit = !!(existingTemplate && existingTemplate.name); + const originalName = isEdit ? existingTemplate.name : null; + const folders = await fetchMediaFolders(); + + const state = { + allowedTags: (existingTemplate?.policy?.AllowedTags || []).slice(), + blockedTags: (existingTemplate?.policy?.BlockedTags || []).slice(), + schedules: clone(existingTemplate?.policy?.AccessSchedules || []) + }; + + const content = document.createElement('div'); + content.innerHTML = buildEditorHTML( + existingTemplate || { name: '', policy: getSeedPolicy() }, + folders + ); + bindEditorInteractions(content, state); + + const footer = document.createElement('div'); + footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;'; + footer.innerHTML = ` + + `; + + window.ModalSystem.create({ + id: EDITOR_MODAL_ID, + title: isEdit ? `Edit Template: ${existingTemplate.name}` : 'Create Template', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '1400px', height: '90vh' } + : undefined, + onOpen: (modal) => { + modal.dialogFooter.querySelector('#um-editor-cancel')?.addEventListener('click', () => { + window.ModalSystem.close(EDITOR_MODAL_ID); + }); + + modal.dialogFooter.querySelector('#um-editor-save')?.addEventListener('click', async () => { + const root = modal.dialogContent; + const name = root.querySelector('#um-template-name')?.value?.trim(); + if (!name) { + window.KefinTweaksToaster?.toast?.('Template name is required'); + return; + } + const policy = collectPolicyFromEditor(root, state); + const config = loadConfig(); + if (!isEdit && config.templates.some((t) => t.name === name)) { + window.KefinTweaksToaster?.toast?.('A template with that name already exists'); + return; + } + if (isEdit && name !== originalName && config.templates.some((t) => t.name === name)) { + window.KefinTweaksToaster?.toast?.('A template with that name already exists'); + return; + } + + const next = { + name, + dateModified: new Date().toISOString(), + policy + }; + const ok = await persistTemplates((cfg) => { + if (isEdit) { + const idx = cfg.templates.findIndex((t) => t.name === originalName); + if (idx >= 0) cfg.templates[idx] = next; + else cfg.templates.push(next); + if (cfg.defaultTemplateName === originalName) cfg.defaultTemplateName = name; + } else { + cfg.templates.push(next); + } + }); + + if (ok) { + window.KefinTweaksToaster?.toast?.('Template saved'); + window.ModalSystem.close(EDITOR_MODAL_ID); + refreshMainConfigListIfOpen(); + } + }); + } + }); + } + + function refreshMainConfigListIfOpen() { + const listRoot = document.querySelector('.um-config-root'); + if (!listRoot) return; + const wrap = listRoot.parentElement; + if (!wrap) return; + wrap.innerHTML = buildMainConfigHTML(loadConfig()); + bindMainConfigActions(wrap); + } + + function bindMainConfigActions(root) { + if (!root) return; + const scope = root; + + scope.querySelector('#um-create-template')?.addEventListener('click', () => { + openTemplateEditor(null); + }); + + scope.querySelector('#um-default-template')?.addEventListener('change', async (e) => { + const value = e.target.value || ''; + await persistTemplates((config) => { + config.defaultTemplateName = value; + }); + }); + + scope.addEventListener('click', async (e) => { + const del = e.target.closest('.um-delete-template'); + if (del) { + e.stopPropagation(); + const name = del.dataset.name; + if (!name || !confirm(`Delete template "${name}"?`)) return; + const ok = await persistTemplates((config) => { + config.templates = config.templates.filter((t) => t.name !== name); + if (config.defaultTemplateName === name) config.defaultTemplateName = ''; + }); + if (ok) refreshMainConfigListIfOpen(); + return; + } + + const editBtn = e.target.closest('.um-edit-template'); + const row = e.target.closest('.um-template-row'); + const target = editBtn || row; + if (target) { + e.stopPropagation(); + const name = target.dataset.name; + const template = loadConfig().templates.find((t) => t.name === name); + if (template) openTemplateEditor(clone(template)); + } + }); + } + + async function openConfigModal() { + try { + const config = loadConfig(); + const content = document.createElement('div'); + content.innerHTML = buildMainConfigHTML(config); + bindMainConfigActions(content); + + const footer = document.createElement('div'); + footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;'; + footer.innerHTML = ``; + + window.ModalSystem.create({ + id: CONFIG_MODAL_ID, + title: 'User Manager', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '1400px', height: '90vh' } + : undefined + }); + LOG('User Manager config opened'); + } catch (e) { + ERR('Error opening User Manager config', e); + alert('Error opening configuration: ' + e.message); + } + } + + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.userManager = { + openConfigModal, + openTemplateEditor, + loadConfig, + getSeedPolicy, + clonePolicy: clone + }; + + // Shared helpers for runtime script + window.KefinUserManagerConfig = { + loadConfig, + getSeedPolicy, + clonePolicy: clone, + openTemplateEditor, + openConfigModal + }; + + console.log('[KefinTweaks UserManager Config] Script loaded'); +})(); diff --git a/configuration/versionPreferences-configuration.js b/configuration/versionPreferences-configuration.js new file mode 100644 index 0000000..47c09d4 --- /dev/null +++ b/configuration/versionPreferences-configuration.js @@ -0,0 +1,485 @@ +// KefinTweaks Item Version Preferences Configuration UI + +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks VersionPreferences Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks VersionPreferences Config]', ...args); + + const MODAL_ID = 'kefin-versionpreferences-config'; + const NONE_SENTINEL = 'None'; + + const DEFAULT_VERSIONS = ['4k', '2160p', '1080p', '720p', '576p', '480p', '360p', NONE_SENTINEL]; + const DEFAULT_EDITIONS = ['Theatrical', "Director's Cut", 'Extended Edition', 'Fan Edit', NONE_SENTINEL]; + + function escapeHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function isNoneSentinel(term) { + return typeof term === 'string' && term.trim().toLowerCase() === NONE_SENTINEL.toLowerCase(); + } + + function ensureNoneSentinel(list) { + const out = []; + let noneSeen = false; + (Array.isArray(list) ? list : []).forEach((term) => { + if (typeof term !== 'string' || !term.trim()) return; + if (isNoneSentinel(term)) { + if (!noneSeen) { + out.push(NONE_SENTINEL); + noneSeen = true; + } + return; + } + out.push(term.trim()); + }); + if (!noneSeen) out.push(NONE_SENTINEL); + return out; + } + + function getDefaults() { + return { + versions: DEFAULT_VERSIONS.slice(), + editions: DEFAULT_EDITIONS.slice(), + showVersionBeforeEdition: false + }; + } + + async function loadConfig() { + const defaults = getDefaults(); + const cfg = window.KefinTweaksConfig?.versionPreferences || {}; + return { + versions: ensureNoneSentinel( + Array.isArray(cfg.versions) ? cfg.versions : defaults.versions + ), + editions: ensureNoneSentinel( + Array.isArray(cfg.editions) ? cfg.editions : defaults.editions + ), + showVersionBeforeEdition: cfg.showVersionBeforeEdition === true + }; + } + + async function saveConfig(versionPreferences) { + try { + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + window.KefinTweaksConfig.versionPreferences = { + versions: ensureNoneSentinel(versionPreferences.versions), + editions: ensureNoneSentinel(versionPreferences.editions), + showVersionBeforeEdition: versionPreferences.showVersionBeforeEdition === true + }; + + if (window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } + + if (window.KefinTweaksConfiguration?.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + function buildTermRowHTML(term, index, listKey) { + const isNone = isNoneSentinel(term); + const termCell = isNone + ? ` + ${escapeHtml(NONE_SENTINEL)} + (no match) + ` + : ``; + + const removeBtn = isNone + ? '' + : ``; + + return ` +
+ ${index + 1}. + ${termCell} + + + ${removeBtn} +
+ `; + } + + function buildListSectionHTML(title, hint, listKey, terms) { + return ` +
+
+
${escapeHtml(title)}
+
${escapeHtml(hint)}
+
+ ${terms.map((term, i) => buildTermRowHTML(term, i, listKey)).join('')} +
+
+ + +
+
+
+ `; + } + + function ensureLayoutStyles() { + if (document.getElementById('kefin-vp-config-styles')) return; + const style = document.createElement('style'); + style.id = 'kefin-vp-config-styles'; + style.textContent = ` + .kefin-vp-lists-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1em; + align-items: start; + height: calc(100% - 40px); + } + @media (max-width: 899px) { + .kefin-vp-lists-grid { + grid-template-columns: 1fr; + } + } + [data-name="kefin-modal-content"]:has(.kefin-vp-lists-grid) > div { + height: 100% !important; + } + .listItem.kefin-vp-list-section { + align-items: start; + } + .kefin-vp-label-order-card { + display: flex; + align-items: center; + gap: 1em; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 0.85em 1em; + margin-bottom: 1em; + } + .kefin-vp-label-order-text { + flex: 1; + min-width: 0; + } + .kefin-vp-label-order-card .kefin-vp-toggle-switch { + position: relative; + width: 52px; + height: 28px; + border-radius: 14px; + border: none; + background: rgba(158, 158, 158, 0.5); + cursor: pointer; + flex-shrink: 0; + padding: 0; + transition: background-color 0.3s ease; + } + .kefin-vp-label-order-card .kefin-vp-toggle-switch[data-enabled="true"] { + background: rgba(0, 164, 220, 0.8); + } + .kefin-vp-label-order-card .kefin-vp-toggle-switch::after { + content: ""; + position: absolute; + width: 22px; + height: 22px; + border-radius: 50%; + background: white; + top: 3px; + left: 3px; + transition: left 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + .kefin-vp-label-order-card .kefin-vp-toggle-switch[data-enabled="true"]::after { + left: calc(100% - 25px); + } + `; + document.head.appendChild(style); + } + + function getLabelOrderDescription(showVersionBeforeEdition) { + return showVersionBeforeEdition + ? 'Version will be listed before the edition (1080p - Theatrical)' + : 'Edition will be listed before the version (Theatrical - 1080p)'; + } + + function buildLabelOrderToggleHTML(showVersionBeforeEdition) { + const enabled = showVersionBeforeEdition === true; + return ` +
+ +
+
List Version Before Edition
+
+ ${escapeHtml(getLabelOrderDescription(enabled))} +
+
+ +
+ `; + } + + function updateLabelOrderToggleUI(root, checked) { + const checkbox = root.querySelector('#kefin-vp-show-version-before-edition'); + const toggleBtn = root.querySelector('.kefin-vp-toggle-switch'); + const desc = root.querySelector('.kefin-vp-label-order-desc'); + if (checkbox) checkbox.checked = checked === true; + if (toggleBtn) { + toggleBtn.dataset.enabled = checked === true; + toggleBtn.setAttribute('aria-pressed', String(checked === true)); + } + if (desc) desc.textContent = getLabelOrderDescription(checked === true); + } + + function buildConfigHTML(config) { + return ` +
+
+ Manage how different versions and editions of your items are displayed. + Terms are matched as case-insensitive substrings of the media source label. + Higher list positions are prioritized first. +
+ ${buildLabelOrderToggleHTML(config.showVersionBeforeEdition)} +
+ ${buildListSectionHTML( + 'Version Terms', + 'Resolution / quality labels (e.g. 4k, 1080p).', + 'versions', + config.versions + )} + ${buildListSectionHTML( + 'Edition Terms', + 'Edition labels (e.g. Theatrical, Director\'s Cut).', + 'editions', + config.editions + )} +
+
+ `; + } + + function readTermsFromDom(root, listKey) { + const rows = root.querySelectorAll(`.kefin-vp-term-row[data-list="${listKey}"]`); + const terms = []; + rows.forEach((row) => { + if (row.dataset.isNone === 'true') { + terms.push(NONE_SENTINEL); + return; + } + const input = row.querySelector('.kefin-vp-term-input'); + const value = input?.value?.trim(); + if (value) terms.push(value); + }); + return ensureNoneSentinel(terms); + } + + function syncStateFromDom(root, state) { + state.versions = readTermsFromDom(root, 'versions'); + state.editions = readTermsFromDom(root, 'editions'); + const checkbox = root.querySelector('#kefin-vp-show-version-before-edition'); + state.showVersionBeforeEdition = checkbox?.checked === true; + } + + function refreshList(root, state, listKey) { + const listEl = root.querySelector(`.kefin-vp-term-list[data-list="${listKey}"]`); + if (!listEl) return; + state[listKey] = ensureNoneSentinel(state[listKey] || []); + const terms = state[listKey]; + listEl.innerHTML = terms.map((term, i) => buildTermRowHTML(term, i, listKey)).join(''); + const rows = listEl.querySelectorAll('.kefin-vp-term-row'); + rows.forEach((row, i) => { + const down = row.querySelector('.kefin-vp-move-down'); + if (down) down.disabled = i === rows.length - 1; + const up = row.querySelector('.kefin-vp-move-up'); + if (up) up.disabled = i === 0; + }); + } + + function collectConfig(root) { + return { + versions: readTermsFromDom(root, 'versions'), + editions: readTermsFromDom(root, 'editions'), + showVersionBeforeEdition: root.querySelector('#kefin-vp-show-version-before-edition')?.checked === true + }; + } + + function bindLabelOrderToggle(root, state) { + root.addEventListener('click', (e) => { + const toggleBtn = e.target.closest('.kefin-vp-toggle-switch'); + if (!toggleBtn || !root.contains(toggleBtn)) return; + e.preventDefault(); + const checkbox = root.querySelector('#kefin-vp-show-version-before-edition'); + if (!checkbox) return; + checkbox.checked = !checkbox.checked; + state.showVersionBeforeEdition = checkbox.checked; + updateLabelOrderToggleUI(root, checkbox.checked); + }); + } + + function bindListActions(root, state) { + root.addEventListener('click', (e) => { + const addBtn = e.target.closest('.kefin-vp-add-btn'); + if (addBtn) { + const listKey = addBtn.dataset.list; + const input = root.querySelector(`.kefin-vp-add-input[data-list="${listKey}"]`); + const term = input?.value?.trim(); + if (!term) return; + if (isNoneSentinel(term)) { + window.KefinTweaksToaster?.toast?.('"None" is a built-in priority slot and cannot be added as a term.'); + if (input) input.value = ''; + return; + } + syncStateFromDom(root, state); + state[listKey].push(term); + state[listKey] = ensureNoneSentinel(state[listKey]); + if (input) input.value = ''; + refreshList(root, state, listKey); + return; + } + + const removeBtn = e.target.closest('.kefin-vp-remove'); + if (removeBtn) { + const listKey = removeBtn.dataset.list; + const index = parseInt(removeBtn.dataset.index, 10); + syncStateFromDom(root, state); + if (Number.isNaN(index)) return; + if (isNoneSentinel(state[listKey][index])) return; + state[listKey].splice(index, 1); + state[listKey] = ensureNoneSentinel(state[listKey]); + refreshList(root, state, listKey); + return; + } + + const upBtn = e.target.closest('.kefin-vp-move-up'); + if (upBtn) { + const listKey = upBtn.dataset.list; + const index = parseInt(upBtn.dataset.index, 10); + syncStateFromDom(root, state); + if (Number.isNaN(index) || index <= 0) return; + const list = state[listKey]; + [list[index - 1], list[index]] = [list[index], list[index - 1]]; + refreshList(root, state, listKey); + return; + } + + const downBtn = e.target.closest('.kefin-vp-move-down'); + if (downBtn) { + const listKey = downBtn.dataset.list; + const index = parseInt(downBtn.dataset.index, 10); + syncStateFromDom(root, state); + if (Number.isNaN(index) || index >= state[listKey].length - 1) return; + const list = state[listKey]; + [list[index], list[index + 1]] = [list[index + 1], list[index]]; + refreshList(root, state, listKey); + } + }); + + root.addEventListener('keydown', (e) => { + if (e.key !== 'Enter') return; + const addInput = e.target.closest('.kefin-vp-add-input'); + if (!addInput) return; + e.preventDefault(); + const listKey = addInput.dataset.list; + const btn = root.querySelector(`.kefin-vp-add-btn[data-list="${listKey}"]`); + btn?.click(); + }); + } + + async function openConfigModal() { + try { + ensureLayoutStyles(); + const config = await loadConfig(); + const state = { + versions: config.versions.slice(), + editions: config.editions.slice(), + showVersionBeforeEdition: config.showVersionBeforeEdition === true + }; + + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(state); + bindLabelOrderToggle(content, state); + bindListActions(content, state); + refreshList(content, state, 'versions'); + refreshList(content, state, 'editions'); + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + window.ModalSystem.create({ + id: MODAL_ID, + title: 'Item Version Preferences', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '1400px', height: '90vh' } + : undefined, + onOpen: (modal) => { + const saveBtn = modal.dialogFooter.querySelector('#save-versionpreferences-config-btn'); + saveBtn?.addEventListener('click', async () => { + const root = modal.dialogContent || content; + syncStateFromDom(root, state); + const toSave = collectConfig(root); + const success = await saveConfig(toSave); + if (success) { + window.KefinTweaksToaster?.toast?.('Configuration saved!'); + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + }); + + LOG('Version Preferences configuration modal opened'); + } catch (error) { + ERR('Error opening version preferences config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.versionPreferences = { openConfigModal }; + + console.log('[KefinTweaks VersionPreferences Config] Script loaded'); +})(); diff --git a/configuration/watchTogether-configuration.js b/configuration/watchTogether-configuration.js new file mode 100644 index 0000000..eea3eb7 --- /dev/null +++ b/configuration/watchTogether-configuration.js @@ -0,0 +1,127 @@ +// KefinTweaks Watch Together Configuration UI + +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks WatchTogether Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks WatchTogether Config]', ...args); + + const MODAL_ID = 'kefin-watchtogether-config'; + const DEFAULT_SESSION_TIMEOUT_MINUTES = 30; + + async function loadConfig() { + const config = window.KefinTweaksConfig || {}; + return config.watchTogether || {}; + } + + async function saveConfig(watchTogetherConfig) { + try { + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + window.KefinTweaksConfig.watchTogether = watchTogetherConfig; + + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } + if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + function buildConfigHTML(config) { + const timeout = Number.isFinite(config.sessionTimeout * 1) + ? config.sessionTimeout * 1 + : DEFAULT_SESSION_TIMEOUT_MINUTES; + + return ` +
+
+
+
Session timeout (minutes)
+
+ After selecting who is watching, skip the Watch Together prompt for this many minutes + when starting another item on this device. Playback that continues on the video OSD + (autoplay) never re-prompts. +
+
+ +
+
+
+
+ `; + } + + function collectConfig() { + const raw = parseInt(document.querySelector('#wt-session-timeout')?.value || String(DEFAULT_SESSION_TIMEOUT_MINUTES), 10); + const sessionTimeout = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SESSION_TIMEOUT_MINUTES; + return { sessionTimeout }; + } + + async function openConfigModal() { + try { + const config = await loadConfig(); + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + window.ModalSystem.create({ + id: MODAL_ID, + title: 'Watch Together', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: { maxWidth: '90vw', width: '560px' }, + onOpen: (modal) => { + const saveBtn = modal.dialogFooter.querySelector('#save-watchtogether-config-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const next = collectConfig(); + const success = await saveConfig(next); + if (success) { + window.KefinTweaksToaster?.toast?.('Watch Together configuration saved!'); + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + } + }); + LOG('Watch Together configuration modal opened'); + } catch (error) { + ERR('Error opening Watch Together config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.watchTogether = { openConfigModal }; + + console.log('[KefinTweaks WatchTogether Config] Script loaded'); +})(); diff --git a/configuration/watchlist-configuration.js b/configuration/watchlist-configuration.js new file mode 100644 index 0000000..d5825c9 --- /dev/null +++ b/configuration/watchlist-configuration.js @@ -0,0 +1,203 @@ +// KefinTweaks Watchlist Configuration UI + +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks Watchlist Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks Watchlist Config]', ...args); + + const MODAL_ID = 'kefin-watchlist-config'; + + const DEFAULTS = { + sideMenu: true, + topNavigation: 'main', + userMenu: true, + order: 2 + }; + + function normalizeConfig(raw) { + const cfg = raw && typeof raw === 'object' ? raw : {}; + const orderRaw = parseInt(cfg.order, 10); + return { + sideMenu: cfg.sideMenu !== false, + topNavigation: typeof cfg.topNavigation === 'boolean' + ? (cfg.topNavigation ? 'main' : 'none') + : cfg.topNavigation === undefined ? 'main' : cfg.topNavigation, + userMenu: cfg.userMenu !== false, + order: Number.isFinite(orderRaw) ? orderRaw : DEFAULTS.order + }; + } + + async function loadConfig() { + const config = window.KefinTweaksConfig || {}; + return normalizeConfig(config.watchlist); + } + + async function saveConfig(watchlistConfig) { + try { + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + window.KefinTweaksConfig.watchlist = watchlistConfig; + + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } + if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + function buildToggleCard(id, checked, label, description) { + if (typeof window.KefinTweaksUI?.buildToggleCard === 'function') { + return window.KefinTweaksUI.buildToggleCard(id, checked, label, description); + } + return ` +
+ + ${description ? `
${description}
` : ''} +
+ `; + } + + function buildConfigHTML(config) { + const order = Number.isFinite(config.order) ? config.order : DEFAULTS.order; + return ` +
+ ${buildToggleCard( + 'wl-side-menu', + config.sideMenu, + 'Side menu', + 'Show a Watchlist link in the left navigation drawer.' + )} + ${buildToggleCard( + 'wl-top-navigation', + config.topNavigation !== false && config.topNavigation !== 'none', + 'Top navigation', + 'Show Watchlist in the main top navigation bar (or via Custom Tabs when available).' + )} + ${buildToggleCard( + 'wl-user-menu', + config.userMenu, + 'User menu', + 'Show a Watchlist link in the user settings menu.' + )} +
+
+
Menu order
+
+ Lower values appear earlier in the side menu and top navigation. +
+
+ +
+
+
+
+ `; + } + + function collectConfig() { + const side = document.getElementById('wl-side-menu'); + const top = document.getElementById('wl-top-navigation'); + const user = document.getElementById('wl-user-menu'); + const orderRaw = parseInt(document.getElementById('wl-order')?.value || String(DEFAULTS.order), 10); + return { + sideMenu: side ? side.checked : DEFAULTS.sideMenu, + topNavigation: top + ? (top.checked ? 'main' : 'none') + : DEFAULTS.topNavigation, + userMenu: user ? user.checked : DEFAULTS.userMenu, + order: Number.isFinite(orderRaw) ? orderRaw : DEFAULTS.order + }; + } + + function attachToggleHandlers(root) { + if (typeof window.KefinTweaksUI?.bindToggleCards === 'function') { + window.KefinTweaksUI.bindToggleCards(root); + return; + } + root.querySelectorAll('.kefin-toggle-switch').forEach((btn) => { + if (btn.dataset.kefinToggleBound === 'true') return; + btn.dataset.kefinToggleBound = 'true'; + btn.addEventListener('click', () => { + const next = btn.dataset.enabled !== 'true'; + btn.dataset.enabled = String(next); + btn.setAttribute('aria-pressed', String(next)); + const checkboxId = btn.dataset.checkboxId; + const checkbox = checkboxId ? document.getElementById(checkboxId) : null; + if (checkbox) checkbox.checked = next; + }); + }); + } + + async function openConfigModal() { + try { + const config = await loadConfig(); + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + window.ModalSystem.create({ + id: MODAL_ID, + title: 'Watchlist', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: { maxWidth: '90vw', width: '560px' }, + onOpen: (modal) => { + attachToggleHandlers(modal.dialogContent || content); + const saveBtn = modal.dialogFooter.querySelector('#save-watchlist-config-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const next = collectConfig(); + const success = await saveConfig(next); + if (success) { + window.KefinTweaksToaster?.toast?.('Watchlist configuration saved!'); + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + } + }); + LOG('Watchlist configuration modal opened'); + } catch (error) { + ERR('Error opening Watchlist config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.watchlist = { openConfigModal, DEFAULTS, normalizeConfig }; + + console.log('[KefinTweaks Watchlist Config] Script loaded'); +})(); diff --git a/helpers/dataHelper.js b/helpers/dataHelper.js new file mode 100644 index 0000000..d28277e --- /dev/null +++ b/helpers/dataHelper.js @@ -0,0 +1,292 @@ +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks DataHelper]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks DataHelper]', ...args); + const ERR = (...args) => console.error('[KefinTweaks DataHelper]', ...args); + + /** + * Ensure ApiClient is available before using helper methods + */ + function ensureApiClient() { + if (typeof ApiClient === 'undefined' || !ApiClient) { + throw new Error('ApiClient is not available'); + } + } + + /** @type {Map} */ + const filtersMemoryCache = new Map(); + + function parseMajorVersion(version) { + if (!version || typeof version !== 'string') return 0; + const major = parseInt(version.split('.')[0], 10); + return Number.isNaN(major) ? 0 : major; + } + + function getServerMajorVersion() { + ensureApiClient(); + const version = ApiClient._serverVersion || ApiClient._appVersion || ''; + return parseMajorVersion(version); + } + + function normalizeIncludeItemTypesKey(includeItemTypes) { + if (includeItemTypes == null) return 'ALL'; + const list = Array.isArray(includeItemTypes) + ? includeItemTypes.map(t => String(t).trim()).filter(Boolean) + : String(includeItemTypes).split(',').map(s => s.trim()).filter(Boolean); + if (list.length === 0) return 'ALL'; + return [...list].sort().join(','); + } + + function buildIncludeItemTypesParam(includeItemTypes) { + const key = normalizeIncludeItemTypesKey(includeItemTypes); + if (key === 'ALL') return ''; + return key; + } + + async function unwrapGetQueryResult(result) { + if (!result) return null; + if (typeof result.ensureData === 'function') { + return await result.ensureData(); + } + if (result.dataPromise != null) { + return await result.dataPromise; + } + return result; + } + + function normalizeGenreEntry(entry) { + if (entry == null) return null; + if (typeof entry === 'string') { + const name = entry.trim(); + return name ? { id: name, name } : null; + } + const id = entry.Id ?? entry.id; + const name = entry.Name ?? entry.name; + if (id != null && name != null) { + return { id: String(id), name: String(name) }; + } + if (name != null) { + const n = String(name); + return { id: n, name: n }; + } + return null; + } + + function normalizeTagEntry(entry) { + if (entry == null) return null; + if (typeof entry === 'string') { + const name = entry.trim(); + return name ? { id: name, name } : null; + } + const name = entry.Name ?? entry.name ?? entry.Id ?? entry.id; + if (name == null) return null; + const n = String(name); + return { id: n, name: n }; + } + + function normalizeFiltersPayload(raw) { + const genres = (Array.isArray(raw?.Genres) ? raw.Genres : []) + .map(normalizeGenreEntry) + .filter(Boolean); + const tags = (Array.isArray(raw?.Tags) ? raw.Tags : []) + .map(normalizeTagEntry) + .filter(Boolean); + return { + Genres: genres, + Tags: tags, + OfficialRatings: Array.isArray(raw?.OfficialRatings) ? raw.OfficialRatings : [], + Years: Array.isArray(raw?.Years) ? raw.Years : [] + }; + } + + function buildFiltersUrl(serverAddress, userId, includeItemTypes) { + const typesParam = buildIncludeItemTypesParam(includeItemTypes); + const useFilters2 = getServerMajorVersion() >= 12; + const path = useFilters2 ? '/Items/Filters2' : '/Items/Filters'; + const params = new URLSearchParams({ UserId: userId }); + if (useFilters2) { + params.set('recursive', 'true'); + } + if (typesParam) { + params.set('IncludeItemTypes', typesParam); + } + return `${serverAddress}${path}?${params.toString()}`; + } + + /** + * API Helper functions for Jellyfin operations + */ + const dataHelper = { + getLibraries: async function() { + ensureApiClient(); + + if (__libraries?.Items) { + return __libraries.Items; + } + + const response = await ApiClient.getItems(); + return response?.Items ?? []; + }, + + /** + * Get filter data (Genres, Tags, OfficialRatings, Years) for specified item types + * @param {string|Array} includeItemTypes - Item types to filter (e.g., 'Movie', ['Movie', 'Episode']) + * @param {boolean} useCache - Whether to use cached data (default: true) + * @param {boolean} forceRefresh - Whether to bypass cache (default: false) + * @returns {Promise} - Object with Genres, Tags, OfficialRatings, Years arrays + */ + getFilters: async function(includeItemTypes, useCache = true, forceRefresh = false) { + ensureApiClient(); + + if (!window.apiHelper) { + throw new Error('apiHelper is not available'); + } + + const userId = ApiClient.getCurrentUserId(); + const serverAddress = ApiClient.serverAddress(); + + if (!userId || !serverAddress) { + throw new Error('User ID or server address not available'); + } + + const cacheKey = normalizeIncludeItemTypesKey(includeItemTypes); + if (forceRefresh) { + filtersMemoryCache.delete(cacheKey); + } + if (useCache && !forceRefresh && filtersMemoryCache.has(cacheKey)) { + return filtersMemoryCache.get(cacheKey); + } + + const url = buildFiltersUrl(serverAddress, userId, includeItemTypes); + + const cacheOptions = { + useCache: useCache && !forceRefresh, + ttl: 24 * 60 * 60 * 1000, + forceRefresh: forceRefresh + }; + + const result = await window.apiHelper.getQuery(url, cacheOptions); + const raw = await unwrapGetQueryResult(result); + const normalized = normalizeFiltersPayload(raw); + filtersMemoryCache.set(cacheKey, normalized); + return normalized; + }, + + getFilterGenreItems: async function(includeItemTypes, options = {}) { + const { useCache = true, forceRefresh = false } = options; + const filters = await this.getFilters(includeItemTypes, useCache, forceRefresh); + return filters.Genres || []; + }, + + getFilterTagItems: async function(includeItemTypes, options = {}) { + const { useCache = true, forceRefresh = false } = options; + const filters = await this.getFilters(includeItemTypes, useCache, forceRefresh); + return filters.Tags || []; + }, + + /** + * Get watched or in progress movies from Jellyfin API + * @returns {Promise} - Array of movies + */ + getWatchedOrInProgressMovies: async function(options = {}) { + + const { watchedMoviesResult, inProgressMoviesResult } = await Promise.all([this.getWatchedMovies(options), this.getInProgressMovies(options)]); + + if (options.useCache) { + const watchedMovies = watchedMoviesResult.data.Items; + const inProgressMovies = inProgressMoviesResult.data.Items; + let isStale = Promise.all([watchedMoviesResult.isStalePromise, inProgressMoviesResult.isStalePromise]).then(([isWatchedStale, isInProgressStale]) => { + return isStale = isWatchedStale || isInProgressStale ? true : false; + }); + + let dataPromise = Promise.all([watchedMoviesResult.dataPromise, inProgressMoviesResult.dataPromise]).then(([watchedMoviesData, inProgressMoviesData]) => { + return { + data: { + Items: [...watchedMoviesData.Items, ...inProgressMoviesData.Items] + } + }; + }); + + return { + data: { + Items: [...watchedMovies, ...inProgressMovies] + }, + isStalePromise: isStalePromise, + dataPromise: dataPromise + }; + } + + // Otherwise, use the fetched data arrays directly + return { + Items: [...watchedMovies.Items, ...inProgressMovies.Items] + }; + }, + + /** + * Get watched movies from Jellyfin API + * @param {Object} options - Options + * @param {boolean} options.useCache - Whether to use cache (default: false) + * @returns {Promise|Array} - Array of movies + */ + getWatchedMovies: async function(options = {}) { + // default TTL is 1 day + const { useCache = false, ttl = 24 * 60 * 60 * 1000 } = options; + + const serverUrl = ApiClient.serverAddress(); + const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsPlayed&Fields=UserData,People,ProviderIds&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending`; + + // Use apiHelper.getQuery to get the data + if (!window.apiHelper) { + ERR('apiHelper is not available'); + return []; + } + + return window.apiHelper.getQuery(url, { useCache: useCache, ttl: ttl }); + }, + + getInProgressMovies: async function(options = {}) { + // default TTL is 1 day + const { useCache = false, ttl = 24 * 60 * 60 * 1000 } = options; + + const serverUrl = ApiClient.serverAddress(); + const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsResumable&Fields=UserData,People,ProviderIds&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending`; + + if (!window.apiHelper) { + ERR('apiHelper is not available'); + return []; + } + + return window.apiHelper.getQuery(url, { useCache: useCache, ttl: ttl }); + }, + + getCachedLibraries: function() { + return __libraries?.Items ? __libraries.Items : null; + } + }; + + async function initialize() { + ensureApiClient(); + + // Wait for login + if (window.userHelper?.waitForLogin) { + await window.userHelper.waitForLogin(); + } + + if (__libraries) { + return; + } + + const libraries = await ApiClient.getItems(); + __libraries = libraries; + } + + let __libraries = null; + + initialize(); + + // Expose dataHelper to global window object + window.dataHelper = dataHelper; + + console.log('[KefinTweaks DataHelper] Module loaded and available at window.dataHelper'); +})(); \ No newline at end of file diff --git a/helpers/websocketHelper.js b/helpers/websocketHelper.js new file mode 100644 index 0000000..32844fb --- /dev/null +++ b/helpers/websocketHelper.js @@ -0,0 +1,265 @@ +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks WebSocketHelper]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks WebSocketHelper]', ...args); + const ERR = (...args) => console.error('[KefinTweaks WebSocketHelper]', ...args); + + // Registry of event listeners: MessageType -> array of callbacks + const listeners = {}; + // messageType -> unsubscribe from ApiClient.subscribe + const subscribeUnsubs = {}; + // messageType -> whether we already attached a subscribe fan-out + const subscribeAttached = {}; + + let hookedSocket = null; + let hookedOriginalOnMessage = null; + let pollingIntervalId = null; + let deliveryMode = null; // 'subscribe' | 'onmessage' | null + + const POLL_INTERVAL_MS = 1500; + + function getApiClient() { + return window.ApiClient || null; + } + + function getSocket() { + const api = getApiClient(); + if (!api) return null; + return api.webSocket || api._webSocket || null; + } + + function canSubscribe() { + const api = getApiClient(); + return !!(api && typeof api.subscribe === 'function'); + } + + function dispatchMessage(data) { + if (!data || typeof data !== 'object') return; + const messageType = data.MessageType; + if (!messageType || !listeners[messageType]) return; + listeners[messageType].forEach((callback) => { + try { + callback(data); + } catch (err) { + ERR(`Error in WebSocket listener for ${messageType}:`, err); + } + }); + } + + function ensureSubscribeForType(messageType) { + if (subscribeAttached[messageType]) return true; + if (!canSubscribe()) return false; + + try { + const api = getApiClient(); + const unsub = api.subscribe([messageType], (msg) => { + // SDK/subscribe may pass the outbound message object directly + const data = msg?.MessageType ? msg : { MessageType: messageType, Data: msg?.Data ?? msg }; + if (!data.MessageType) data.MessageType = messageType; + dispatchMessage(data); + }); + subscribeAttached[messageType] = true; + subscribeUnsubs[messageType] = typeof unsub === 'function' ? unsub : null; + deliveryMode = 'subscribe'; + // Drop any legacy onmessage hook immediately — do not wait for the poll tick + unhookSocket(); + LOG(`Subscribed via ApiClient.subscribe for ${messageType}`); + return true; + } catch (err) { + ERR(`ApiClient.subscribe failed for ${messageType}:`, err); + return false; + } + } + + function ensureSubscribeForAllRegisteredTypes() { + if (!canSubscribe()) return false; + let any = false; + Object.keys(listeners).forEach((messageType) => { + if (listeners[messageType]?.length) { + if (ensureSubscribeForType(messageType)) any = true; + } + }); + return any; + } + + function unhookSocket() { + if (hookedSocket && hookedOriginalOnMessage !== undefined) { + try { + hookedSocket.onmessage = hookedOriginalOnMessage; + } catch (e) { + // ignore + } + } + hookedSocket = null; + hookedOriginalOnMessage = null; + } + + /** + * Legacy path: wrap socket.onmessage, re-bind when the socket instance changes. + * Only used when ApiClient.subscribe is unavailable. + */ + function hookSocketIfNeeded() { + if (canSubscribe()) return false; + + const socket = getSocket(); + if (!socket) return false; + + if (hookedSocket === socket) return true; + + unhookSocket(); + + try { + const originalHandler = socket.onmessage; + hookedOriginalOnMessage = originalHandler; + hookedSocket = socket; + + socket.onmessage = function(event) { + // Keep JF handler isolated so its throws do not look like parse failures + // and do not block KefinTweaks listener delivery. + if (originalHandler) { + try { + originalHandler.call(this, event); + } catch (err) { + WARN('Original WebSocket onmessage handler error:', err); + } + } + + try { + const messageData = event.Data || event.data; + const data = typeof messageData === 'string' ? JSON.parse(messageData) : messageData; + dispatchMessage(data); + } catch (err) { + WARN('WebSocket message parse error:', err); + } + }; + + deliveryMode = 'onmessage'; + LOG('WebSocket onmessage hook attached (legacy fallback)', { + readyState: socket.readyState, + }); + return true; + } catch (err) { + ERR('Error hooking WebSocket onmessage:', err); + hookedSocket = null; + hookedOriginalOnMessage = null; + return false; + } + } + + function tryInitializeDelivery() { + // Prefer JF-web style subscribe (reconnect-safe). Never wrap onmessage when subscribe exists. + if (canSubscribe()) { + return ensureSubscribeForAllRegisteredTypes(); + } + + // Fallback: raw socket onmessage with rebind support + return hookSocketIfNeeded(); + } + + function stopPolling() { + if (pollingIntervalId == null) return; + clearInterval(pollingIntervalId); + pollingIntervalId = null; + } + + function startPolling() { + if (pollingIntervalId != null) return; + if (tryInitializeDelivery()) { + // Still poll in legacy mode so we rebind if socket is replaced + if (deliveryMode === 'subscribe') return; + } + + pollingIntervalId = setInterval(() => { + if (canSubscribe()) { + if (ensureSubscribeForAllRegisteredTypes() && deliveryMode === 'subscribe') { + unhookSocket(); + stopPolling(); + LOG('Delivery mode: ApiClient.subscribe'); + return; + } + // subscribe exists but types not attached yet — keep polling, never hook onmessage + return; + } + + const socket = getSocket(); + if (!socket) return; + + if (hookedSocket !== socket) { + hookSocketIfNeeded(); + } + }, POLL_INTERVAL_MS); + } + + /** + * Register a callback to listen for a specific WebSocket message type + * @param {string} messageType - e.g. 'UserDataChanged', 'LibraryChanged' + * @param {Function} callback - Receives the parsed message object + */ + function listen(messageType, callback) { + if (typeof messageType !== 'string' || !messageType) { + ERR('listen() requires a non-empty string messageType'); + return; + } + + if (typeof callback !== 'function') { + ERR('listen() requires a callback function'); + return; + } + + if (!listeners[messageType]) { + listeners[messageType] = []; + } + + if (listeners[messageType].includes(callback)) { + LOG(`Listener already registered for message type: ${messageType}`); + startPolling(); + return; + } + + listeners[messageType].push(callback); + LOG(`Registered listener for message type: ${messageType} (${listeners[messageType].length} total)`, { + deliveryMode: deliveryMode || 'pending', + canSubscribe: canSubscribe(), + }); + + if (canSubscribe()) { + ensureSubscribeForType(messageType); + } + + startPolling(); + } + + /** + * Remove a callback registered with listen(). + * @param {string} messageType + * @param {Function} callback + */ + function unlisten(messageType, callback) { + if (!listeners[messageType]) return; + listeners[messageType] = listeners[messageType].filter((cb) => cb !== callback); + LOG(`Unregistered listener for message type: ${messageType} (${listeners[messageType].length} remaining)`); + + if (!listeners[messageType].length && subscribeUnsubs[messageType]) { + try { + subscribeUnsubs[messageType](); + } catch (e) { + WARN(`Failed to unsubscribe ${messageType}:`, e); + } + delete subscribeUnsubs[messageType]; + delete subscribeAttached[messageType]; + } + } + + window.websocketHelper = { + listen, + unlisten, + /** @returns {'subscribe'|'onmessage'|null} */ + getDeliveryMode() { + return deliveryMode; + }, + }; + + // Do not startPolling() here — wait until the first listen() so we never + // wrap JF's socket before any consumer needs delivery. +})(); diff --git a/injector.js b/injector.js index b0b16c5..faed3b2 100644 --- a/injector.js +++ b/injector.js @@ -2,28 +2,12 @@ // Dynamically loads scripts and CSS files based on user configuration // Usage: Configuration is now managed in kefinTweaks.js - this script reads from window.KefinTweaksConfig -(function () { +(function() { 'use strict'; console.log('[KefinTweaks Injector] Initializing...'); - function getAuthHeader() { - const token = ApiClient.accessToken(); - const client = typeof ApiClient.applicationName === 'function' ? ApiClient.applicationName() : 'Jellyfin Web'; - const device = typeof ApiClient.deviceName === 'function' ? ApiClient.deviceName() : (navigator.userAgent.includes('Chrome') ? 'Chrome' : 'Browser'); - const deviceId = typeof ApiClient.deviceId === 'function' ? ApiClient.deviceId() : ''; - const version = ApiClient._appVersion || ApiClient._serverVersion || ''; - const parts = [ - `Client="${encodeURIComponent(client)}"`, - `Device="${encodeURIComponent(device)}"`, - `DeviceId="${encodeURIComponent(deviceId)}"`, - `Version="${encodeURIComponent(version)}"`, - `Token="${encodeURIComponent(token)}"` - ]; - return `MediaBrowser ${parts.join(', ')}`; - } - - // Check for global configuration reset + // Check for global configuration reset (admin "Reset All Users Local Settings") try { const config = window.KefinTweaksConfig || {}; const remoteVersion = config.configVersion || 0; @@ -31,20 +15,99 @@ if (remoteVersion > localVersion && remoteVersion > 0) { console.log('[KefinTweaks] Global reset detected. Clearing user configuration.'); - // Only clear the user config to force defaults - // Remove all keys starting with 'kefinTweaks' Object.keys(localStorage) .filter(key => key.startsWith('kefinTweaks')) .forEach(key => localStorage.removeItem(key)); - // Update local version to avoid loop localStorage.setItem('kefinTweaks_lastResetVersion', remoteVersion.toString()); } } catch (e) { console.error('[KefinTweaks] Error checking for global reset:', e); } + // Cache for resolved root URL (to avoid multiple API calls) let resolvedRootCache = null; + // Cached Jellyfin major (10 / 11 / 12 …); null until resolved or if unavailable + let cachedJellyfinMajorVersion = null; + function getMajorServerVersion(version) { + if (!version || typeof version !== 'string') return null; + const versionParts = version.split('.'); + + if (versionParts[0] !== '10') { + const majorVersion = parseInt(versionParts[0], 10); + return Number.isNaN(majorVersion) ? null : majorVersion; + } + + if (versionParts.length >= 2) { + const majorVersion = parseInt(versionParts[1], 10); + if (!Number.isNaN(majorVersion)) { + return majorVersion; + } + } + return null; + } + + /** + * Resolve Jellyfin major version (e.g. 10 for 10.10.x, 11 for 10.11.x, 12 for 10.12.x). + * Mirrors skinManager detection (Jellyfin Web app version, else server version with brief poll). + * @returns {Promise} + */ + async function getCurrentMajorServerVersion() { + try { + if (cachedJellyfinMajorVersion !== null) { + return cachedJellyfinMajorVersion; + } + + // Brief wait — injector init can run before ApiClient version fields exist + if (!window.ApiClient?._appVersion && !window.ApiClient?._serverVersion) { + const startTime = Date.now(); + while (Date.now() - startTime < 5000) { + if (window.ApiClient?._appVersion || window.ApiClient?._serverVersion) { + break; + } + await new Promise(resolve => setTimeout(resolve, 200)); + } + } + + if (!window.ApiClient) { + return null; + } + + if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) { + cachedJellyfinMajorVersion = getMajorServerVersion(window.ApiClient._appVersion); + return cachedJellyfinMajorVersion; + } + + if (!window.ApiClient._serverVersion) { + const startTime = Date.now(); + while (Date.now() - startTime < 10000) { + if (window.ApiClient._serverVersion) { + break; + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + cachedJellyfinMajorVersion = getMajorServerVersion(window.ApiClient._serverVersion); + return cachedJellyfinMajorVersion; + } catch (error) { + console.warn('[KefinTweaks Injector] Error getting server version:', error); + return null; + } + } + + function isScriptCompatible(scriptDef, majorVersion) { + if (!scriptDef?.versions || !scriptDef.versions.length) return true; + if (majorVersion == null) return false; + return scriptDef.versions.includes(majorVersion); + } + + function syncCachedMajorOnApi(majorVersion) { + if (window.KefinTweaks) { + window.KefinTweaks._jellyfinMajorVersion = majorVersion; + } + } + /** * Extracts version string from root URL * @param {string} root - The root URL (e.g., "https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks@v0.3.3/") @@ -54,42 +117,47 @@ if (!root) { return "Pending Install"; // Fallback } - - // Match version patterns: @commitHash, @latest, @main, or @v0.3.3 + + // Match version patterns: @commitHash, @latest, @main, @experimental, or @v0.3.3 // Note: check for hash first to avoid partial numeric matches (e.g. hash starting with digits) - const versionMatch = root.match(/@([a-f0-9]{7,}|latest|main|v?[\d.]+)/i); - + const versionMatch = root.match(/@([a-f0-9]{7,}|latest|main|experimental|v?[\d.]+)/i); + if (versionMatch) { const version = versionMatch[1]; - + // Handle version tags (v0.3.3 -> 0.3.3) if (version.startsWith('v') && /^\d+\.\d+\.\d+/.test(version.substring(1))) { return version.substring(1); // Remove 'v' prefix } - - // Handle @main -> "Dev" + + // Handle @main -> "development" if (version.toLowerCase() === 'main') { return 'development'; } - + + // Handle @experimental -> "experimental" + if (version.toLowerCase() === 'experimental') { + return 'experimental'; + } + // Handle @latest -> "latest" if (version.toLowerCase() === 'latest') { return 'latest'; } - + // Handle commit hash -> show as "Dev (#hash)" if (/^[a-f0-9]{7,}$/i.test(version)) { return `Dev (#${version.substring(0, 7)})`; } - + // Return as-is for other patterns return version; } - + // No version found in URL, this is a custom install return "Custom Install"; } - + // Configuration: Start with defaults, then merge user config // This allows new scripts to work out of the box without requiring config updates const ENABLED_SCRIPTS = { @@ -99,7 +167,7 @@ search: true, // Enhanced search functionality headerTabs: true, // Header tab enhancements customMenuLinks: true, // Custom menu links functionality - exclusiveElsewhere: true, // Exclusive elsewhere branding + hamburgerMenu: false, // Desktop v12 hamburger / left drawer updoot: false, // Upvote functionality backdropLeakFix: true, // Memory leak fixes dashboardButtonFix: true, // Dashboard button fix @@ -110,23 +178,46 @@ itemDetailsCollections: true, // Add related collections to item details pages flattenSingleSeasonShows: true, // Flatten series with only 1 season to show episodes directly seriesInfo: true, // Add series and season information to details pages + versionPreferences: true, // Reorder/sanitize media source versions on details pages collections: true, // Collection sorting functionality skinManager: true, // Skin selection and management - + thumbnailScrubber: false, // Trickplay thumbnail scrub on hover (bottom 20px of video cards) + watchTogether: false, // Sync playstate to Watch Together group accounts on this device + userManager: false, // User templates for new/existing accounts (admin dashboard) + games: true, // Sidebar Games link → pages/games.html hub + // Note: Core functionality scripts (utils, cardBuilder, localStorageCache, modal) // are automatically enabled when needed by other scripts - + // Merge user config on top of defaults (allows users to override defaults) ...window.KefinTweaksConfig?.scripts }; + // Third-party libraries. Relative paths load from {kefinTweaksRoot}/scripts/; http(s) URLs use the CDN loaders. + const THIRD_PARTY_SCRIPTS = { + pickr: { + js: [ + 'thirdparty/pickr/pickr.min.js' + ], + css: [ + 'thirdparty/pickr/pickr-nano.min.css' + ] + }, + chartjs: { + js: [ + 'thirdparty/chartjs/chart.js' + ], + css: [] + } + }; + // Script definitions with dependencies and metadata const SCRIPT_DEFINITIONS = [ { name: 'utils', script: 'utils.js', css: null, - dependencies: ['apiHelper'], + dependencies: [], description: 'Common utilities for page view management and MutationObserver conversion' }, { @@ -143,11 +234,195 @@ dependencies: [], description: 'Default skin configuration for KefinTweaks' }, + { + name: 'ui', + script: 'ui.js', + css: null, + dependencies: [], + description: 'UI helper functions for KefinTweaks' + }, + { + name: 'homeScreenConfig', + script: 'homeScreenConfig.js', + css: null, + dependencies: [], + description: 'Default home screen configuration for KefinTweaks' + }, + { + name: 'homeScreenConfig2', + script: 'homeScreenConfig2.js', + css: null, + dependencies: [], + description: 'Default home screen configuration for KefinTweaks' + }, + { + name: 'homeScreen-migration', + script: '../configuration/homeScreen-migration.js', + css: null, + dependencies: [], + description: 'Home screen migration for KefinTweaks' + }, + { + name: 'homeScreen-user-configuration', + script: '../configuration/homeScreen-user-configuration.js', + css: null, + dependencies: ['ui', 'utils'], + description: 'User home screen section preferences and bulk order editor' + }, + { + name: 'homeScreenSectionConfigure', + script: 'homeScreenSectionConfigure.js', + css: null, + dependencies: ['homeScreen-user-configuration', 'modal', 'ui', 'cardBuilder'], + thirdParty: ['pickr'], + description: 'Inline configure popover for user home screen sections' + }, + { + name: 'homeScreenPin', + script: 'homeScreenPin.js', + css: null, + dependencies: ['homeScreen-user-configuration', 'modal', 'utils'], + description: 'Pin items to the home screen from context menus' + }, + { + name: 'homeScreenCustomItemsEditor', + script: '../configuration/homeScreenCustomItemsEditor.js', + css: null, + dependencies: [], + description: 'Shared custom items editor (wizard + advanced)' + }, + { + name: 'homeScreenSectionEditor', + script: '../configuration/homeScreenSectionEditor.js', + css: '../configuration/homeScreenSectionEditor.css', + dependencies: ['modal', 'toaster', 'utils', 'cardBuilder', 'ui', 'homeScreenCustomItemsEditor', 'homeScreenAdvancedEditor'], + description: 'Home screen section editor (wizard + advanced)' + }, + { + name: 'homeScreenEditorProfiles', + script: '../configuration/homeScreenEditorProfiles.js', + css: null, + dependencies: [], + description: 'Editor profile registry for home screen section editor' + }, + { + name: 'homeScreenAdvancedEditor', + script: '../configuration/homeScreenAdvancedEditor.js', + css: '../configuration/homeScreenAdvancedEditor.css', + dependencies: ['ui', 'homeScreenCustomItemsEditor', 'homeScreenEditorProfiles'], + thirdParty: ['pickr'], + description: 'Advanced section editor UI for custom sections' + }, + { + name: 'homescreenBenchmark', + script: '../configuration/homescreenBenchmark.js', + css: null, + dependencies: ['apiHelper'], + description: 'Home screen section query benchmarks for admin config' + }, + { + name: 'homeScreen-configuration', + script: '../configuration/homeScreen-configuration.js', + css: '../configuration/homeScreen-configuration.css', + dependencies: ['modal', 'toaster', 'utils', 'cardBuilder', 'ui', 'homeScreenCustomItemsEditor', 'homeScreenSectionEditor', 'homescreenBenchmark', 'sectionHelper'], + description: 'Home screen configuration for KefinTweaks' + }, + { + name: 'search-configuration', + script: '../configuration/search-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Search configuration for KefinTweaks' + }, + { + name: 'seriesEpisodes-configuration', + script: '../configuration/seriesEpisodes-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Series episodes configuration for KefinTweaks' + }, + { + name: 'seriesInfo-configuration', + script: '../configuration/seriesInfo-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Series info configuration for KefinTweaks' + }, + { + name: 'versionPreferences-configuration', + script: '../configuration/versionPreferences-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Item version preferences configuration for KefinTweaks' + }, + { + name: 'skinManager-configuration', + script: '../configuration/skinManager-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Skin manager configuration for KefinTweaks' + }, + { + name: 'customMenuLinks-configuration', + script: '../configuration/customMenuLinks-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Custom menu links configuration for KefinTweaks' + }, + { + name: 'homeScreenConfigCommunity', + script: 'homeScreenConfig-community.js', + css: null, + dependencies: [], + description: 'Community home screen sections for KefinTweaks' + }, + { + name: 'peopleCache', + script: 'peopleCache.js', + css: null, + dependencies: ['libraryCacheUtils', 'userHelper', 'moviesCache', 'seriesCache', 'indexedDBCache', 'apiHelper'], + description: 'People cache for KefinTweaks' + }, + { + name: 'studiosCache', + script: 'studiosCache.js', + css: null, + dependencies: [], + description: 'Studios cache for KefinTweaks' + }, + { + name: 'libraryCacheUtils', + script: 'libraryCacheUtils.js', + css: null, + dependencies: [], + description: 'Shared strip/provider helpers for library caches' + }, + { + name: 'moviesCache', + script: 'moviesCache.js', + css: null, + dependencies: ['libraryCacheUtils', 'userHelper', 'indexedDBCache', 'apiHelper'], + description: 'Movies library cache for KefinTweaks' + }, + { + name: 'seriesCache', + script: 'seriesCache.js', + css: null, + dependencies: ['libraryCacheUtils', 'userHelper', 'indexedDBCache', 'apiHelper'], + description: 'Series library cache for KefinTweaks' + }, + { + name: 'libraryCache', + script: 'libraryCache.js', + css: null, + dependencies: ['moviesCache', 'seriesCache'], + description: 'Library cache facade for KefinTweaks' + }, { name: 'skinManager', script: 'skinManager.js', css: 'defaultSkin.css', - dependencies: ['utils', 'skinConfigLegacyDefaults', 'skinConfig', 'modal', 'apiHelper'], + dependencies: ['utils', 'skinConfigLegacyDefaults', 'skinConfig', 'modal'], priority: true, // Load immediately after dependencies to reduce UI disruption description: 'Adds skin selection dropdown to display preferences page and manages skin CSS loading' }, @@ -155,14 +430,21 @@ name: 'apiHelper', script: 'apiHelper.js', css: null, - dependencies: [], + dependencies: ['dataHelper'], description: 'API helper functions for common Jellyfin operations' }, + { + name: 'sectionHelper', + script: 'sectionHelper.js', + css: null, + dependencies: ['apiHelper', 'cardBuilder', 'peopleCache', 'studiosCache', 'moviesCache', 'seriesCache', 'libraryCache', 'homeScreenConfig2', 'dataHelper'], + description: 'Section loading and progressive discovery resolve helpers' + }, { name: 'cardBuilder', script: 'cardBuilder.js', css: 'cardBuilder.css', - dependencies: ['apiHelper'], + dependencies: ['apiHelper', 'userHelper', 'websocketHelper'], description: 'Core card building functionality (required by other scripts)' }, { @@ -186,6 +468,55 @@ dependencies: [], description: 'Generic modal system for Jellyfin-style dialogs' }, + { + name: 'userHelper', + script: 'userHelper.js', + css: null, + dependencies: [], + description: 'User helper functions for Jellyfin operations' + }, + { + name: 'dataHelper', + script: '../helpers/dataHelper.js', + css: null, + dependencies: [], + description: 'Data helper functions for Jellyfin operations' + }, + { + name: 'websocketHelper', + script: '../helpers/websocketHelper.js', + css: null, + dependencies: [], + description: 'WebSocket helper functions for Jellyfin operations' + }, + { + name: 'watchTogether-configuration', + script: '../configuration/watchTogether-configuration.js', + css: null, + dependencies: ['modal', 'ui'], + description: 'Configuration UI for Watch Together' + }, + { + name: 'watchTogether', + script: 'watchTogether.js', + css: null, + dependencies: ['utils', 'modal', 'apiHelper', 'userHelper', 'websocketHelper', 'ui', 'watchTogether-configuration'], + description: 'Watch Together: sync watched/in-progress state to selected group accounts' + }, + { + name: 'userManager-configuration', + script: '../configuration/userManager-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils', 'apiHelper', 'ui'], + description: 'Configuration UI for User Manager templates' + }, + { + name: 'userManager', + script: 'userManager.js', + css: 'userManager.css', + dependencies: ['utils', 'modal', 'apiHelper', 'userHelper', 'toaster', 'userManager-configuration'], + description: 'User Manager: apply policy templates on new user creation and bulk-edit existing users' + }, { name: 'toaster', script: 'toaster.js', @@ -193,18 +524,34 @@ dependencies: [], description: 'Toast notification system using Jellyfin\'s existing toast functionality' }, + { + name: 'statistics', + script: 'statistics.js', + css: null, + dependencies: [], + description: 'Watchlist statistics charts and activity analytics' + }, + { + name: 'watchlist-configuration', + script: '../configuration/watchlist-configuration.js', + css: null, + dependencies: ['modal', 'ui'], + description: 'Configuration UI for Watchlist menu placements' + }, { name: 'watchlist', script: 'watchlist.js', css: 'watchlist.css', - dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils'], + dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils', 'statistics', 'watchlist-configuration', 'websocketHelper'], + thirdParty: ['chartjs'], description: 'Adds watchlist functionality throughout Jellyfin interface' }, { name: 'homeScreen', - script: 'homeScreen.js', + script: 'homeScreen3.js', css: 'homeScreen.css', - dependencies: ['cardBuilder', 'localStorageCache', 'utils'], + dependencies: ['cardBuilder', 'localStorageCache', 'utils', 'userHelper', 'homeScreenConfig2', 'homeScreen-configuration', 'peopleCache', 'studiosCache', 'moviesCache', 'seriesCache', 'libraryCache', 'libraryCacheUtils', 'indexedDBCache', 'homeScreenConfigCommunity', 'dataHelper', 'apiHelper', 'sectionHelper', 'homeScreen-migration', 'homeScreen-user-configuration', 'homeScreenSectionConfigure', 'homeScreenPin'], + priority: true, // Load immediately after dependencies to reduce UI disruption description: 'Adds custom home screen sections' }, { @@ -218,7 +565,7 @@ name: 'headerTabs', script: 'headerTabs.js', css: null, - dependencies: ['apiHelper'], + dependencies: [], description: 'Header tab improvements' }, { @@ -229,24 +576,33 @@ description: 'Load and add custom menu links from configuration' }, { - name: 'exclusiveElsewhere', - script: 'exclusiveElsewhere.js', + name: 'hamburgerMenu', + script: 'hamburgerMenu.js', css: null, - dependencies: [], - description: 'Modifies the behavior of the Jellyfin Enhanced Elsewhere functionality to add custom branding when items are not available on streaming services' + dependencies: ['utils'], + versions: [12], + description: 'Desktop hamburger button that opens a v12-style left navigation drawer' + }, + { + name: 'games', + script: 'games.js', + css: null, + dependencies: ['utils'], + description: 'Adds a Games sidebar link to the games hub page' }, { name: 'backdropLeakFix', script: 'backdropLeakFix.js', css: null, dependencies: [], + versions: [10, 11], description: 'Fixes issue that causes backdrop images to be continuously added to the page if the tab isn\'t focused.' }, { name: 'updoot', script: 'updoot.js', css: null, - dependencies: ['apiHelper'], + dependencies: [], description: 'Upvote functionality provided by https://github.com/BobHasNoSoul/jellyfin-updoot' }, { @@ -254,8 +610,23 @@ script: 'dashboardButtonFix.js', css: null, dependencies: [], + versions: [10, 11], description: 'Fixes the dashboard button to redirect to the home page when the back button is clicked and there is no history to go back to' }, + { + name: 'thumbnailScrubber-configuration', + script: '../configuration/thumbnailScrubber-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Thumbnail scrubber configuration for KefinTweaks' + }, + { + name: 'thumbnailScrubber', + script: 'thumbnailScrubber.js', + css: null, + dependencies: ['thumbnailScrubber-configuration'], + description: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card for 2+ seconds' + }, { name: 'infiniteScroll', script: 'infiniteScroll.js', @@ -267,14 +638,14 @@ name: 'removeContinue', script: 'removeContinue.js', css: null, - dependencies: ['apiHelper'], + dependencies: [], description: 'Adds remove from continue watching functionality to cards with data-position-ticks' }, { name: 'subtitleSearch', script: 'subtitleSearch.js', css: 'subtitleSearch.css', - dependencies: ['toaster', 'apiHelper'], + dependencies: ['toaster'], description: 'Adds subtitle search functionality to the video OSD, allowing users to search and download subtitles from remote sources' }, { @@ -296,6 +667,7 @@ script: 'itemDetailsCollections.js', css: null, dependencies: ['indexedDBCache', 'utils', 'cardBuilder'], + versions: [10, 11], description: 'Adds related collections to item details pages showing which collections contain the current item' }, { @@ -312,6 +684,13 @@ dependencies: ['utils'], description: 'Adds series and season information (seasons count, episodes count, end time) to details pages' }, + { + name: 'versionPreferences', + script: 'versionPreferences.js', + css: null, + dependencies: ['utils'], + description: 'Reorders and sanitizes media source version/edition options on item details pages' + }, { name: 'collections', script: 'collections.js', @@ -326,35 +705,44 @@ //const urlSuffix = `?v=${new Date().getTime()}`; // Inject dashboard drawer version badge CSS using dynamic version number + function isFloatingKefinRootUrl(root) { + return /@(latest|main|master|experimental)(\/|$)/i.test(String(root || '')); + } + async function injectVersionBadgeCSS() { const styleId = 'kefinTweaks-version-css'; if (document.getElementById(styleId)) return; - - // Get version from root URL in config + + // Prefer pinned resolved root; GitHub resolve only for admins on floating symbolic roots let displayVersion = ""; try { - const configRoot = window.KefinTweaksConfig?.kefinTweaksRoot || ''; + const cfg = window.KefinTweaksConfig || {}; + let configRoot = cfg.kefinTweaksRootResolved || cfg.kefinTweaksRoot || ''; if (configRoot) { - // Resolve the root to get the actual version (if @latest or @main) - const resolvedRoot = await resolveRootVersion(configRoot); - displayVersion = extractVersionFromRoot(resolvedRoot); + if (isFloatingKefinRootUrl(configRoot)) { + const isAdmin = await checkAdminWithTimeout(2000); + if (isAdmin) { + configRoot = await resolveRootVersion(configRoot); + } + } + displayVersion = extractVersionFromRoot(configRoot); } } catch (error) { console.warn('[KefinTweaks Injector] Could not extract version, using fallback:', error); } - + // Format version for display (add 'v' prefix if it's a version number, not for 'latest' or 'development') let versionDisplay = displayVersion; - if (!displayVersion.startsWith('v') && - !displayVersion.includes('latest') && + if (!displayVersion.startsWith('v') && + !displayVersion.includes('latest') && !displayVersion.includes('development') && /^\d+\.\d+/.test(displayVersion)) { versionDisplay = 'v' + displayVersion; } - + const style = document.createElement('style'); style.id = styleId; - style.textContent = ` +/* style.textContent = ` body:not(:has(.libraryPage:not(.hide))) .MuiPaper-root.MuiDrawer-paperAnchorLeft::after { content: 'KefinTweaks ${versionDisplay}'; font-style: italic; @@ -365,7 +753,7 @@ display: block; } `; - document.head.appendChild(style); + document.head.appendChild(style); */ } // Auto-enable dependencies for enabled scripts (iteratively to handle transitive dependencies) @@ -373,11 +761,11 @@ let hasChanges = false; let maxIterations = 10; // Prevent infinite loops let iterations = 0; - + // 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 => { @@ -390,31 +778,31 @@ }); } }); - + 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; } - + // Configuration validation function validateConfiguration() { const errors = []; - + // Auto-enable dependencies first const dependenciesEnabled = autoEnableDependencies(); if (dependenciesEnabled) { console.log('[KefinTweaks Injector] Auto-enabled required dependencies'); } - + // Check dependencies - only validate if the script is enabled SCRIPT_DEFINITIONS.forEach(script => { if (ENABLED_SCRIPTS[script.name]) { @@ -425,21 +813,22 @@ }); } }); - + if (errors.length > 0) { console.error('[KefinTweaks Injector] Configuration errors:', errors); return false; } - + return true; } - - // Resolve @latest or @main to actual version/commit hash + + // Resolve @latest, @main, or @experimental to actual version/commit hash (so user always loads latest from that ref) async function resolveRootVersion(root) { - // Check if root contains @latest or @main + // Check if root contains @latest, @main, or @experimental const latestMatch = root.match(/@latest(\/|$)/); const mainMatch = root.match(/@main(\/|$)/); - + const experimentalMatch = root.match(/@experimental(\/|$)/); + if (latestMatch) { // Fetch the latest release version number try { @@ -481,37 +870,64 @@ console.warn('[KefinTweaks Injector] Error fetching commit hash:', error); return root; } + } else if (experimentalMatch) { + // Fetch the latest commit hash from the experimental branch + try { + const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/commits/experimental'); + if (response.ok) { + const commit = await response.json(); + if (commit && commit.sha) { + return root.replace('@experimental', `@${commit.sha}`); + } else { + console.warn('[KefinTweaks Injector] Could not fetch experimental commit hash, using @experimental'); + return root; + } + } else { + console.warn('[KefinTweaks Injector] Failed to fetch experimental commit hash, using @experimental'); + return root; + } + } catch (error) { + console.warn('[KefinTweaks Injector] Error fetching experimental commit hash:', error); + return root; + } } - + // No resolution needed return root; } - + // Get the root path for scripts from configuration + // Prefer kefinTweaksRootResolved; GitHub floating resolve only for admins. async function getScriptRoot() { - const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot; - - // If kefinTweaksRoot is not set or empty, return null (scripts should not load) + const cfg = window.KefinTweaksConfig || {}; + const kefinTweaksRoot = cfg.kefinTweaksRootResolved || cfg.kefinTweaksRoot; + + // If root is not set or empty, return null (scripts should not load) if (!kefinTweaksRoot || kefinTweaksRoot === '') { console.warn('[KefinTweaks Injector] kefinTweaksRoot is not configured'); return null; } - + // Ensure root ends with / let root = kefinTweaksRoot.endsWith('/') ? kefinTweaksRoot : kefinTweaksRoot + '/'; - - // Resolve @latest or @main if needed (with caching) + + // Resolve floating refs only when still floating and user is admin (with caching) if (resolvedRootCache === null) { - root = await resolveRootVersion(root); + if (isFloatingKefinRootUrl(root)) { + const isAdmin = await checkAdminWithTimeout(2000); + if (isAdmin) { + root = await resolveRootVersion(root); + } + } resolvedRootCache = root; } else { root = resolvedRootCache; } - + // Return scripts path return root + 'scripts/'; } - + // Load a CSS file async function loadCSS(filename) { return new Promise(async (resolve, reject) => { @@ -520,7 +936,7 @@ reject(new Error('kefinTweaksRoot is not configured')); return; } - + // Check if CSS is already loaded (match by root and filename, ignore suffix) const existingLink = document.querySelector(`link[href*="${scriptRoot}${filename}"]`); if (existingLink) { @@ -528,26 +944,26 @@ resolve(); return; } - + const link = document.createElement('link'); link.rel = 'stylesheet'; link.type = 'text/css'; link.href = `${scriptRoot}${filename}${urlSuffix}`; - + link.onload = () => { console.log(`[KefinTweaks Injector] CSS loaded: ${filename}`); resolve(); }; - + link.onerror = () => { console.warn(`[KefinTweaks Injector] Failed to load CSS: ${filename}`); reject(new Error(`Failed to load CSS: ${filename}`)); }; - + document.head.appendChild(link); }); } - + // Load a JavaScript file async function loadScript(filename) { return new Promise(async (resolve, reject) => { @@ -556,7 +972,7 @@ reject(new Error('kefinTweaksRoot is not configured')); return; } - + // Check if script is already loaded const existingScript = document.querySelector(`script[src*="${scriptRoot}${filename}"]`); if (existingScript) { @@ -564,25 +980,137 @@ resolve(); return; } - + const script = document.createElement('script'); script.src = `${scriptRoot}${filename}${urlSuffix}`; script.async = true; - + script.onload = () => { console.log(`[KefinTweaks Injector] Script loaded: ${filename}`); resolve(); }; - + script.onerror = () => { console.error(`[KefinTweaks Injector] Failed to load script: ${filename}`); reject(new Error(`Failed to load script: ${filename}`)); }; + + document.head.appendChild(script); + }); + } + + /** + * Load an absolute-URL stylesheet (CDN / third-party). Dedupes via DOM. + * @param {string} url + */ + async function loadExternalCSS(url) { + return new Promise((resolve, reject) => { + if (!url) { + resolve(); + return; + } + const existingLink = document.querySelector(`link[href="${url}"]`); + if (existingLink) { + console.log(`[KefinTweaks Injector] External CSS already loaded: ${url}`); + resolve(); + return; + } + + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = url; + + link.onload = () => { + console.log(`[KefinTweaks Injector] External CSS loaded: ${url}`); + resolve(); + }; + link.onerror = () => { + console.warn(`[KefinTweaks Injector] Failed to load external CSS: ${url}`); + reject(new Error(`Failed to load external CSS: ${url}`)); + }; + + document.head.appendChild(link); + }); + } + + /** + * Load an absolute-URL script (CDN / third-party). Dedupes via DOM. + * @param {string} url + */ + async function loadExternalScript(url) { + return new Promise((resolve, reject) => { + if (!url) { + resolve(); + return; + } + const existingScript = document.querySelector(`script[src="${url}"]`); + if (existingScript) { + console.log(`[KefinTweaks Injector] External script already loaded: ${url}`); + resolve(); + return; + } + + const script = document.createElement('script'); + script.src = url; + script.async = true; + + script.onload = () => { + console.log(`[KefinTweaks Injector] External script loaded: ${url}`); + resolve(); + }; + script.onerror = () => { + console.error(`[KefinTweaks Injector] Failed to load external script: ${url}`); + reject(new Error(`Failed to load external script: ${url}`)); + }; document.head.appendChild(script); }); } + function isAbsoluteUrl(url) { + return /^https?:\/\//i.test(url); + } + + /** + * Load a named third-party library from THIRD_PARTY_SCRIPTS (css then js). + * Relative paths load from the script root; http(s) URLs use the external loaders. + * @param {string} name + */ + async function loadThirdParty(name) { + const def = THIRD_PARTY_SCRIPTS[name]; + if (!def) { + console.warn(`[KefinTweaks Injector] Unknown thirdParty '${name}' — skipping`); + return; + } + + const cssUrls = Array.isArray(def.css) ? def.css : []; + const jsUrls = Array.isArray(def.js) ? def.js : []; + + for (const url of cssUrls) { + try { + if (isAbsoluteUrl(url)) { + await loadExternalCSS(url); + } else { + await loadCSS(url); + } + } catch (error) { + console.warn(`[KefinTweaks Injector] Third-party CSS failed for '${name}':`, error); + } + } + for (const url of jsUrls) { + try { + if (isAbsoluteUrl(url)) { + await loadExternalScript(url); + } else { + await loadScript(url); + } + } catch (error) { + console.warn(`[KefinTweaks Injector] Third-party script failed for '${name}':`, error); + } + } + } + // Recursively collect all dependencies for a given script function collectAllDependencies(scriptDef, collected = new Set(), visited = new Set()) { // Avoid infinite recursion @@ -590,19 +1118,19 @@ return collected; } visited.add(scriptDef.name); - + // For each dependency for (const depName of scriptDef.dependencies) { // Only process enabled dependencies if (!ENABLED_SCRIPTS[depName]) { continue; } - + const depScript = SCRIPT_DEFINITIONS.find(s => s.name === depName); if (!depScript) { continue; } - + // Add to collected if we haven't already if (!collected.has(depScript.name)) { collected.add(depScript.name); @@ -610,10 +1138,10 @@ collectAllDependencies(depScript, collected, visited); } } - + return collected; } - + // Load a single script (assumes dependencies are already loaded) async function loadScriptSync(scriptDef) { const scriptRoot = await getScriptRoot(); @@ -621,26 +1149,56 @@ throw new Error('kefinTweaksRoot is not configured'); } + for (const thirdPartyName of scriptDef.thirdParty || []) { + await loadThirdParty(thirdPartyName); + } + // Check if already loaded (match by root and filename, ignore suffix) const isAlreadyLoaded = document.querySelector(`script[src*="${scriptRoot}${scriptDef.script}"]`); if (isAlreadyLoaded) { console.log(`[KefinTweaks Injector] Script already loaded: ${scriptDef.name}`); return; } - + // Load CSS if specified if (scriptDef.css) { await loadCSS(scriptDef.css); } - + // Load the script await loadScript(scriptDef.script); - + console.log(`[KefinTweaks Injector] Successfully loaded: ${scriptDef.name}`); } + + async function ensureKefinTweaksLoader(scriptRoot) { + if (window.KefinTweaksLoader) return window.KefinTweaksLoader; + const base = scriptRoot.endsWith('/') ? scriptRoot : scriptRoot + '/'; + const url = base + 'kefinTweaks-loader.js'; + await new Promise((resolve, reject) => { + if (document.querySelector('script[src="' + url + '"]')) { + const start = Date.now(); + const wait = () => { + if (window.KefinTweaksLoader) return resolve(); + if (Date.now() - start > 15000) return reject(new Error('Timed out waiting for KefinTweaksLoader')); + setTimeout(wait, 50); + }; + return wait(); + } + const script = document.createElement('script'); + script.src = url; + script.async = false; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load kefinTweaks-loader.js')); + document.head.appendChild(script); + }); + if (!window.KefinTweaksLoader) throw new Error('KefinTweaksLoader not available'); + return window.KefinTweaksLoader; + } + async function loadConfigurationJS() { - const configDependencyNames = ['modal', 'toaster', 'apiHelper', 'utils', 'indexedDBCache']; + const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreenConfig2', 'ui', 'homeScreen-migration', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'versionPreferences-configuration', 'skinManager-configuration', 'customMenuLinks-configuration', 'thumbnailScrubber-configuration', 'watchTogether-configuration', 'watchlist-configuration', 'userManager-configuration', 'apiHelper', 'sectionHelper']; for (const depName of configDependencyNames) { const depScript = SCRIPT_DEFINITIONS.find(script => script.name === depName); if (depScript) { @@ -651,15 +1209,20 @@ } } } - + // Load configuration UI so admins can re-enable KefinTweaks await loadScript('configuration.js'); await loadCSS('configuration.css'); } - - // Main initialization function + + // Main initialization: live load plan (fills gaps vs baked preload) + stamp sync async function initialize() { - console.log(`[KefinTweaks Injector] Starting KefinTweaks initialization...`); + console.log('[KefinTweaks Injector] Starting KefinTweaks initialization...'); + + const bakeAlreadyAttached = !!( + window.KefinTweaksScriptsPreloaded + || window.KefinTweaksInjectorAttached + ); const scriptRoot = await getScriptRoot(); if (!scriptRoot) { @@ -667,150 +1230,102 @@ return; } - // Check if KefinTweaks is enabled in config - const isKefinTweaksEnabled = window.KefinTweaksConfig?.enabled !== false; - - if (!isKefinTweaksEnabled) { - console.log('[KefinTweaks Injector] KefinTweaks is disabled in configuration. Only loading configuration UI requirements for admin access.'); - - try { - // Ensure configuration dependencies are loaded so the admin UI works - await loadConfigurationJS(); - console.log('[KefinTweaks Injector] Configuration script loaded (KefinTweaks is disabled)'); - } catch (error) { - console.warn('[KefinTweaks Injector] Failed to load configuration UI while disabled:', error); - } + let Loader; + try { + Loader = await ensureKefinTweaksLoader(scriptRoot); + } catch (err) { + console.error('[KefinTweaks Injector] Failed to load kefinTweaks-loader.js:', err); return; } - // Validate configuration - if (!validateConfiguration()) { - console.error('[KefinTweaks Injector] Configuration validation failed. Aborting.'); - return; - } + Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS); + const majorVersion = await window.KefinTweaks.getJellyfinMajorVersion(); + console.log('[KefinTweaks Injector] Jellyfin major version:', majorVersion ?? 'unknown'); - // Get enabled scripts - const enabledScripts = SCRIPT_DEFINITIONS.filter(script => ENABLED_SCRIPTS[script.name]); - - // Step 1: Collect all dependencies from all enabled scripts - const allDependencyNames = new Set(); - for (const script of enabledScripts) { - const deps = collectAllDependencies(script); - deps.forEach(dep => allDependencyNames.add(dep)); - } - - // Step 2: Separate dependencies from non-dependencies - const dependencyScripts = SCRIPT_DEFINITIONS.filter(script => - allDependencyNames.has(script.name) && ENABLED_SCRIPTS[script.name] - ); - - const nonDependencyScripts = enabledScripts.filter(script => - !allDependencyNames.has(script.name) - ); - - // Step 2.5: Separate priority scripts from regular non-dependency scripts - const priorityScripts = nonDependencyScripts.filter(script => script.priority === true); - const regularScripts = nonDependencyScripts.filter(script => !script.priority); - - console.log(`[KefinTweaks Injector] Found ${dependencyScripts.length} dependency scripts:`, - dependencyScripts.map(s => s.name)); - console.log(`[KefinTweaks Injector] Found ${priorityScripts.length} priority scripts:`, - priorityScripts.map(s => s.name)); - console.log(`[KefinTweaks Injector] Found ${regularScripts.length} regular scripts:`, - regularScripts.map(s => s.name)); + const config = window.KefinTweaksConfig || {}; + const root = Loader.getResolvedKefinRoot + ? Loader.getResolvedKefinRoot(config) + : Loader.normalizeRoot(config.kefinTweaksRootResolved || config.kefinTweaksRoot || ''); try { - // Step 3: Load all dependencies first - console.log('[KefinTweaks Injector] Loading dependencies first...'); - console.log(`[KefinTweaks Injector] Dependency load order:`, dependencyScripts.map(s => s.name)); - - let dependencyLoadPromises = []; - dependencyLoadPromises.push(...dependencyScripts.map(script => loadScriptSync(script))); - await Promise.all(dependencyLoadPromises); - - // Step 4: Load priority scripts immediately after dependencies - if (priorityScripts.length > 0) { - console.log('[KefinTweaks Injector] Loading priority scripts...'); - console.log(`[KefinTweaks Injector] Priority load order:`, priorityScripts.map(s => s.name)); - let priorityLoadPromises = []; - priorityLoadPromises.push(...priorityScripts.map(script => loadScriptSync(script))); - await Promise.all(priorityLoadPromises); - } - - // Step 5: Load regular non-dependencies in parallel (their dependencies are already loaded) - let loadPromises = []; - console.log('[KefinTweaks Injector] Loading regular non-dependencies...'); - loadPromises.push(...regularScripts.map(script => loadScriptSync(script))); - await Promise.all(loadPromises); - - console.log('[KefinTweaks Injector] All scripts loaded successfully!'); - - // Always load configuration.js for admin UI (loads after other scripts) - console.log('[KefinTweaks Injector] Loading configuration script...'); - try { - await loadConfigurationJS(); - console.log('[KefinTweaks Injector] Configuration script loaded successfully'); - } catch (error) { - console.warn('[KefinTweaks Injector] Failed to load configuration script:', error); - } - - // Dispatch custom event when all scripts are loaded - const event = new CustomEvent('kefinTweaksLoaded', { - detail: { - loadedScripts: enabledScripts.map(s => s.name), - timestamp: new Date().toISOString() - } + const plan = Loader.buildLoadPlan(config, majorVersion, { + root, + urlSuffix, + configOnly: config.enabled === false }); - document.dispatchEvent(event); + if (bakeAlreadyAttached) { + // Belt-and-suspenders: never dual-inject when bake already ran. + // Tip re-pin / bake rewrite is owned by kefinTweaks-plugin tip-sync path. + console.log('[KefinTweaks Injector] Bake already attached; skipping applyAssetsToDocument (stamp=', plan.stamp, ')'); + return; + } + + console.log('[KefinTweaks Injector] Applying ordered assets:', plan.assets.length, 'stamp=', plan.stamp); + Loader.applyAssetsToDocument(plan.assets); + document.dispatchEvent(new CustomEvent('kefinTweaksLoaded', { + detail: { + loadedScripts: plan.scriptNames, + stamp: plan.stamp, + timestamp: new Date().toISOString(), + live: true + } + })); + console.log('[KefinTweaks Injector] Live ordered inject complete'); + + if (typeof Loader.syncKefinTweaksInjector === 'function') { + const syncResult = await Loader.syncKefinTweaksInjector(plan); + if (syncResult?.synced) { + console.log('[KefinTweaks Injector] Baked KefinTweaks-injector synced to stamp:', syncResult.stamp); + } else if (syncResult?.reason && syncResult.reason !== 'stamp match' && syncResult.reason !== 'not admin' && syncResult.reason !== 'not logged in') { + console.warn('[KefinTweaks Injector] Injector sync skipped:', syncResult.reason); + } else { + console.log('[KefinTweaks Injector] Injector sync:', syncResult?.reason || 'done'); + } + } } catch (error) { console.error('[KefinTweaks Injector] Error during initialization:', error); } } + // Utility functions for debugging and configuration - window.KefinTweaks = { - // Get current configuration - getConfig: () => ({ ...ENABLED_SCRIPTS }), - - // Get script definitions - getScripts: () => [...SCRIPT_DEFINITIONS], - - // Check if a script is loaded - isScriptLoaded: (scriptName) => { - const scriptDef = SCRIPT_DEFINITIONS.find(s => s.name === scriptName); - if (!scriptDef) return false; - - const scriptElement = document.querySelector(`script[src*="${scriptDef.script}"]`); - return !!scriptElement; - }, - - // Reload all scripts (useful for development) - reload: () => { - console.log('[KefinTweaks Injector] Reloading all scripts...'); - initialize(); - }, - - // Load a specific script (useful for dynamic loading) - loadScript: async (scriptName) => { - const scriptDef = SCRIPT_DEFINITIONS.find(s => s.name === scriptName); - if (!scriptDef) { - throw new Error(`Script '${scriptName}' not found`); + // Populated in initialize via KefinTweaksLoader.ensureKefinTweaksApi; stub for early callers + window.KefinTweaks = window.KefinTweaks || { + _jellyfinMajorVersion: null, + parseMajorVersion: function (version) { + if (!version || typeof version !== 'string') return null; + const versionParts = version.split('.'); + if (versionParts[0] !== '10') { + const majorVersion = parseInt(versionParts[0], 10); + return Number.isNaN(majorVersion) ? null : majorVersion; } - - if (!ENABLED_SCRIPTS[scriptName]) { - throw new Error(`Script '${scriptName}' is disabled in configuration`); + if (versionParts.length >= 2) { + const majorVersion = parseInt(versionParts[1], 10); + if (!Number.isNaN(majorVersion)) return majorVersion; } - - await loadScriptWithDependencies(scriptDef); - } + return null; + }, + getJellyfinMajorVersion: async function () { + if (window.KefinTweaksLoader) { + window.KefinTweaksLoader.ensureKefinTweaksApi(); + return window.KefinTweaks.getJellyfinMajorVersion(); + } + return this._jellyfinMajorVersion; + }, + getScripts: function () { + return (window.KefinTweaksLoader && window.KefinTweaksLoader.SCRIPT_DEFINITIONS) + ? window.KefinTweaksLoader.SCRIPT_DEFINITIONS.slice() + : (typeof SCRIPT_DEFINITIONS !== 'undefined' ? SCRIPT_DEFINITIONS.slice() : []); + }, + reload: function () { initialize(); } }; // Check if user is admin (with timeout for login) async function checkAdminWithTimeout(maxWaitMs = 5000) { const startTime = Date.now(); const checkInterval = 100; // Check every 100ms - + return new Promise((resolve) => { const checkAdmin = async () => { try { @@ -831,18 +1346,18 @@ } catch (error) { // User might not be logged in yet, continue waiting } - + // Check if we've exceeded max wait time if (Date.now() - startTime >= maxWaitMs) { console.log('[KefinTweaks Startup] Timeout waiting for user login'); resolve(false); return; } - + // Continue checking setTimeout(checkAdmin, checkInterval); }; - + checkAdmin(); }); } @@ -855,10 +1370,11 @@ } const server = ApiClient._serverAddress; + const token = ApiClient.accessToken(); const response = await fetch(`${server}/Plugins`, { headers: { - 'Authorization': getAuthHeader() + 'Authorization': window.apiHelper.getAuthHeader() } }); @@ -868,7 +1384,7 @@ const plugins = await response.json(); const pluginsList = Array.isArray(plugins) ? plugins : (plugins.Items || []); - + const plugin = pluginsList.find(p => p.Name === pluginName); return plugin ? plugin.Id : null; } catch (error) { @@ -885,10 +1401,11 @@ } const server = ApiClient._serverAddress; + const token = ApiClient.accessToken(); const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { headers: { - 'Authorization': getAuthHeader() + 'Authorization': window.apiHelper.getAuthHeader() } }); @@ -911,11 +1428,12 @@ } const server = ApiClient._serverAddress; + const token = ApiClient.accessToken(); const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { method: 'POST', headers: { - 'Authorization': getAuthHeader(), + 'Authorization': window.apiHelper.getAuthHeader(), 'Content-Type': 'application/json' }, body: JSON.stringify(config) @@ -934,6 +1452,7 @@ // Ensure Watchlist tab exists in CustomTabs + // Kept unused for now — Watchlist is hosted via addCustomPage('#/watchlist'). async function ensureWatchlistTab() { try { if (!window.KefinTweaksConfig) { @@ -960,7 +1479,7 @@ } console.log('[KefinTweaks Startup] Ensuring Watchlist tab exists in CustomTabs...'); - + const pluginId = await findPlugin('Custom Tabs'); if (!pluginId) { console.warn('[KefinTweaks Startup] CustomTabs plugin not found'); @@ -969,10 +1488,11 @@ // Get current CustomTabs config const server = ApiClient._serverAddress; - + const token = ApiClient.accessToken(); + const response = await fetch(`${server}/CustomTabs/Config`, { headers: { - 'Authorization': getAuthHeader() + 'Authorization': window.apiHelper.getAuthHeader() } }); @@ -987,7 +1507,7 @@ // Find watchlist tab by checking if the ContentHtml includes div class=\"sections watchlist\" const watchlistTab = tabs.find(tab => tab.ContentHtml.includes('sections watchlist')); - + if (watchlistTab) { console.log('[KefinTweaks Startup] Watchlist tab already exists with correct ContentHtml'); return true; @@ -1009,28 +1529,355 @@ } } + /** + * Remove the Watchlist Custom Tabs entry (exact ContentHtml match). + * Watchlist is now hosted via addCustomPage('#/watchlist'). + */ + async function removeWatchlistFromCustomTabPlugin() { + try { + const pluginId = await findPlugin('Custom Tabs'); + if (!pluginId) { + console.warn('[KefinTweaks Startup] CustomTabs plugin not found; nothing to remove'); + return false; + } + + const config = await getPluginConfig(pluginId); + const tabs = Array.isArray(config?.Tabs) ? config.Tabs : []; + const watchlistHtml = '
'; + const filtered = tabs.filter((tab) => tab.ContentHtml !== watchlistHtml); + + if (filtered.length === tabs.length) { + console.log('[KefinTweaks Startup] No exact Watchlist Custom Tab to remove'); + return true; + } + + await savePluginConfig(pluginId, { Tabs: filtered }); + console.log('[KefinTweaks Startup] Removed Watchlist tab from CustomTabs'); + return true; + } catch (error) { + console.error('[KefinTweaks Startup] Error removing Watchlist Custom Tab:', error); + return false; + } + } + + function normalizeReleaseTag(tag) { + const t = String(tag || '').trim(); + if (!t) return ''; + return t.startsWith('v') || t.startsWith('V') ? `v${t.slice(1)}` : `v${t}`; + } + + function normalizeUpdateState(config) { + if (!config || typeof config !== 'object') return { dismissed: true, latestVersion: '' }; + if (!config.updateState || typeof config.updateState !== 'object') { + config.updateState = { dismissed: true, latestVersion: '' }; + } else { + if (typeof config.updateState.dismissed !== 'boolean') { + config.updateState.dismissed = true; + } + if (typeof config.updateState.latestVersion !== 'string') { + config.updateState.latestVersion = ''; + } else { + config.updateState.latestVersion = normalizeReleaseTag(config.updateState.latestVersion) + || config.updateState.latestVersion; + } + } + return config.updateState; + } + + function isGithubJsdelivrKefinRoot(root) { + return /cdn\.jsdelivr\.net\/gh\/ranaldsgift\/KefinTweaks@/i.test(String(root || '')); + } + + function isSymbolicLatestRoot(root) { + return /@latest(\/|$)/i.test(String(root || '')); + } + + function parseGithubRootRef(root) { + const s = String(root || ''); + const floating = s.match(/@(latest|main|master|experimental)(\/|$)/i); + if (floating) { + const name = floating[1].toLowerCase(); + return { kind: name === 'master' ? 'main' : name, value: name === 'master' ? 'main' : name }; + } + const semver = s.match(/@(v?\d+\.\d+\.\d+)(\/|$)/i); + if (semver) { + return { kind: 'semver', value: normalizeReleaseTag(semver[1]) }; + } + const sha = s.match(/@([0-9a-f]{40})(\/|$)/i); + if (sha) { + return { kind: 'sha', value: sha[1].toLowerCase() }; + } + return null; + } + + function parseShaFromRoot(root) { + const m = String(root || '').match(/@([0-9a-f]{40})(\/|$)/i); + return m ? m[1].toLowerCase() : null; + } + + async function waitForGlobal(getter, timeoutMs = 15000, intervalMs = 250) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const value = getter(); + if (value) return value; + } catch (_) { /* ignore */ } + await new Promise((r) => setTimeout(r, intervalMs)); + } + return null; + } + + async function persistConfigFromStartup(config) { + const saveFn = await waitForGlobal( + () => window.KefinTweaksUtils?.saveConfigToJavaScriptInjector, + 20000 + ); + if (!saveFn) { + console.warn('[KefinTweaks Startup] saveConfigToJavaScriptInjector unavailable; updateState not persisted'); + return false; + } + window.KefinTweaksConfig = config; + try { + return !!(await saveFn(config)); + } catch (e) { + console.warn('[KefinTweaks Startup] Failed to persist config:', e); + return false; + } + } + + async function fetchGithubJson(url) { + const response = await fetch(url); + if (!response.ok) return null; + return response.json(); + } + + async function fetchLatestGithubRelease() { + const data = await fetchGithubJson('https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest'); + if (!data?.tag_name) return null; + const publishedAt = data.published_at ? new Date(data.published_at) : null; + if (!publishedAt || Number.isNaN(publishedAt.getTime())) return null; + return { + tag: normalizeReleaseTag(data.tag_name), + publishedAt + }; + } + + async function fetchReleasePublishedAt(tag) { + const normalized = normalizeReleaseTag(tag); + if (!normalized) return null; + const data = await fetchGithubJson( + `https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/tags/${encodeURIComponent(normalized)}` + ); + if (!data?.published_at) return null; + const d = new Date(data.published_at); + return Number.isNaN(d.getTime()) ? null : d; + } + + async function fetchCommitDate(shaOrRef) { + if (!shaOrRef) return null; + const data = await fetchGithubJson( + `https://api.github.com/repos/ranaldsgift/KefinTweaks/commits/${encodeURIComponent(shaOrRef)}` + ); + const iso = data?.commit?.committer?.date || data?.commit?.author?.date; + if (!iso) return null; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d; + } + + async function resolveInstalledCommitDate(root, config, branchName) { + const resolvedSha = parseShaFromRoot(config?.kefinTweaksRootResolved) + || parseShaFromRoot(resolvedRootCache); + if (resolvedSha) { + const fromResolved = await fetchCommitDate(resolvedSha); + if (fromResolved) return fromResolved; + } + return fetchCommitDate(branchName || 'main'); + } + + /** + * True when the install should treat GitHub Latest as a "new update" vs current pin/tip. + */ + async function isGithubInstallBehindLatest(root, config, latest) { + const ref = parseGithubRootRef(root); + if (!ref) return true; + + if (ref.kind === 'semver') { + if (ref.value === latest.tag) return false; + const installedPublished = await fetchReleasePublishedAt(ref.value); + if (!installedPublished) return true; + return installedPublished.getTime() < latest.publishedAt.getTime(); + } + + if (ref.kind === 'sha') { + const commitDate = await fetchCommitDate(ref.value); + if (!commitDate) return true; + return commitDate.getTime() < latest.publishedAt.getTime(); + } + + if (ref.kind === 'main' || ref.kind === 'experimental') { + const tipDate = await resolveInstalledCommitDate(root, config, ref.kind); + if (!tipDate) return true; + // Tip newer than Latest ⇒ following branch tip, not a release-update prompt + if (tipDate.getTime() >= latest.publishedAt.getTime()) return false; + return true; + } + + return true; + } + + function buildConcreteLatestRoot(latestTag) { + const tag = normalizeReleaseTag(latestTag); + return `https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks@${tag}/`; + } + + async function showPinnedVersionUpdateToast(config, latestTag) { + const toastFn = await waitForGlobal(() => window.KefinTweaksToaster?.toast, 20000); + if (!toastFn) { + console.warn('[KefinTweaks Startup] Toaster unavailable; skipping update toast'); + return; + } + + const tag = normalizeReleaseTag(latestTag); + const githubInstall = isGithubJsdelivrKefinRoot(config?.kefinTweaksRoot); + + const dismissAndSave = async () => { + normalizeUpdateState(config); + config.updateState.dismissed = true; + if (tag) config.updateState.latestVersion = tag; + await persistConfigFromStartup(config); + }; + + const handle = toastFn( + 'An update for KefinTweaks is available.', + '0', + false, + { + buttons: [ + { + label: 'Update', + onClick: async () => { + try { + normalizeUpdateState(config); + config.updateState.dismissed = true; + if (tag) config.updateState.latestVersion = tag; + + if (githubInstall && tag) { + const nextRoot = buildConcreteLatestRoot(tag); + config.kefinTweaksRoot = nextRoot; + config.kefinTweaksRootResolved = nextRoot; + await persistConfigFromStartup(config); + handle?.dismiss?.(); + toastFn( + 'KefinTweaks updated. Please refresh the page for the changes to take effect.', + '8' + ); + } else { + await persistConfigFromStartup(config); + handle?.dismiss?.(); + const url = tag + ? `https://github.com/ranaldsgift/KefinTweaks/releases/tag/${encodeURIComponent(tag)}` + : 'https://github.com/ranaldsgift/KefinTweaks/releases/latest'; + try { + window.open(url, '_blank', 'noopener'); + } catch (_) { /* ignore */ } + } + } catch (e) { + console.error('[KefinTweaks Startup] Update action failed:', e); + } + } + }, + { + label: 'Dismiss', + onClick: async () => { + try { + await dismissAndSave(); + handle?.dismiss?.(); + } catch (e) { + console.error('[KefinTweaks Startup] Dismiss action failed:', e); + } + } + } + ] + } + ); + } + + async function checkPinnedVersionUpdate() { + const config = window.KefinTweaksConfig; + const root = config?.kefinTweaksRoot; + if (!root) return; + + // Floating @latest already auto-resolves to GitHub Latest — skip this prompt flow + if (isSymbolicLatestRoot(root)) { + normalizeUpdateState(config); + return; + } + + const state = normalizeUpdateState(config); + let latest; + try { + latest = await fetchLatestGithubRelease(); + } catch (e) { + console.warn('[KefinTweaks Startup] Failed to fetch latest release:', e); + return; + } + if (!latest?.tag) return; + + const known = normalizeReleaseTag(state.latestVersion); + if (known !== latest.tag) { + let markNew = false; + if (isGithubJsdelivrKefinRoot(root)) { + try { + markNew = await isGithubInstallBehindLatest(root, config, latest); + } catch (e) { + console.warn('[KefinTweaks Startup] Install-vs-latest compare failed; treating as new:', e); + markNew = true; + } + } else { + // Self-hosted / custom root + markNew = true; + } + + if (markNew) { + state.dismissed = false; + state.latestVersion = latest.tag; + await persistConfigFromStartup(config); + } else { + // Same or newer install relative to Latest — remember tag without prompting + state.latestVersion = latest.tag; + await persistConfigFromStartup(config); + } + } + + if (!state.dismissed) { + await showPinnedVersionUpdateToast(config, latest.tag); + } + } + // Startup task - runs only for admin users async function startupTask() { console.log('[KefinTweaks Startup] Starting startup task...'); - + // Check if kefinTweaksRoot is configured - startup tasks only run after installation if (!window.KefinTweaksConfig?.kefinTweaksRoot || window.KefinTweaksConfig.kefinTweaksRoot === '') { console.log('[KefinTweaks Startup] kefinTweaksRoot is not configured, skipping startup tasks'); return; } - + // Check if user is admin (wait up to 5s for login) const isAdmin = await checkAdminWithTimeout(5000); - + if (!isAdmin) { console.log('[KefinTweaks Startup] User is not admin or not logged in, skipping startup task'); return; } try { - // Task: Ensure Watchlist tab exists in CustomTabs - await ensureWatchlistTab(); + // Fire-and-forget: remove leftover Watchlist Custom Tab (best-effort) + //removeWatchlistFromCustomTabPlugin(); + await checkPinnedVersionUpdate(); + console.log('[KefinTweaks Startup] Startup task completed successfully'); } catch (error) { console.error('[KefinTweaks Startup] Error in startup task:', error); @@ -1053,7 +1900,7 @@ setTimeout(startupTask, 1000); })(); } - + console.log('[KefinTweaks Injector] Injector script loaded. Available at window.KefinTweaks'); - + })(); diff --git a/kefinTweaks-default-config.js b/kefinTweaks-default-config.js index 9b00528..6b36803 100644 --- a/kefinTweaks-default-config.js +++ b/kefinTweaks-default-config.js @@ -1,15 +1,20 @@ window.KefinTweaksDefaultConfig = { "kefinTweaksRoot": "", + "enabled": true, "scripts": { + "watchlist": true, "homeScreen": true, "search": true, - "watchlist": true, "headerTabs": true, "customMenuLinks": true, + "hamburgerMenu": false, + "games": true, "breadcrumbs": true, "playlist": true, "itemDetailsCollections": true, "flattenSingleSeasonShows": true, + "seriesInfo": true, + "versionPreferences": true, "collections": true, "exclusiveElsewhere": false, "backdropLeakFix": true, @@ -17,378 +22,10 @@ window.KefinTweaksDefaultConfig = { "infiniteScroll": true, "removeContinue": true, "subtitleSearch": true, - "skinManager": true - }, - "homeScreen": { - "defaultItemLimit": 16, - "defaultSortOrder": "Random", - "defaultCardFormat": "Poster", - "recentlyAddedInLibrary": {}, - "recentlyReleased": { - "enabled": true, - "order": 30, - "movies": { - "name": "Recently Released Movies", - "enabled": true, - "itemLimit": 16, - "sortOrder": "ReleaseDate", - "sortOrderDirection": "Descending", - "cardFormat": "Poster", - "order": 30, - "minAgeInDays": null, - "maxAgeInDays": null - }, - "episodes": { - "name": "Recently Aired Episodes", - "enabled": true, - "itemLimit": 16, - "sortOrder": "PremiereDate", - "sortOrderDirection": "Descending", - "cardFormat": "Backdrop", - "order": 31, - "minAgeInDays": null, - "maxAgeInDays": null - } - }, - "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, - "enableSeasonalAnimations": true, - "enableSeasonalBackground": 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": "seasonal-1-section-0", - "enabled": true, - "name": "Christmas Movies", - "type": "Tag", - "source": "christmas", - "itemLimit": 16, - "sortOrder": "Random", - "sortOrderDirection": "Ascending", - "cardFormat": "Poster", - "order": 50, - "spotlight": true, - "discoveryEnabled": false, - "searchTerm": "", - "includeItemTypes": [ - "Movie" - ], - "additionalQueryOptions": [] - }, - { - "id": "seasonal-1-section-1", - "enabled": true, - "name": "Christmas Episodes", - "type": "Parent", - "source": "", - "itemLimit": 16, - "sortOrder": "Random", - "sortOrderDirection": "Ascending", - "cardFormat": "Thumb", - "order": 51, - "spotlight": false, - "discoveryEnabled": false, - "searchTerm": "christmas", - "includeItemTypes": [ - "Episode" - ], - "additionalQueryOptions": [] - } - ] - } - ] - }, - "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, - "renderSpotlightAboveMatching": false, - "spotlight": { - "autoPlay": true, - "interval": 5000, - "showDots": true, - "showNavButtons": true, - "showClearArt": true - }, - "randomizeOrder": false - }, - "customSections": [] - }, - "exclusiveElsewhere": { - "hideServerName": false + "skinManager": true, + "thumbnailScrubber": false, + "watchTogether": false, + "userManager": false }, "search": { "enableJellyseerr": false @@ -396,9 +33,40 @@ window.KefinTweaksDefaultConfig = { "flattenSingleSeasonShows": { "hideSingleSeasonContainer": false }, + "seriesInfo": { + "use24HourTimeFormat": false + }, + "versionPreferences": { + "versions": ["4k", "2160p", "1080p", "720p", "576p", "480p", "360p", "__none__"], + "editions": ["Theatrical", "Director's Cut", "Extended Edition", "Fan Edit", "__none__"], + "showVersionBeforeEdition": false + }, + "thumbnailScrubber": { + "PreviewOnHover": false, + "UseVideoStream": true, + "FallbackAfterMs": 500, + "HOVER_PREVIEW_TIMEOUT_MS": 800, + "HOVER_TIMEOUT": 800, + "HOVER_PREVIEW_FRAME_MS": 250, + "SCRUB_ACTIVATION_DELAY_MS": 300 + }, + "watchTogether": { + "sessionTimeout": 30 + }, + "userManager": { + "defaultTemplateName": "", + "templates": [] + }, + "exclusiveElsewhere": { + "hideServerName": false + }, "skins": [], "defaultSkin": null, "themes": [], "customMenuLinks": [], - "optionalIncludes": [] + "optionalIncludes": [], + "updateState": { + "dismissed": true, + "latestVersion": "" + } }; diff --git a/kefinTweaks-plugin.js b/kefinTweaks-plugin.js index 3043505..60729a4 100644 --- a/kefinTweaks-plugin.js +++ b/kefinTweaks-plugin.js @@ -33,6 +33,9 @@ // Check if user is admin async function isAdmin() { + // Wait for user to be logged in + await waitForLoginForInjectorWrite(); + try { if (window.ApiClient && window.ApiClient.getCurrentUser) { const user = await window.ApiClient.getCurrentUser(); @@ -59,6 +62,11 @@ } }); + // Non-admins cannot list plugins (HTTP 403) — treat as no access, not a hard failure + if (response.status === 403 || response.status === 401) { + return null; + } + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } @@ -84,7 +92,7 @@ // Get current JavaScript Injector configuration async function getJavaScriptInjectorConfig(pluginId) { try { - if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { + if (!pluginId || !window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { return null; } @@ -96,6 +104,10 @@ } }); + if (response.status === 403 || response.status === 401) { + return null; + } + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } @@ -190,7 +202,15 @@ async function saveConfigToJavaScriptInjector(config) { try { const pluginId = await findJavaScriptInjectorPlugin(); + if (!pluginId) { + console.warn('[KefinTweaks Installer] Cannot save config: JavaScript Injector plugin unavailable (non-admin or missing)'); + return false; + } const injectorConfig = await getJavaScriptInjectorConfig(pluginId); + if (!injectorConfig) { + console.warn('[KefinTweaks Installer] Cannot save config: could not read JS Injector configuration'); + return false; + } if (!injectorConfig.CustomJavaScripts) { injectorConfig.CustomJavaScripts = []; @@ -313,6 +333,223 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; } } + function normalizeKefinRootUrl(root) { + if (!root || typeof root !== 'string') return ''; + return root.endsWith('/') ? root : root + '/'; + } + + function isFloatingKefinRoot(root) { + return /@(latest|main|master|experimental)(\/|$)/i.test(String(root || '')); + } + + /** Human label for a newly pinned resolved root (channel-aware). */ + function formatResolvedUpdateLabel(symbolicRoot, resolvedRoot) { + const symbolic = String(symbolicRoot || ''); + const resolved = String(resolvedRoot || ''); + const verMatch = resolved.match(/@v?(\d+\.\d+\.\d+)/i); + if (verMatch) { + return `v${verMatch[1]}`; + } + const shaMatch = resolved.match(/@([0-9a-f]{7,40})(?:\/|$)/i); + const shortSha = shaMatch ? shaMatch[1].substring(0, 7).toLowerCase() : null; + if (/@experimental(\/|$)/i.test(symbolic)) { + return shortSha ? `Experimental (#${shortSha})` : 'Experimental'; + } + if (/@(main|master)(\/|$)/i.test(symbolic)) { + return shortSha ? `Dev (#${shortSha})` : 'Development'; + } + if (shortSha) { + return `Dev (#${shortSha})`; + } + return 'a new version'; + } + + function flushPendingRootUpdateToast() { + const pending = window.__kefinPendingRootUpdateToast; + if (!pending?.message) return false; + if (typeof window.KefinTweaksToaster?.toast !== 'function') return false; + window.__kefinPendingRootUpdateToast = null; + try { + // Sticky when a forced tip-reload countdown is active; otherwise auto-hide. + const sticky = !!pending.sticky || !!window.__kefinTipReloadScheduled; + window.KefinTweaksToaster.toast(pending.message, pending.duration || '5', !sticky); + } catch (e) { + console.warn('[KefinTweaks Installer] Failed to show root-update toast:', e); + return false; + } + return true; + } + + function queueRootUpdateToast(message, options = null) { + if (!message) return; + window.__kefinPendingRootUpdateToast = { + message, + sticky: !!(options && options.sticky), + duration: (options && options.duration) || '5' + }; + if (flushPendingRootUpdateToast()) return; + const start = Date.now(); + const poll = () => { + if (flushPendingRootUpdateToast()) return; + if (Date.now() - start > 30000) return; + setTimeout(poll, 250); + }; + setTimeout(poll, 250); + } + + /** + * After tip re-pin is persisted to disk, force reload so Config/bake/scripts stay aligned. + * Single-flight per page session. Countdown updates the same toast in place (no reopen). + */ + function scheduleForcedTipReloadCountdown({ symbolicRoot, nextResolved }) { + if (window.__kefinTipReloadScheduled) return; + window.__kefinTipReloadScheduled = true; + + const label = formatResolvedUpdateLabel(symbolicRoot, nextResolved); + let remaining = 5; + const msg = (n) => `KefinTweaks updated to ${label} — refreshing in ${n}…`; + + let toastHandle = null; + + const setToastMessage = (el, text) => { + if (!el) return; + const messageEl = el.querySelector('.kefin-toast-message'); + if (messageEl) messageEl.textContent = text; + else el.textContent = text; + }; + + const ensureToast = () => { + if (toastHandle?.element?.isConnected) return toastHandle; + if (typeof window.KefinTweaksToaster?.toast === 'function') { + toastHandle = window.KefinTweaksToaster.toast(msg(remaining), '6', false); + return toastHandle; + } + // Toaster not ready yet — queue once; flush will show sticky toast + queueRootUpdateToast(msg(remaining), { sticky: true, duration: '6' }); + return null; + }; + + const update = (n) => { + const handle = ensureToast(); + if (handle?.element?.isConnected) { + setToastMessage(handle.element, msg(n)); + return; + } + // Pending toast may have been flushed by toaster init — adopt it if present + const existing = document.querySelector('.toastContainer .toast'); + if (existing) { + toastHandle = { element: existing, dismiss: () => {} }; + setToastMessage(existing, msg(n)); + } + }; + + update(remaining); + const tick = setInterval(() => { + remaining -= 1; + if (remaining <= 0) { + clearInterval(tick); + try { + window.location.reload(); + } catch (e) { + console.warn('[KefinTweaks Installer] Reload after tip update failed:', e); + } + return; + } + update(remaining); + }, 1000); + } + + /** + * After a floating root's resolved URL was persisted (skip first pin): countdown + reload. + */ + function maybeQueueResolvedRootUpdateToast({ symbolicRoot, previousResolved, nextResolved, persisted }) { + if (!persisted) return; + if (!isFloatingKefinRoot(symbolicRoot)) return; + const previous = normalizeKefinRootUrl(previousResolved); + const next = normalizeKefinRootUrl(nextResolved); + if (!previous || !next || previous === next) return; + scheduleForcedTipReloadCountdown({ symbolicRoot, nextResolved: next }); + } + + /** + * Admin + bake already on page: tip resolve/persist/sync without loading live injector.js. + * Tip advanced → persist Config+bake then forced reload. Tip unchanged → stamp sync only. + */ + async function handleAdminAttachedBakeTipSync(config) { + const symbolic = config?.kefinTweaksRoot || ''; + if (!symbolic) return; + + await waitForLoginForInjectorWrite(); + + const current = normalizeKefinRootUrl(config.kefinTweaksRootResolved || ''); + let tip = normalizeKefinRootUrl(symbolic); + if (isFloatingKefinRoot(symbolic)) { + tip = normalizeKefinRootUrl(await resolveRootVersion(symbolic)); + } + + if (tip && tip !== current) { + console.log('[KefinTweaks Installer] Tip advanced while bake attached:', current || '(none)', '->', tip); + config.kefinTweaksRootResolved = tip; + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = config; + } else { + window.KefinTweaksConfig.kefinTweaksRootResolved = tip; + } + + try { + const persisted = await saveConfigToJavaScriptInjector(config); + if (persisted) { + scheduleForcedTipReloadCountdown({ symbolicRoot: symbolic, nextResolved: tip }); + console.log('[KefinTweaks Installer] Tip persist+bake ok; scheduling reload'); + } else { + console.warn('[KefinTweaks Installer] Tip advanced but persist failed; not reloading'); + } + } catch (e) { + console.error('[KefinTweaks Installer] Tip persist failed; not reloading:', e); + } + return; + } + + // Tip unchanged — stamp sync only (no live inject, no reload) + try { + const loadRoot = current || tip; + if (!loadRoot) return; + await loadKefinTweaksLoaderFromRoot(loadRoot); + const Loader = window.KefinTweaksLoader; + if (!Loader || typeof Loader.syncKefinTweaksInjector !== 'function') { + console.log('[KefinTweaks Installer] Loader sync unavailable; bake attached, tip unchanged'); + return; + } + Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS); + let major = null; + try { + major = await window.KefinTweaks.getJellyfinMajorVersion(); + } catch (e) { /* ignore */ } + const planRoot = Loader.getResolvedKefinRoot + ? Loader.getResolvedKefinRoot(config) + : loadRoot; + const plan = Loader.buildLoadPlan(config, major, { + root: planRoot, + configOnly: config.enabled === false + }); + const syncResult = await Loader.syncKefinTweaksInjector(plan); + console.log( + '[KefinTweaks Installer] Attached bake stamp sync:', + syncResult?.synced ? ('synced ' + syncResult.stamp) : (syncResult?.reason || 'done') + ); + } catch (e) { + console.warn('[KefinTweaks Installer] Attached bake stamp sync failed:', e); + } + } + + function isKefinInjectorAttachedOnPage() { + return !!( + window.KefinTweaksInjectorAttached + || window.KefinTweaksScriptsPreloaded + || window.KefinTweaksInjectorStamp + ); + } + async function waitForLoginForInjectorWrite(maxWaitMs = 10000, checkInterval = 250) { if (isLoggedInForInjectorWrite()) return true; const start = Date.now(); @@ -337,20 +574,20 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; return { ok: false, reason: 'not logged in' }; } - try { - const normalize = (r) => { - if (!r || typeof r !== 'string') return ''; - return r.endsWith('/') ? r : r + '/'; - }; + if (!(await isAdmin())) { + return { ok: false, reason: 'not admin' }; + } - const expected = normalize(await resolveRootVersion(rootRaw)); - const current = normalize(config.kefinTweaksRootResolved || ''); + try { + const expected = normalizeKefinRootUrl(await resolveRootVersion(rootRaw)); + const current = normalizeKefinRootUrl(config.kefinTweaksRootResolved || ''); + const resolvedRootUpdated = !!(current && expected && current !== expected); if (!current || current !== expected) { config.kefinTweaksRootResolved = expected; console.log('[KefinTweaks Installer] Updated kefinTweaksRootResolved:', current || '(none)', '->', expected); } - const loadRoot = normalize(config.kefinTweaksRootResolved || rootRaw); + const loadRoot = normalizeKefinRootUrl(config.kefinTweaksRootResolved || rootRaw); await loadKefinTweaksLoaderFromRoot(loadRoot); const Loader = window.KefinTweaksLoader; if (!Loader) { @@ -411,6 +648,15 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; }; } + if (resolvedRootUpdated) { + maybeQueueResolvedRootUpdateToast({ + symbolicRoot: rootRaw, + previousResolved: current, + nextResolved: expected, + persisted: true + }); + } + console.log('[KefinTweaks Installer] Auto-created KefinTweaks-injector with', plan.assets.length, 'assets'); return { ok: true, plan }; } catch (err) { @@ -431,8 +677,13 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; const LATEST_RELEASE_NAME = 'Latest'; const DEVELOPMENT_NAME = 'Development'; const EXPERIMENTAL_NAME = 'Experimental'; + const SPECIFIC_BUILD_NAME = 'Specific Build'; const SELF_HOSTED_NAME = 'Self Hosted'; + function isFullCommitSha(value) { + return typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value.trim()); + } + // Parse source URL to determine source type and version function parseKefinTweaksSource(url) { if (!url || url === '') { @@ -452,9 +703,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; return { sourceType: 'github', version: DEVELOPMENT_NAME }; } else if (version === 'experimental') { return { sourceType: 'github', version: EXPERIMENTAL_NAME }; - } else if (version && version.length === 40 && /^[0-9a-f]+$/.test(version)) { - // Commit hash URL (e.g. from a previous Experimental selection) - return { sourceType: 'github', version: EXPERIMENTAL_NAME }; + } else if (isFullCommitSha(version)) { + return { sourceType: 'github', version: SPECIFIC_BUILD_NAME, commit: version.toLowerCase() }; } else { return { sourceType: 'github', version: version.replace(/^v/, '') }; } @@ -477,16 +727,16 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; .map(release => release.tag_name.replace(/^v/, '')) .filter(tag => tag.match(/^\d+\.\d+\.\d+/)); // Only semantic versions - versionsCache = [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME, ...versions]; + versionsCache = [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME, SPECIFIC_BUILD_NAME, ...versions]; return versionsCache; } catch (error) { console.warn('[KefinTweaks Installer] Error fetching versions:', error); - return [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME]; + return [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME, SPECIFIC_BUILD_NAME]; } } // Build kefinTweaksRoot URL from selections - function buildKefinTweaksRootUrl(sourceType, source) { + function buildKefinTweaksRootUrl(sourceType, source, commitHash) { if (sourceType === 'custom') { // Ensure URL ends with / return source.endsWith('/') ? source : source + '/'; @@ -498,6 +748,12 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; versionTag = 'main'; } else if (source === EXPERIMENTAL_NAME) { versionTag = 'experimental'; + } else if (source === SPECIFIC_BUILD_NAME) { + const sha = String(commitHash || '').trim().toLowerCase(); + if (!isFullCommitSha(sha)) { + throw new Error('Specific Build requires a full 40-character commit hash'); + } + versionTag = sha; } else if (source !== LATEST_RELEASE_NAME) { versionTag = source.startsWith('v') ? source : 'v' + source; } @@ -505,6 +761,13 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; return `${JSDELIVR_BASE}@${versionTag}/`; } + function formatSpecificBuildDisplay(sourceInfo) { + const sha = sourceInfo?.commit || ''; + return sha + ? `${SPECIFIC_BUILD_NAME} (#${sha.slice(0, 7)})` + : SPECIFIC_BUILD_NAME; + } + // Simple modal creation (doesn't depend on modal.js) function createSimpleModal(id, title, content, footer) { // Remove existing modal @@ -786,7 +1049,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; const content = document.createElement('div'); content.style.display = 'flex'; content.style.flexDirection = 'column'; - content.style.gap = '1.5em'; + content.style.gap = '0.5em'; // Enabled checkbox const isEnabled = currentConfig?.enabled !== false; @@ -794,6 +1057,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; enabledContainer.style.display = 'flex'; enabledContainer.style.alignItems = 'center'; enabledContainer.style.gap = '0.75em'; + enabledContainer.style.marginBottom = '0.75em'; const enabledCheckbox = document.createElement('input'); enabledCheckbox.type = 'checkbox'; @@ -807,7 +1071,6 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; enabledLabel.style.margin = '0'; enabledLabel.style.display = 'flex'; enabledLabel.style.alignItems = 'center'; - enabledLabel.style.gap = '0.75em'; const enabledText = document.createElement('span'); enabledText.textContent = isEnabled ? 'Enabled' : 'Disabled'; @@ -828,7 +1091,6 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; const sourceTypeLabel = document.createElement('label'); sourceTypeLabel.textContent = 'Install From:'; sourceTypeLabel.style.display = 'block'; - sourceTypeLabel.style.marginBottom = '0.5em'; const sourceTypeSelect = document.createElement('select'); sourceTypeSelect.className = 'fld emby-select emby-select-withcolor'; @@ -842,7 +1104,6 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; const sourceLabel = document.createElement('label'); sourceLabel.textContent = 'Version:'; sourceLabel.style.display = 'block'; - sourceLabel.style.marginBottom = '0.5em'; const sourceSelect = document.createElement('select'); sourceSelect.className = 'fld emby-select emby-select-withcolor'; @@ -860,6 +1121,86 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; customUrlInput.value = sourceInfo.sourceType === 'custom' ? currentRoot : ''; customUrlInput.style.display = sourceInfo.sourceType === 'custom' ? 'block' : 'none'; + // Specific Build commit hash input + const commitHashLabel = document.createElement('label'); + commitHashLabel.textContent = 'Commit Hash:'; + commitHashLabel.style.display = 'block'; + commitHashLabel.style.marginBottom = '0.5em'; + commitHashLabel.style.marginTop = '1em'; + + const commitHashInput = document.createElement('input'); + commitHashInput.type = 'text'; + commitHashInput.className = 'fld emby-input'; + commitHashInput.id = 'kefinTweaksCommitHash'; + commitHashInput.placeholder = '40-character git commit SHA'; + commitHashInput.spellcheck = false; + commitHashInput.autocomplete = 'off'; + commitHashInput.value = sourceInfo.commit || ''; + + // Resolved hash readout for Development / Experimental + const resolvedHashEl = document.createElement('div'); + resolvedHashEl.id = 'kefinTweaksResolvedHash'; + resolvedHashEl.className = 'listItemBodyText secondary'; + resolvedHashEl.style.display = 'none'; + resolvedHashEl.style.marginTop = '0.35em'; + resolvedHashEl.style.fontFamily = 'monospace'; + resolvedHashEl.style.fontSize = '0.9em'; + resolvedHashEl.style.wordBreak = 'break-all'; + + function extractShaFromRoot(root) { + if (!root || typeof root !== 'string') return null; + const m = root.match(/@([0-9a-f]{7,40})(?:\/|$)/i); + return m ? m[1].toLowerCase() : null; + } + + async function updateResolvedHashVisibility() { + const show = + sourceTypeSelect.value === 'github' && + (sourceSelect.value === DEVELOPMENT_NAME || sourceSelect.value === EXPERIMENTAL_NAME); + if (!show) { + resolvedHashEl.style.display = 'none'; + resolvedHashEl.textContent = ''; + return; + } + + resolvedHashEl.style.display = 'block'; + resolvedHashEl.textContent = 'Version: …'; + + let sha = extractShaFromRoot(currentConfig?.kefinTweaksRootResolved); + const symbolicMatchesSelection = + (sourceSelect.value === DEVELOPMENT_NAME && /@(main|master)(\/|$)/.test(currentRoot || '')) || + (sourceSelect.value === EXPERIMENTAL_NAME && /@experimental(\/|$)/.test(currentRoot || '')); + + // Prefer stored resolved hash when it matches the selected branch; otherwise resolve live + if (!sha || !symbolicMatchesSelection || sourceSelect.value !== sourceInfo.version) { + try { + const branchRoot = + sourceSelect.value === EXPERIMENTAL_NAME + ? `${JSDELIVR_BASE}@experimental/` + : `${JSDELIVR_BASE}@main/`; + const resolved = await resolveRootVersion(branchRoot); + sha = extractShaFromRoot(resolved) || sha; + } catch (e) { + console.warn('[KefinTweaks Installer] Could not resolve branch hash:', e); + } + } + + if (sha) { + const short = sha.length > 12 ? sha.slice(0, 12) : sha; + resolvedHashEl.textContent = `Version: ${short}`; + } else { + resolvedHashEl.textContent = 'Version: unavailable'; + } + } + + const updateCommitHashVisibility = () => { + const showCommit = sourceTypeSelect.value === 'github' + && sourceSelect.value === SPECIFIC_BUILD_NAME; + commitHashLabel.style.display = showCommit ? 'block' : 'none'; + commitHashInput.style.display = showCommit ? 'block' : 'none'; + updateResolvedHashVisibility(); + }; + // Set initial visibility based on source type if (sourceInfo.sourceType === 'custom') { sourceSelect.style.display = 'none'; @@ -870,6 +1211,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; sourceLabel.style.display = 'block'; customUrlInput.style.display = 'none'; } + updateCommitHashVisibility(); // Toggle visibility based on source type sourceTypeSelect.onchange = () => { @@ -882,12 +1224,20 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; sourceLabel.style.display = 'block'; customUrlInput.style.display = 'none'; } + updateCommitHashVisibility(); + }; + + sourceSelect.onchange = () => { + updateCommitHashVisibility(); }; content.appendChild(sourceTypeLabel); content.appendChild(sourceTypeSelect); content.appendChild(sourceLabel); content.appendChild(sourceSelect); + content.appendChild(resolvedHashEl); + content.appendChild(commitHashLabel); + content.appendChild(commitHashInput); content.appendChild(customUrlInput); // Footer with buttons @@ -914,7 +1264,19 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; return; } - const kefinTweaksRoot = buildKefinTweaksRootUrl(sourceType, source); + let commitHash = ''; + if (sourceType === 'github' && source === SPECIFIC_BUILD_NAME) { + commitHash = (commitHashInput.value || '').trim().toLowerCase(); + if (!isFullCommitSha(commitHash)) { + showAlertModal( + 'Invalid Commit Hash', + '

Please enter a full 40-character git commit SHA for Specific Build.

' + ); + return; + } + } + + const kefinTweaksRoot = buildKefinTweaksRootUrl(sourceType, source, commitHash); const kefinTweaksRootResolvedRaw = await resolveRootVersion(kefinTweaksRoot); const kefinTweaksRootResolved = kefinTweaksRootResolvedRaw.endsWith('/') ? kefinTweaksRootResolvedRaw @@ -979,6 +1341,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; let versionDisplay = ''; if (sourceInfo.sourceType === 'custom') { versionDisplay = SELF_HOSTED_NAME; + } else if (sourceInfo.version === SPECIFIC_BUILD_NAME) { + versionDisplay = formatSpecificBuildDisplay(sourceInfo); } else if (sourceInfo.version === DEVELOPMENT_NAME) { versionDisplay = DEVELOPMENT_NAME; } else if (sourceInfo.version === LATEST_RELEASE_NAME) { @@ -1171,6 +1535,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; if (root) { if (sourceInfo.sourceType === 'custom') { versionDisplay = SELF_HOSTED_NAME; + } else if (sourceInfo.version === SPECIFIC_BUILD_NAME) { + versionDisplay = formatSpecificBuildDisplay(sourceInfo); } else if (sourceInfo.version === DEVELOPMENT_NAME) { versionDisplay = DEVELOPMENT_NAME; } else if (sourceInfo.version === LATEST_RELEASE_NAME) { @@ -1367,6 +1733,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; if (root) { if (sourceInfo.sourceType === 'custom') { versionDisplay = SELF_HOSTED_NAME; + } else if (sourceInfo.version === SPECIFIC_BUILD_NAME) { + versionDisplay = formatSpecificBuildDisplay(sourceInfo); } else if (sourceInfo.version === DEVELOPMENT_NAME) { versionDisplay = DEVELOPMENT_NAME; } else if (sourceInfo.version === LATEST_RELEASE_NAME) { @@ -1687,25 +2055,18 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; function injectCustomPageStyles(config) { if (!shouldInjectCustomPageStyles(config)) return; - if (document.getElementById('kefin-custom-page-styles')) return; - const style = document.createElement('style'); - style.id = 'kefin-custom-page-styles'; - style.textContent = ` -#reactRoot .skinBody:has(.customPage:not(.hide)) #fallbackPage { + const css = ` +#reactRoot.kefin-custom-page-active .skinBody #fallbackPage { display: none; } -#reactRoot:not(:has(.skinBody .customPage:not(.hide))) .pageTitle { - display: none !important; +#reactRoot:not(.kefin-custom-page-active) #fallbackPage:not([data-kefin-fallback-ready]) > * { + display: none; } -#reactRoot .skinBody:not(:has(.customPage:not(.hide))) #fallbackPage > * { - display: none; -} - -#reactRoot:not(:has(.skinBody .customPage:not(.hide))) #fallbackPage::after { - content:''; +#reactRoot:not(.kefin-custom-page-active) #fallbackPage:not([data-kefin-fallback-ready])::after { + content: ''; display: inline-block; width: 20px; height: 20px; @@ -1719,11 +2080,46 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; top: 1em; } -#reactRoot:has(.MuiBox-root) .libraryPage:not(.noSecondaryNavPage).customPage { - padding-top: 0 !important; +#fallbackPage[data-kefin-fallback-ready]::after { + display: none !important; + content: none !important; +} + +#reactRoot.kefin-custom-page-active .backdropImage { + background: none !important; +} + +header.MuiPaper-root + main.MuiBox-root .customPage { + margin-top: 3rem; +} + +.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :nth-child(n+2), +.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child .MuiButton-icon img { + display: none; +} +.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child .MuiButton-icon::after { + content: '\\e88a'; + font-family: 'Material Icons'; + font-size: 1.2rem; +} +.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child { + font-size: 0px; +} +main.MuiBox-root .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-custom-page] { + padding-top: 1rem !important; +} +.layout-mobile :not(main.MuiBox-root) > .skinBody > .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-custom-page] { + padding-top: 5rem !important; } `; - (document.head || document.documentElement).appendChild(style); + window.KefinTweaksCustomPageStyles = css; + let style = document.getElementById('kefin-custom-page-styles'); + if (!style) { + style = document.createElement('style'); + style.id = 'kefin-custom-page-styles'; + (document.head || document.documentElement).appendChild(style); + } + style.textContent = css; } // Initialize the installer (no login/admin gate — admin is checked when rendering the card) @@ -1800,46 +2196,76 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; return root; } - // Load injector.js from a specific root URL (resolves floating refs first) + // Load injector.js from a specific root URL (fallback when bake is absent). + // GitHub floating resolve + persist only for admins; non-admins use pinned/symbolic as-is. async function loadInjectorFromRoot(root) { if (!root || root === '') { console.warn('[KefinTweaks Installer] Cannot load injector: kefinTweaksRoot is empty'); return false; } - // Ensure root ends with / - const rootUrl = root.endsWith('/') ? root : root + '/'; - const resolvedRoot = await resolveRootVersion(rootUrl); - const injectorUrl = `${resolvedRoot}injector.js`; + const rootUrl = normalizeKefinRootUrl(root); + const admin = await isAdmin(); + let resolvedRoot = rootUrl; + + if (isFloatingKefinRoot(rootUrl)) { + if (admin) { + resolvedRoot = normalizeKefinRootUrl(await resolveRootVersion(rootUrl)); + } else { + const pinned = normalizeKefinRootUrl(window.KefinTweaksConfig?.kefinTweaksRootResolved || ''); + resolvedRoot = pinned || rootUrl; + console.log('[KefinTweaks Installer] Non-admin: skipping GitHub resolve; using', resolvedRoot); + } + } if (resolvedRoot !== rootUrl) { console.log('[KefinTweaks Installer] Resolved root from', rootUrl, 'to', resolvedRoot); } - // Persist commit-SHA root when floating GitHub refs resolve to a new URL - try { - const normalize = (r) => { - if (!r || typeof r !== 'string') return ''; - return r.endsWith('/') ? r : r + '/'; - }; - const expected = normalize(resolvedRoot); - if (!window.KefinTweaksConfig) { - window.KefinTweaksConfig = {}; - } - const current = normalize(window.KefinTweaksConfig.kefinTweaksRootResolved || ''); - if (expected && expected !== current) { - window.KefinTweaksConfig.kefinTweaksRootResolved = expected; - console.log('[KefinTweaks Installer] Updated kefinTweaksRootResolved:', current || '(none)', '->', expected); - if (isLoggedInForInjectorWrite()) { - await saveConfigToJavaScriptInjector(window.KefinTweaksConfig); - } else { - console.warn('[KefinTweaks Installer] kefinTweaksRootResolved updated in memory; not logged in to persist'); + // Admin: persist tip move; if replacing a previous pin, countdown reload (no tip injector this session) + if (admin && isFloatingKefinRoot(rootUrl)) { + try { + const expected = normalizeKefinRootUrl(resolvedRoot); + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; } + const symbolicRoot = window.KefinTweaksConfig.kefinTweaksRoot || rootUrl; + const current = normalizeKefinRootUrl(window.KefinTweaksConfig.kefinTweaksRootResolved || ''); + if (expected && expected !== current) { + window.KefinTweaksConfig.kefinTweaksRootResolved = expected; + console.log('[KefinTweaks Installer] Updated kefinTweaksRootResolved:', current || '(none)', '->', expected); + if (isLoggedInForInjectorWrite()) { + const persisted = await saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + if (persisted && current) { + // Prior pin existed — force reload so scripts/Config stay aligned + maybeQueueResolvedRootUpdateToast({ + symbolicRoot, + previousResolved: current, + nextResolved: expected, + persisted: true + }); + return true; + } + if (persisted && !current) { + // First pin: continue and load injector from the new resolved URL + console.log('[KefinTweaks Installer] First resolved pin persisted; loading injector.js'); + } + } else { + console.warn('[KefinTweaks Installer] kefinTweaksRootResolved updated in memory; not logged in to persist'); + } + } + } catch (e) { + console.warn('[KefinTweaks Installer] Failed to persist kefinTweaksRootResolved:', e); } - } catch (e) { - console.warn('[KefinTweaks Installer] Failed to persist kefinTweaksRootResolved:', e); } + if (window.__kefinTipReloadScheduled) { + console.log('[KefinTweaks Installer] Tip reload scheduled; skipping injector.js load'); + return true; + } + + const injectorUrl = `${resolvedRoot}injector.js`; + // Check if injector is already loaded from this URL const existingScript = document.querySelector(`script[src="${injectorUrl}"]`); if (existingScript) { @@ -1868,41 +2294,45 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; }); } - // Check if config exists; auto-create KefinTweaks-injector when missing; always load live injector.js + // Check if config exists; auto-create bake when missing (admin); never dual-inject when bake attached async function checkAndLoadInjector() { try { - const config = await getKefinTweaksConfig(); - const root = config?.kefinTweaksRoot || ''; + // Prefer in-page Config (already injected). Avoid /Plugins GET on normal page load for non-admins. + let config = window.KefinTweaksConfig || null; + if (!config) { + const admin = await isAdmin(); + if (admin) { + await waitForLoginForInjectorWrite(); + config = await getKefinTweaksConfig(); + } + } // Before injector/utils: hide fallback page chrome for custom routes injectCustomPageStyles(config); + const root = config?.kefinTweaksRoot || ''; if (!root || root === '') { console.log('[KefinTweaks Installer] kefinTweaksRoot not configured. Please configure via the Plugins page.'); return; } - // Plugin config GET/POST needs a session; wait briefly so admin first-load can auto-create - await waitForLoginForInjectorWrite(); - - let injectorConfig = null; - try { - const pluginId = await findJavaScriptInjectorPlugin(); - if (pluginId) { - injectorConfig = await getJavaScriptInjectorConfig(pluginId); + // Bake already on page: never load live injector.js (prevents dual SHA trees) + if (isKefinInjectorAttachedOnPage()) { + if (!(await isAdmin())) { + console.log('[KefinTweaks Installer] KefinTweaks-injector attached; non-admin skipping live injector.js'); + return; } - } catch (e) { - console.warn('[KefinTweaks Installer] Could not read JS Injector config:', e); + console.log('[KefinTweaks Installer] KefinTweaks-injector attached; admin tip sync without live inject'); + await handleAdminAttachedBakeTipSync(config); + return; } - const hasPreload = !!(injectorConfig && hasEnabledKefinInjectorEntry(injectorConfig.CustomJavaScripts)); - if (hasPreload) { - console.log('[KefinTweaks Installer] KefinTweaks-injector present; still loading injector.js for live plan + sync'); - } else { - // Entry missing: try to create + apply assets now (admin/logged-in) + if (await isAdmin()) { + await waitForLoginForInjectorWrite(); const ensured = await ensureKefinTweaksInjectorEntry(config); if (ensured.ok && ensured.plan) { window.KefinTweaksScriptsPreloaded = true; + window.KefinTweaksInjectorAttached = true; if (window.KefinTweaksLoader) { window.KefinTweaksLoader.ensureKefinTweaksApi(window.KefinTweaksLoader.SCRIPT_DEFINITIONS); window.KefinTweaksLoader.applyAssetsToDocument(ensured.plan.assets); @@ -1917,9 +2347,13 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; })); } catch (e) { /* ignore */ } console.log('[KefinTweaks Installer] Applied KefinTweaks-injector assets after auto-create'); - } else { - console.log('[KefinTweaks Installer] KefinTweaks-injector not available (' + (ensured.reason || 'unknown') + '); loading injector.js'); + // Tip advance inside ensure may have scheduled reload; never also load tip injector.js + if (window.__kefinTipReloadScheduled) return; + return; } + console.log('[KefinTweaks Installer] KefinTweaks-injector not available (' + (ensured.reason || 'unknown') + '); loading injector.js'); + } else { + console.log('[KefinTweaks Installer] KefinTweaks-injector not on page (non-admin); loading injector.js'); } await loadInjectorFromRoot(root); @@ -1938,4 +2372,5 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; } console.log('[KefinTweaks Installer] Installer script loaded'); -})(); \ No newline at end of file +})(); + diff --git a/kefinTweaks.minimal.js b/kefinTweaks.minimal.js index 5f8ca4b..82045f5 100644 --- a/kefinTweaks.minimal.js +++ b/kefinTweaks.minimal.js @@ -352,7 +352,8 @@ window.KefinTweaksConfig = { }, "starringActorRecentlyWatched": { "enabled": true, - "name": "Starring [Actor] because you recently watched [Movie]", + "name": "Starring {Person}", + "caption": "because you recently watched {Title}", "itemLimit": 16, "sortOrder": "Random", "sortOrderDirection": "Ascending", @@ -360,7 +361,8 @@ window.KefinTweaksConfig = { }, "directedByDirectorRecentlyWatched": { "enabled": true, - "name": "Directed by [Director] because you recently watched [Movie]", + "name": "Directed by {Person}", + "caption": "because you recently watched {Title}", "itemLimit": 16, "sortOrder": "Random", "sortOrderDirection": "Ascending", @@ -368,7 +370,8 @@ window.KefinTweaksConfig = { }, "writtenByWriterRecentlyWatched": { "enabled": true, - "name": "Written by [Writer] because you recently watched [Movie]", + "name": "Written by {Person}", + "caption": "because you recently watched {Title}", "itemLimit": 16, "sortOrder": "Random", "sortOrderDirection": "Ascending", diff --git a/logo.png b/logo.png index ff6f9a9..85ef84a 100644 Binary files a/logo.png and b/logo.png differ diff --git a/pages/3x3.html b/pages/3x3.html new file mode 100644 index 0000000..8745112 --- /dev/null +++ b/pages/3x3.html @@ -0,0 +1,1063 @@ + + + + + + + + +
+
+
+

Rules:

+
    +
  • 60 seconds to guess as many movies as possible
  • +
  • 9 possible movies to guess at any one time
  • +
  • 3 re-rolls total to roll 9 new images (careful!)
  • +
  • Unlimited guesses
  • +
+
+
+
+ Loading... + + +
+ 01:00 +
Score: 0
+
Re-rolls: 3/3
+
+ + +
+ +
+ Enter your guess: + +
+ +
+ + + +
+
+
+ Enter a name to submit your score

+ Name: + + Score: + + + +
+
+ +
+ + + + + + diff --git a/pages/3x3.js b/pages/3x3.js new file mode 100644 index 0000000..3c5619b --- /dev/null +++ b/pages/3x3.js @@ -0,0 +1,894 @@ +// Jellyfin Quiz Game - Enhanced with animations and preloading +(function() { + 'use strict'; + + if (window.__kefinQuizLoaded) { + console.log('[Quiz] Already initialized; skipping re-entry'); + return; + } + window.__kefinQuizLoaded = true; + + var itemList = []; + var currentItemList = []; + var titlesCorrect = []; + var serverId; + var timer; + var quizScore = 0; + var correctGuesses = 0; + var quickGuesses = 0; + var username = ''; + var resetTimer = false; + var preloadedImages = []; + var imagePreloader = null; + var isQuizActive = false; + var rerollCount = 0; + var maxRerolls = 3; + var correctGuessesData = []; + + // Initialize UI state + document.getElementById('quizInitializeButton').disabled = true; + document.getElementById("quizStartButton").disabled = true; + document.getElementById('quizInitializeButton').style.display = "none"; + document.getElementById('quizStartButton').style.display = "none"; + document.getElementById('quizRerollButton').disabled = true; + document.getElementById('quizRerollButton').style.display = "none"; + + function initializeQuiz(apiClient) { + console.log('initing quiz'); + document.getElementById('quizLoading').classList.remove("hide"); + apiClient.getItems(apiClient.getCurrentUserId(), { + IncludeItemTypes: 'Movie', + Recursive: true, + Fields: 'PrimaryImageAspectRatio,ImageTags,BackdropImageTags' + }).then((data) => { + itemList = (data && data.Items) || []; + currentItemList = itemList.slice(); + document.getElementById('quizLoading').classList.add("hide"); + initializeQuizData(); + }); + + apiClient.getCurrentUser().then((user) => { username = user.Name; }); + + fetch('/files/KefinTweaks/pages/scripts/hiscores.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + } + }) + .then(response => response.text()) + .then(data => { + var hiscores = data.split("\n"); + + hiscores.sort((s1, s2) => { + var s1Value = parseInt(s1.split(';')[1]); + var s2Value = parseInt(s2.split(';')[1]); + return s1Value < s2Value ? 1 : s1Value > s2Value ? -1 : 0; + }); + + var topScores = hiscores.slice(0,10); + + // Create table HTML + var tableHtml = ` +
+

🏆 Leaderboard

+
+
+ + + + + + + + + + + + `; + + topScores.forEach((score, index) => { + if (score.trim()) { + var scoreName = score.split(';')[0]; + var scorePoints = score.split(';')[1]; + var scoreGuesses = score.split(';')[2]; + var scoreDate = score.split(';')[3]; + + // Format date + var date = new Date(scoreDate); + var formattedDate = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + + // Add rank styling + var rankClass = ''; + if (index === 0) rankClass = 'rank-gold'; + else if (index === 1) rankClass = 'rank-silver'; + else if (index === 2) rankClass = 'rank-bronze'; + + tableHtml += ` + + + + + + + + `; + } + }); + + tableHtml += ` + +
#PlayerScoreGuessedDate
${index + 1}${scoreName}${parseInt(scorePoints).toLocaleString()}${scoreGuesses}${formattedDate}
+
+ `; + + document.getElementById("quizHiscoresContainer").innerHTML = tableHtml; + }) + .catch(error => { + console.log('Error fetching hiscores:', error); + }); + } + + function initializeQuizData() { + document.getElementById('quizInputContainer').classList.add("hide"); + document.getElementById('quizInitializeButton').disabled = true; + document.getElementById('quizInitializeButton').style.display = "none"; + document.getElementById("quizStartButton").disabled = true; + document.getElementById('quizContainer').classList.remove("active"); + document.getElementById('quizContainer').classList.add("inactive"); + document.getElementById('quizContainer').classList.add("hide"); + document.getElementById('quizContainer').innerHTML = ''; + document.getElementById("quizTimer").textContent = "01:00"; + document.getElementById("quizTimer").classList.remove("warning"); + document.getElementById('quizScoreDisplay').classList.add("hide"); + document.getElementById('quizRerollDisplay').classList.add("hide"); + document.getElementById('quizControlButtons').classList.add("hide"); + document.getElementById('quizControlButtons').style.display = "none"; + quizScore = 0; + correctGuesses = 0; + quickGuesses = 0; + rerollCount = 0; + titlesCorrect = []; + correctGuessesData = []; + resetTimer = true; + isQuizActive = false; + + // Clear any existing answer overlays + document.querySelectorAll('.answer-overlay').forEach((overlay) => { + overlay.remove(); + }); + + currentItemList = [...itemList]; // Create a copy + + console.log('initing quiz data'); + + // Preload images and add them to quiz container + preloadImages().then(() => { + // Add the preloaded images to the quiz container + for (var i = 0; i < 9; i++) { + addImageToQuiz(i); + } + document.getElementById("quizStartButton").disabled = false; + document.getElementById('quizStartButton').style.display = "inline-block"; + }); + } + + async function preloadImages() { + console.log('Preloading images...'); + preloadedImages = []; + imagePreloader = document.getElementById('imagePreloader'); + imagePreloader.innerHTML = ''; // Clear previous images + + const preloadPromises = []; + + for (let i = 0; i < 9; i++) { + const randomItem = getRandomItem(); + const imageUrl = getImageUrl(randomItem); + console.log(`Preloading image ${i}:`, imageUrl); + + if (!imageUrl) { + console.warn(`No image URL generated for index ${i}, skipping`); + preloadedImages.push({ + item: randomItem, + url: null, + loaded: false + }); + continue; + } + + const preloadPromise = new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + console.log(`Image ${i} loaded successfully`); + preloadedImages.push({ + item: randomItem, + url: imageUrl, + loaded: true + }); + resolve(); + }; + img.onerror = () => { + console.warn('Failed to load image:', imageUrl); + // Try to get a different image + const fallbackItem = getRandomItem(); + const fallbackUrl = getImageUrl(fallbackItem); + if (!fallbackUrl) { + console.warn(`No fallback URL for index ${i}`); + preloadedImages.push({ + item: randomItem, + url: imageUrl, + loaded: false + }); + resolve(); + return; + } + const fallbackImg = new Image(); + fallbackImg.onload = () => { + console.log(`Fallback image ${i} loaded successfully`); + preloadedImages.push({ + item: fallbackItem, + url: fallbackUrl, + loaded: true + }); + resolve(); + }; + fallbackImg.onerror = () => { + console.warn(`Both original and fallback images failed for index ${i}`); + preloadedImages.push({ + item: randomItem, + url: imageUrl, + loaded: false + }); + resolve(); // Continue even if image fails + }; + fallbackImg.src = fallbackUrl; + }; + img.src = imageUrl; + }); + + preloadPromises.push(preloadPromise); + } + + await Promise.all(preloadPromises); + console.log('Image preloading complete:', preloadedImages.length, 'images loaded'); + console.log('Preloaded images array:', preloadedImages); + } + + function getRandomItem() { + if (!itemList || itemList.length === 0) { + return null; + } + + if (currentItemList.length === 0) { + currentItemList = itemList.slice(); + } + + function hasBanner(item) { + return !!(item && item.ImageTags && item.ImageTags.Banner); + } + + function hasBackdrop(item) { + return !!(item && item.BackdropImageTags && item.BackdropImageTags.length > 0); + } + + // Prefer Banner-tagged titles; fall back to any with Backdrop (Banner is often missing). + function pickFromList(predicate) { + var attempts = 0; + var maxAttempts = currentItemList.length * 2; + while (attempts < maxAttempts && currentItemList.length > 0) { + var randomIndex = Math.floor(Math.random() * currentItemList.length); + var candidateItem = currentItemList[randomIndex]; + if (predicate(candidateItem)) { + currentItemList.splice(randomIndex, 1); + return candidateItem; + } + currentItemList.splice(randomIndex, 1); + attempts++; + } + return null; + } + + var randomItem = pickFromList(hasBanner); + if (!randomItem) { + currentItemList = itemList.slice(); + randomItem = pickFromList(hasBackdrop); + } + if (!randomItem) { + console.warn('[Quiz] No movies with Banner or Backdrop images available'); + currentItemList = itemList.slice(); + return null; + } + + console.log('randomItem', randomItem); + return randomItem; + } + + function getImageUrl(item) { + if (!item) return null; + console.log('getting img url for item:', item.Name); + console.log('Item backdrop tags:', item.BackdropImageTags); + var backdropCount = item.BackdropImageTags ? item.BackdropImageTags.length : 0; + if (backdropCount === 0) { + console.warn('No backdrop images for item:', item.Name); + var newItem = getRandomItem(); + if (!newItem) { + console.error('No backdrop images available'); + return null; + } + backdropCount = newItem.BackdropImageTags ? newItem.BackdropImageTags.length : 0; + if (backdropCount === 0) { + console.error('No backdrop images available'); + return null; + } + item = newItem; + } + var imageUrl = ApiClient.getImageUrl(item.Id, { type: 'Backdrop/' + Math.floor(Math.random() * backdropCount) }); + console.log('Generated image URL:', imageUrl); + return imageUrl; + } + + function addImageToQuiz(index) { + console.log('Adding image to quiz at index:', index); + console.log('Preloaded images:', preloadedImages); + + // Use preloaded image if available + if (preloadedImages[index] && preloadedImages[index].loaded && preloadedImages[index].url) { + var imageData = preloadedImages[index]; + var imageUrl = imageData.url; + var randomItem = imageData.item; + console.log('Using preloaded image:', imageUrl); + } else { + // Fallback to generating new image + console.log('Using fallback image generation'); + var randomItem = getRandomItem(); + var imageUrl = getImageUrl(randomItem); + } + + if (!randomItem || !imageUrl) { + console.error('No image URL available for index', index); + return; + } + + var existingImage = document.getElementById('quizContainer').querySelectorAll('img')[index]; + console.log('Existing image at index', index, ':', existingImage); + + var timeStarted = new Date(); + + if (existingImage) { + // Smooth image replacement + console.log('Replacing existing image'); + replaceImageWithAnimation(existingImage, imageUrl, randomItem, timeStarted); + return; + } + + // Create new image container + console.log('Creating new image container'); + var imageContainer = document.createElement("div"); + imageContainer.className = "quiz-image-container"; + + var image = document.createElement("IMG"); + image.src = imageUrl; + image.dataset.movieName = randomItem.Name; + image.dataset.timeStarted = timeStarted.toString(); + image.className = "fade-in"; + + imageContainer.appendChild(image); + document.getElementById('quizContainer').appendChild(imageContainer); + console.log('Added image container to quiz container'); + + // Preload next image for this slot + preloadNextImage(index); + } + + function replaceImageWithAnimation(oldImage, newImageUrl, newItem, timeStarted) { + const container = oldImage.parentElement; + + // Create new image + const newImage = document.createElement("IMG"); + newImage.src = newImageUrl; + newImage.dataset.movieName = newItem.Name; + newImage.dataset.timeStarted = timeStarted.toString(); + newImage.className = "fade-in"; + newImage.style.position = "absolute"; + newImage.style.top = "0"; + newImage.style.left = "0"; + newImage.style.width = "100%"; + newImage.style.height = "100%"; + newImage.style.objectFit = "cover"; + newImage.style.borderRadius = "8px"; + + // Add new image behind old one + container.appendChild(newImage); + + // Animate old image out + oldImage.classList.add("fade-out"); + + // Remove old image after animation + setTimeout(() => { + if (oldImage.parentElement) { + oldImage.parentElement.removeChild(oldImage); + } + newImage.style.position = "static"; + }, 500); + + // Preload next image for this slot + preloadNextImage(Array.from(container.parentElement.children).indexOf(container)); + } + + function preloadNextImage(slotIndex) { + // Preload a new image for this slot + const randomItem = getRandomItem(); + const imageUrl = getImageUrl(randomItem); + if (!randomItem || !imageUrl) return; + + const img = new Image(); + img.onload = () => { + // Store preloaded image for this slot + if (!preloadedImages[slotIndex]) { + preloadedImages[slotIndex] = []; + } + preloadedImages[slotIndex] = { + item: randomItem, + url: imageUrl, + loaded: true + }; + }; + img.src = imageUrl; + } + + function quizListeners() { + console.log('adding listener'); + document.getElementById('quizGuess').addEventListener('input', checkGuess); + + document.getElementById('quizStartButton').addEventListener('click', startQuiz); + document.getElementById('quizInitializeButton').addEventListener('click', initializeQuizData); + document.getElementById('quizRerollButton').addEventListener('click', rerollQuiz); + + // Modal listeners + document.getElementById('modalCloseButton').addEventListener('click', hideScoreModal); + document.getElementById('scoreModal').addEventListener('click', function(e) { + if (e.target === this) { + hideScoreModal(); + } + }); + } + + function startQuiz() { + resetTimer = false; + isQuizActive = true; + document.getElementById('quizHeaderContainer').classList.add("hide"); + document.getElementById('quizContainer').classList.add("active"); + document.getElementById('quizContainer').classList.remove("inactive"); + document.getElementById('quizContainer').classList.remove("hide"); + document.getElementById('quizControlButtons').classList.remove("hide"); + document.getElementById('quizTimerContainer').classList.remove("hide"); + document.getElementById('quizScoreDisplay').classList.add("show"); + document.getElementById('quizRerollDisplay').classList.add("show"); + document.getElementById('quizInitializeButton').disabled = false; + document.getElementById('quizStartButton').disabled = false; + document.getElementById('quizRerollButton').disabled = false; + document.getElementById('quizStartButton').style.display = "none"; + document.getElementById('quizControlButtons').style.display = "flex"; + + // Update reroll display and button + updateRerollDisplay(); + updateRerollButton(); + + // Clear input field and focus + document.getElementById('quizGuess').value = ''; + document.getElementById('quizGuess').focus(); + + startTimer(59, document.getElementById("quizTimer")); + + var quizStartedTime = new Date(); + console.log('setting time started'); + document.querySelectorAll('#quizContainer img').forEach((item) => { + item.dataset["timeStarted"] = quizStartedTime.toString(); + console.log('setting time started'); + }); + document.getElementById("quizGuess").focus(); + } + + function startTimer(duration, display) { + tickTimer(duration, display); + } + + function tickTimer(duration, display) { + timer = duration; + var minutes, seconds; + setTimeout(function () { + minutes = parseInt(timer / 60, 10); + seconds = parseInt(timer % 60, 10); + + minutes = minutes < 10 ? "0" + minutes : minutes; + seconds = seconds < 10 ? "0" + seconds : seconds; + + display.textContent = minutes + ":" + seconds; + + // Add warning animation when time is low + if (timer <= 10 && timer > 0) { + display.classList.add("warning"); + } + + // Update reroll cost display + if (isQuizActive) { + updateRerollButton(); + updateRerollDisplay(); + } + + if (resetTimer) { + timer = 0; + display.textContent = "01:00"; + display.classList.remove("warning"); + resetTimer = false; + } else if (--timer < -1) { + timer = 0; + display.classList.remove("warning"); + finishQuiz(); + } else { + tickTimer(timer, display); + } + }, 1000); + } + + function finishQuiz() { + isQuizActive = false; + document.getElementById("quizStartButton").disabled = true; + // Keep control buttons visible so user can reset quiz + // Don't hide the quiz container, just show answers overlay + showAnswers(); + + console.log('submitting data for ' + username + ' with total ' + correctGuesses + ' correct'); + + // Show modal immediately + showScoreModal(); + + const formData = new FormData(); + formData.append('username', username); + formData.append('score', Math.round(quizScore)); + formData.append('correctItemCount', correctGuesses); + + fetch('/files/KefinTweaks/pages/scripts/quiz.php', { + method: 'POST', + body: formData + }) + .then(response => response.text()) + .then(data => { + console.log(data); + // Update modal message with server response + if (quizScore > 0) { + document.getElementById('modalMessage').textContent = data; + } else { + document.getElementById('modalMessage').textContent = 'Try to finish with more than 0 points next time!'; + } + }) + .catch(error => { + console.log('Error submitting quiz score:', error); + document.getElementById('modalMessage').textContent = 'Error submitting score. Please try again.'; + }); + + document.getElementById("quizTimer").textContent = "01:00"; + document.getElementById("quizTimer").classList.remove("warning"); + } + + function showAnswers() { + // Add answer overlays directly to each image container + document.querySelectorAll('#quizContainer .quiz-image-container').forEach((container) => { + var img = container.querySelector('img'); + if (img) { + // Check if answer overlay already exists + var existingAnswer = container.querySelector('.answer-overlay'); + if (existingAnswer) { + existingAnswer.remove(); + } + + // Create answer overlay + var answerOverlay = document.createElement('div'); + answerOverlay.className = 'answer-overlay'; + answerOverlay.innerHTML = img.dataset.movieName; + + // Style the answer overlay + answerOverlay.style.position = 'absolute'; + answerOverlay.style.bottom = '0'; + answerOverlay.style.left = '0'; + answerOverlay.style.right = '0'; + answerOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'; + answerOverlay.style.color = '#4ecdc4'; + answerOverlay.style.fontSize = '14px'; + answerOverlay.style.fontWeight = 'bold'; + answerOverlay.style.textAlign = 'center'; + answerOverlay.style.padding = '8px'; + answerOverlay.style.borderRadius = '0 0 8px 8px'; + answerOverlay.style.zIndex = '10'; + answerOverlay.style.textShadow = '2px 2px 4px rgba(0,0,0,0.8)'; + answerOverlay.style.lineHeight = '1.2'; + answerOverlay.style.wordWrap = 'break-word'; + answerOverlay.style.boxSizing = 'border-box'; + + // Add to container + container.appendChild(answerOverlay); + } + }); + } + + function checkGuess() { + if (!isQuizActive) return; + + var index = 0; + var quizItems = document.querySelectorAll('#quizContainer img'); + var foundMatch = false; + + quizItems.forEach((item) => { + if (foundMatch) return; + + var guessValue = this.value.replace(/[^a-zA-Z ]/g, ""); + var movieName = item.dataset.movieName.replace(/[^a-zA-Z ]/g, ""); + var similarityScore = similarity(guessValue, movieName); + if (similarityScore > 0.5) { + console.log(`Similarity Score ${similarityScore} for guess ${guessValue} compared to ${movieName}`); + if (similarityScore > 0.95) { + console.log('match based on similarity ' + similarityScore); + + var currentTime = new Date(); + var timeStarted = new Date(item.dataset.timeStarted); + var timePassed = (currentTime - timeStarted) / 1000; + var timeMultiplier = (120 - timePassed) / 120; + var pointsEarned = 120 * timeMultiplier; + titlesCorrect.push(item.dataset.movieName); + quizScore += pointsEarned; + correctGuesses++; + + // Track correct guess data + correctGuessesData.push({ + movieName: item.dataset.movieName, + timePassed: timePassed, + pointsEarned: pointsEarned, + timestamp: currentTime + }); + + console.log('Points earned: ' + pointsEarned + ' for ' + item.dataset.movieName + ' in ' + timePassed.toFixed(2) + ' seconds'); + + // Show points animation + showPointsAnimation(item, Math.round(pointsEarned)); + + // Update score display + updateScoreDisplay(); + + // Replace image + addImageToQuiz(index); + this.value = ''; + foundMatch = true; + return; + } + } + index++; + }); + } + + function showPointsAnimation(imageElement, points) { + const container = imageElement.closest('.quiz-image-container') || imageElement.parentElement; + const rect = container.getBoundingClientRect(); + + const pointsElement = document.createElement('div'); + pointsElement.className = 'points-animation'; + pointsElement.textContent = `+${points}`; + pointsElement.style.left = (rect.left + rect.width / 2) + 'px'; + pointsElement.style.top = (rect.top + rect.height / 2) + 'px'; + + document.body.appendChild(pointsElement); + + // Remove after animation + setTimeout(() => { + if (pointsElement.parentElement) { + pointsElement.parentElement.removeChild(pointsElement); + } + }, 2000); + } + + function showRerollAnimation() { + const remaining = maxRerolls - rerollCount; + const rerollButton = document.getElementById('quizRerollButton'); + const rect = rerollButton.getBoundingClientRect(); + + const rerollElement = document.createElement('div'); + rerollElement.className = 'reroll-animation'; + rerollElement.textContent = `${remaining} rolls remaining`; + rerollElement.style.left = (rect.left + rect.width / 2) + 'px'; + rerollElement.style.top = (rect.top - 20) + 'px'; + + document.body.appendChild(rerollElement); + + // Remove after animation + setTimeout(() => { + if (rerollElement.parentElement) { + rerollElement.parentElement.removeChild(rerollElement); + } + }, 2500); + } + + function updateScoreDisplay() { + const scoreDisplay = document.getElementById('quizScoreDisplay'); + scoreDisplay.textContent = `Score: ${Math.round(quizScore)}`; + scoreDisplay.classList.add('show'); + } + + function calculateRerollCost() { + return 50; + // Cost scales from 200 at start to 120 at end (60 second timer) + // Formula: 200 - (timeElapsed / 60) * 80 + const timeElapsed = 60 - timer; // timer counts down from 60 + const cost = Math.max(50, 200 - (timeElapsed / 60) * 80); + return Math.round(cost); + } + + function updateRerollDisplay() { + const rerollDisplay = document.getElementById('quizRerollDisplay'); + const remaining = maxRerolls - rerollCount; + const currentCost = calculateRerollCost(); + rerollDisplay.textContent = `Re-rolls: ${remaining}/${maxRerolls} (${currentCost} pts)`; + rerollDisplay.classList.add('show'); + } + + function updateRerollButton() { + const rerollButton = document.getElementById('quizRerollButton'); + const currentCost = calculateRerollCost(); + rerollButton.textContent = `Re-roll (-${currentCost} pts)`; + } + + async function rerollQuiz() { + if (rerollCount >= maxRerolls || !isQuizActive) { + return; + } + + // Calculate and deduct dynamic cost + const rerollCost = calculateRerollCost(); + quizScore = Math.max(0, quizScore - rerollCost); + rerollCount++; + + // Show reroll animation + showRerollAnimation(); + + // Update displays + updateScoreDisplay(); + updateRerollDisplay(); + + // Clear and refocus input field + document.getElementById('quizGuess').value = ''; + document.getElementById('quizGuess').focus(); + + // Disable button if no rerolls left + if (rerollCount >= maxRerolls) { + document.getElementById('quizRerollButton').disabled = true; + } + + // Clear existing answer overlays + document.querySelectorAll('.answer-overlay').forEach((overlay) => { + overlay.remove(); + }); + + // Preload new images + await preloadImages(); + + // Clear the entire quiz container + document.getElementById('quizContainer').innerHTML = ''; + + // Add all 9 new images to the grid + for (var i = 0; i < 9; i++) { + addImageToQuiz(i); + } + + console.log('Quiz rerolled! All 9 images replaced.'); + } + + function similarity(s1, s2) { + var longer = s1; + var shorter = s2; + if (s1.length < s2.length) { + longer = s2; + shorter = s1; + } + var longerLength = longer.length; + if (longerLength == 0) { + return 1.0; + } + return (longerLength - editDistance(longer, shorter)) / parseFloat(longerLength); + } + + function editDistance(s1, s2) { + s1 = s1.toLowerCase(); + s2 = s2.toLowerCase(); + + var costs = new Array(); + for (var i = 0; i <= s1.length; i++) { + var lastValue = i; + for (var j = 0; j <= s2.length; j++) { + if (i == 0) + costs[j] = j; + else { + if (j > 0) { + var newValue = costs[j - 1]; + if (s1.charAt(i - 1) != s2.charAt(j - 1)) + newValue = Math.min(Math.min(newValue, lastValue), + costs[j]) + 1; + costs[j - 1] = lastValue; + lastValue = newValue; + } + } + } + if (i > 0) + costs[s2.length] = lastValue; + } + return costs[s2.length]; + } + + function showScoreModal() { + // Update modal content + document.getElementById('modalFinalScore').textContent = Math.round(quizScore); + document.getElementById('modalCorrectGuesses').textContent = correctGuesses; + + // Update correct guesses list + updateCorrectGuessesList(); + + // Show modal + document.getElementById('scoreModal').classList.add('show'); + } + + function updateCorrectGuessesList() { + const guessesList = document.getElementById('modalGuessesList'); + if (!guessesList) return; + + // Sort guesses by time (fastest first) + const sortedGuesses = correctGuessesData.sort((a, b) => a.timePassed - b.timePassed); + + let html = ''; + sortedGuesses.forEach((guess, index) => { + const timeFormatted = guess.timePassed < 1 ? + `${Math.round(guess.timePassed * 1000)}ms` : + `${guess.timePassed.toFixed(1)}s`; + + html += ` +
+ ${index + 1}. + ${guess.movieName} + ${timeFormatted} + +${Math.round(guess.pointsEarned)} +
+ `; + }); + + guessesList.innerHTML = html; + } + + function hideScoreModal() { + document.getElementById('scoreModal').classList.remove('show'); + } + + // Main initialization function + function initializeQuizApp() { + console.log('Initializing quiz app'); + serverId = ApiClient.serverId(); + initializeQuiz(ApiClient); + quizListeners(); + } + + // Initialize the quiz when the script loads + if (typeof ApiClient !== 'undefined') { + // Wait for DOM to be ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeQuizApp); + } else { + initializeQuizApp(); + } + } else { + // Wait for ApiClient to be available + const checkApiClient = setInterval(() => { + if (typeof ApiClient !== 'undefined') { + clearInterval(checkApiClient); + initializeQuizApp(); + } + }, 100); + } +})(); \ No newline at end of file diff --git a/pages/games.html b/pages/games.html new file mode 100644 index 0000000..1228dcc --- /dev/null +++ b/pages/games.html @@ -0,0 +1,107 @@ + + + + + + KefinTweaks Games + + + + + + diff --git a/pages/images/clients/afinity.png b/pages/images/clients/afinity.png new file mode 100644 index 0000000..cf397b6 Binary files /dev/null and b/pages/images/clients/afinity.png differ diff --git a/pages/images/clients/moonfin.png b/pages/images/clients/moonfin.png new file mode 100644 index 0000000..c5cbdda Binary files /dev/null and b/pages/images/clients/moonfin.png differ diff --git a/pages/images/clients/neptune.png b/pages/images/clients/neptune.png new file mode 100644 index 0000000..52e50a0 Binary files /dev/null and b/pages/images/clients/neptune.png differ diff --git a/pages/images/clients/pelagica.png b/pages/images/clients/pelagica.png new file mode 100644 index 0000000..a11da5a Binary files /dev/null and b/pages/images/clients/pelagica.png differ diff --git a/pages/images/clients/streamyfin.png b/pages/images/clients/streamyfin.png new file mode 100644 index 0000000..ede5dc7 Binary files /dev/null and b/pages/images/clients/streamyfin.png differ diff --git a/pages/images/clients/wholphin.png b/pages/images/clients/wholphin.png new file mode 100644 index 0000000..4fa742d Binary files /dev/null and b/pages/images/clients/wholphin.png differ diff --git a/pages/images/collections/addiction.jpg b/pages/images/collections/addiction.jpg new file mode 100644 index 0000000..7de812e Binary files /dev/null and b/pages/images/collections/addiction.jpg differ diff --git a/pages/images/collections/alien.jpg b/pages/images/collections/alien.jpg new file mode 100644 index 0000000..6e4ade4 Binary files /dev/null and b/pages/images/collections/alien.jpg differ diff --git a/pages/images/collections/alternative-reality.jpg b/pages/images/collections/alternative-reality.jpg new file mode 100644 index 0000000..9c6192a Binary files /dev/null and b/pages/images/collections/alternative-reality.jpg differ diff --git a/pages/images/collections/anti-hero.jpg b/pages/images/collections/anti-hero.jpg new file mode 100644 index 0000000..1acd099 Binary files /dev/null and b/pages/images/collections/anti-hero.jpg differ diff --git a/pages/images/collections/artificial-intelligence.jpg b/pages/images/collections/artificial-intelligence.jpg new file mode 100644 index 0000000..2936221 Binary files /dev/null and b/pages/images/collections/artificial-intelligence.jpg differ diff --git a/pages/images/collections/based-on-comic-book.jpg b/pages/images/collections/based-on-comic-book.jpg new file mode 100644 index 0000000..6cfdc49 Binary files /dev/null and b/pages/images/collections/based-on-comic-book.jpg differ diff --git a/pages/images/collections/based-on-memoir.jpg b/pages/images/collections/based-on-memoir.jpg new file mode 100644 index 0000000..8d48274 Binary files /dev/null and b/pages/images/collections/based-on-memoir.jpg differ diff --git a/pages/images/collections/based-on-novel.jpg b/pages/images/collections/based-on-novel.jpg new file mode 100644 index 0000000..6183cac Binary files /dev/null and b/pages/images/collections/based-on-novel.jpg differ diff --git a/pages/images/collections/based-on-play-musical.jpg b/pages/images/collections/based-on-play-musical.jpg new file mode 100644 index 0000000..7c5e259 Binary files /dev/null and b/pages/images/collections/based-on-play-musical.jpg differ diff --git a/pages/images/collections/based-on-true-story.jpg b/pages/images/collections/based-on-true-story.jpg new file mode 100644 index 0000000..34f22b1 Binary files /dev/null and b/pages/images/collections/based-on-true-story.jpg differ diff --git a/pages/images/collections/based-on-video-game.jpg b/pages/images/collections/based-on-video-game.jpg new file mode 100644 index 0000000..ae3603c Binary files /dev/null and b/pages/images/collections/based-on-video-game.jpg differ diff --git a/pages/images/collections/biography.jpg b/pages/images/collections/biography.jpg new file mode 100644 index 0000000..a69e3f7 Binary files /dev/null and b/pages/images/collections/biography.jpg differ diff --git a/pages/images/collections/camp.jpg b/pages/images/collections/camp.jpg new file mode 100644 index 0000000..af8af80 Binary files /dev/null and b/pages/images/collections/camp.jpg differ diff --git a/pages/images/collections/class-differences.jpg b/pages/images/collections/class-differences.jpg new file mode 100644 index 0000000..8d21753 Binary files /dev/null and b/pages/images/collections/class-differences.jpg differ diff --git a/pages/images/collections/coming-of-age.jpg b/pages/images/collections/coming-of-age.jpg new file mode 100644 index 0000000..e4d4f82 Binary files /dev/null and b/pages/images/collections/coming-of-age.jpg differ diff --git a/pages/images/collections/conspiracy.jpg b/pages/images/collections/conspiracy.jpg new file mode 100644 index 0000000..95e79d7 Binary files /dev/null and b/pages/images/collections/conspiracy.jpg differ diff --git a/pages/images/collections/courtroom.jpg b/pages/images/collections/courtroom.jpg new file mode 100644 index 0000000..0a6c61f Binary files /dev/null and b/pages/images/collections/courtroom.jpg differ diff --git a/pages/images/collections/criminal-mastermind.jpg b/pages/images/collections/criminal-mastermind.jpg new file mode 100644 index 0000000..298a7f8 Binary files /dev/null and b/pages/images/collections/criminal-mastermind.jpg differ diff --git a/pages/images/collections/cyberpunk.jpg b/pages/images/collections/cyberpunk.jpg new file mode 100644 index 0000000..08179a6 Binary files /dev/null and b/pages/images/collections/cyberpunk.jpg differ diff --git a/pages/images/collections/dark-comedy.jpg b/pages/images/collections/dark-comedy.jpg new file mode 100644 index 0000000..3f59a1d Binary files /dev/null and b/pages/images/collections/dark-comedy.jpg differ diff --git a/pages/images/collections/dysfunctional-family.jpg b/pages/images/collections/dysfunctional-family.jpg new file mode 100644 index 0000000..9d6dfa1 Binary files /dev/null and b/pages/images/collections/dysfunctional-family.jpg differ diff --git a/pages/images/collections/dystopia.jpg b/pages/images/collections/dystopia.jpg new file mode 100644 index 0000000..f4e8aec Binary files /dev/null and b/pages/images/collections/dystopia.jpg differ diff --git a/pages/images/collections/faith-religion.jpg b/pages/images/collections/faith-religion.jpg new file mode 100644 index 0000000..12412e9 Binary files /dev/null and b/pages/images/collections/faith-religion.jpg differ diff --git a/pages/images/collections/family.jpg b/pages/images/collections/family.jpg new file mode 100644 index 0000000..e151d5e Binary files /dev/null and b/pages/images/collections/family.jpg differ diff --git a/pages/images/collections/female-protagonist.jpg b/pages/images/collections/female-protagonist.jpg new file mode 100644 index 0000000..d6d2f84 Binary files /dev/null and b/pages/images/collections/female-protagonist.jpg differ diff --git a/pages/images/collections/film-noir.jpg b/pages/images/collections/film-noir.jpg new file mode 100644 index 0000000..294824e Binary files /dev/null and b/pages/images/collections/film-noir.jpg differ diff --git a/pages/images/collections/friendship.jpg b/pages/images/collections/friendship.jpg new file mode 100644 index 0000000..f613af3 Binary files /dev/null and b/pages/images/collections/friendship.jpg differ diff --git a/pages/images/collections/gangster.jpg b/pages/images/collections/gangster.jpg new file mode 100644 index 0000000..501fd4b Binary files /dev/null and b/pages/images/collections/gangster.jpg differ diff --git a/pages/images/collections/ghost.jpg b/pages/images/collections/ghost.jpg new file mode 100644 index 0000000..0864868 Binary files /dev/null and b/pages/images/collections/ghost.jpg differ diff --git a/pages/images/collections/heist.jpg b/pages/images/collections/heist.jpg new file mode 100644 index 0000000..71bf9d3 Binary files /dev/null and b/pages/images/collections/heist.jpg differ diff --git a/pages/images/collections/high-school.jpg b/pages/images/collections/high-school.jpg new file mode 100644 index 0000000..3cbbaf4 Binary files /dev/null and b/pages/images/collections/high-school.jpg differ diff --git a/pages/images/collections/identity.jpg b/pages/images/collections/identity.jpg new file mode 100644 index 0000000..6bdbcba Binary files /dev/null and b/pages/images/collections/identity.jpg differ diff --git a/pages/images/collections/island.jpg b/pages/images/collections/island.jpg new file mode 100644 index 0000000..54b4fe9 Binary files /dev/null and b/pages/images/collections/island.jpg differ diff --git a/pages/images/collections/kaiju.jpg b/pages/images/collections/kaiju.jpg new file mode 100644 index 0000000..765f7b8 Binary files /dev/null and b/pages/images/collections/kaiju.jpg differ diff --git a/pages/images/collections/lgbtq.jpg b/pages/images/collections/lgbtq.jpg new file mode 100644 index 0000000..853b594 Binary files /dev/null and b/pages/images/collections/lgbtq.jpg differ diff --git a/pages/images/collections/mafia.jpg b/pages/images/collections/mafia.jpg new file mode 100644 index 0000000..fdee8ad Binary files /dev/null and b/pages/images/collections/mafia.jpg differ diff --git a/pages/images/collections/magic.jpg b/pages/images/collections/magic.jpg new file mode 100644 index 0000000..cb91eb0 Binary files /dev/null and b/pages/images/collections/magic.jpg differ diff --git a/pages/images/collections/medieval.jpg b/pages/images/collections/medieval.jpg new file mode 100644 index 0000000..ada5047 Binary files /dev/null and b/pages/images/collections/medieval.jpg differ diff --git a/pages/images/collections/mental-health.jpg b/pages/images/collections/mental-health.jpg new file mode 100644 index 0000000..6b59066 Binary files /dev/null and b/pages/images/collections/mental-health.jpg differ diff --git a/pages/images/collections/mentor.jpg b/pages/images/collections/mentor.jpg new file mode 100644 index 0000000..f806009 Binary files /dev/null and b/pages/images/collections/mentor.jpg differ diff --git a/pages/images/collections/mockumentary.jpg b/pages/images/collections/mockumentary.jpg new file mode 100644 index 0000000..007cf4b Binary files /dev/null and b/pages/images/collections/mockumentary.jpg differ diff --git a/pages/images/collections/murder.jpg b/pages/images/collections/murder.jpg new file mode 100644 index 0000000..b5a6291 Binary files /dev/null and b/pages/images/collections/murder.jpg differ diff --git a/pages/images/collections/mythology.jpg b/pages/images/collections/mythology.jpg new file mode 100644 index 0000000..cffc80c Binary files /dev/null and b/pages/images/collections/mythology.jpg differ diff --git a/pages/images/collections/neo-noir.jpg b/pages/images/collections/neo-noir.jpg new file mode 100644 index 0000000..74cfe02 Binary files /dev/null and b/pages/images/collections/neo-noir.jpg differ diff --git a/pages/images/collections/organized-crime.jpg b/pages/images/collections/organized-crime.jpg new file mode 100644 index 0000000..10075ce Binary files /dev/null and b/pages/images/collections/organized-crime.jpg differ diff --git a/pages/images/collections/parallel-universe.jpg b/pages/images/collections/parallel-universe.jpg new file mode 100644 index 0000000..33d5fa3 Binary files /dev/null and b/pages/images/collections/parallel-universe.jpg differ diff --git a/pages/images/collections/period-piece.jpg b/pages/images/collections/period-piece.jpg new file mode 100644 index 0000000..029fdae Binary files /dev/null and b/pages/images/collections/period-piece.jpg differ diff --git a/pages/images/collections/post-apocalyptic.jpg b/pages/images/collections/post-apocalyptic.jpg new file mode 100644 index 0000000..d433dd7 Binary files /dev/null and b/pages/images/collections/post-apocalyptic.jpg differ diff --git a/pages/images/collections/prison.jpg b/pages/images/collections/prison.jpg new file mode 100644 index 0000000..7fa6919 Binary files /dev/null and b/pages/images/collections/prison.jpg differ diff --git a/pages/images/collections/psychological-thriller.jpg b/pages/images/collections/psychological-thriller.jpg new file mode 100644 index 0000000..0825b75 Binary files /dev/null and b/pages/images/collections/psychological-thriller.jpg differ diff --git a/pages/images/collections/redemption.jpg b/pages/images/collections/redemption.jpg new file mode 100644 index 0000000..58202f7 Binary files /dev/null and b/pages/images/collections/redemption.jpg differ diff --git a/pages/images/collections/remake-reboot.jpg b/pages/images/collections/remake-reboot.jpg new file mode 100644 index 0000000..bf12b83 Binary files /dev/null and b/pages/images/collections/remake-reboot.jpg differ diff --git a/pages/images/collections/revenge.jpg b/pages/images/collections/revenge.jpg new file mode 100644 index 0000000..0ddfce9 Binary files /dev/null and b/pages/images/collections/revenge.jpg differ diff --git a/pages/images/collections/road-trip.jpg b/pages/images/collections/road-trip.jpg new file mode 100644 index 0000000..502bc2a Binary files /dev/null and b/pages/images/collections/road-trip.jpg differ diff --git a/pages/images/collections/satire.jpg b/pages/images/collections/satire.jpg new file mode 100644 index 0000000..5880e80 Binary files /dev/null and b/pages/images/collections/satire.jpg differ diff --git a/pages/images/collections/serial-killer.jpg b/pages/images/collections/serial-killer.jpg new file mode 100644 index 0000000..e330fc1 Binary files /dev/null and b/pages/images/collections/serial-killer.jpg differ diff --git a/pages/images/collections/small-town.jpg b/pages/images/collections/small-town.jpg new file mode 100644 index 0000000..d688f5b Binary files /dev/null and b/pages/images/collections/small-town.jpg differ diff --git a/pages/images/collections/space.jpg b/pages/images/collections/space.jpg new file mode 100644 index 0000000..88326db Binary files /dev/null and b/pages/images/collections/space.jpg differ diff --git a/pages/images/collections/sports.jpg b/pages/images/collections/sports.jpg new file mode 100644 index 0000000..f40ff41 Binary files /dev/null and b/pages/images/collections/sports.jpg differ diff --git a/pages/images/collections/spy.jpg b/pages/images/collections/spy.jpg new file mode 100644 index 0000000..6d06067 Binary files /dev/null and b/pages/images/collections/spy.jpg differ diff --git a/pages/images/collections/super-power.jpg b/pages/images/collections/super-power.jpg new file mode 100644 index 0000000..74eae38 Binary files /dev/null and b/pages/images/collections/super-power.jpg differ diff --git a/pages/images/collections/superhero.jpg b/pages/images/collections/superhero.jpg new file mode 100644 index 0000000..ccc101d Binary files /dev/null and b/pages/images/collections/superhero.jpg differ diff --git a/pages/images/collections/supernatural.jpg b/pages/images/collections/supernatural.jpg new file mode 100644 index 0000000..4a65498 Binary files /dev/null and b/pages/images/collections/supernatural.jpg differ diff --git a/pages/images/collections/surrealism.jpg b/pages/images/collections/surrealism.jpg new file mode 100644 index 0000000..b3184b7 Binary files /dev/null and b/pages/images/collections/surrealism.jpg differ diff --git a/pages/images/collections/survival.jpg b/pages/images/collections/survival.jpg new file mode 100644 index 0000000..8e9572f Binary files /dev/null and b/pages/images/collections/survival.jpg differ diff --git a/pages/images/collections/time-loop.jpg b/pages/images/collections/time-loop.jpg new file mode 100644 index 0000000..78fbf7b Binary files /dev/null and b/pages/images/collections/time-loop.jpg differ diff --git a/pages/images/collections/time-travel.jpg b/pages/images/collections/time-travel.jpg new file mode 100644 index 0000000..26e4bb8 Binary files /dev/null and b/pages/images/collections/time-travel.jpg differ diff --git a/pages/images/collections/true-crime.jpg b/pages/images/collections/true-crime.jpg new file mode 100644 index 0000000..fdfe766 Binary files /dev/null and b/pages/images/collections/true-crime.jpg differ diff --git a/pages/images/collections/true-story.jpg b/pages/images/collections/true-story.jpg new file mode 100644 index 0000000..a172110 Binary files /dev/null and b/pages/images/collections/true-story.jpg differ diff --git a/pages/images/collections/underdog.jpg b/pages/images/collections/underdog.jpg new file mode 100644 index 0000000..912aab5 Binary files /dev/null and b/pages/images/collections/underdog.jpg differ diff --git a/pages/images/collections/unreliable-narrator.jpg b/pages/images/collections/unreliable-narrator.jpg new file mode 100644 index 0000000..1eca417 Binary files /dev/null and b/pages/images/collections/unreliable-narrator.jpg differ diff --git a/pages/images/collections/vampire.jpg b/pages/images/collections/vampire.jpg new file mode 100644 index 0000000..5169201 Binary files /dev/null and b/pages/images/collections/vampire.jpg differ diff --git a/pages/images/collections/victorian-era.jpg b/pages/images/collections/victorian-era.jpg new file mode 100644 index 0000000..698125d Binary files /dev/null and b/pages/images/collections/victorian-era.jpg differ diff --git a/pages/images/collections/vigilante.jpg b/pages/images/collections/vigilante.jpg new file mode 100644 index 0000000..c945602 Binary files /dev/null and b/pages/images/collections/vigilante.jpg differ diff --git a/pages/images/collections/villain.jpg b/pages/images/collections/villain.jpg new file mode 100644 index 0000000..6658030 Binary files /dev/null and b/pages/images/collections/villain.jpg differ diff --git a/pages/images/collections/virtual-reality.jpg b/pages/images/collections/virtual-reality.jpg new file mode 100644 index 0000000..6dd0b65 Binary files /dev/null and b/pages/images/collections/virtual-reality.jpg differ diff --git a/pages/images/collections/war.jpg b/pages/images/collections/war.jpg new file mode 100644 index 0000000..cd1ec75 Binary files /dev/null and b/pages/images/collections/war.jpg differ diff --git a/pages/images/collections/werewolf.jpg b/pages/images/collections/werewolf.jpg new file mode 100644 index 0000000..dd71f35 Binary files /dev/null and b/pages/images/collections/werewolf.jpg differ diff --git a/pages/images/collections/western.jpg b/pages/images/collections/western.jpg new file mode 100644 index 0000000..8c10cfe Binary files /dev/null and b/pages/images/collections/western.jpg differ diff --git a/pages/images/collections/witchcraft.jpg b/pages/images/collections/witchcraft.jpg new file mode 100644 index 0000000..5799cff Binary files /dev/null and b/pages/images/collections/witchcraft.jpg differ diff --git a/pages/images/collections/workplace.jpg b/pages/images/collections/workplace.jpg new file mode 100644 index 0000000..5254d13 Binary files /dev/null and b/pages/images/collections/workplace.jpg differ diff --git a/pages/images/collections/zombie.jpg b/pages/images/collections/zombie.jpg new file mode 100644 index 0000000..d81cfb4 Binary files /dev/null and b/pages/images/collections/zombie.jpg differ diff --git a/pages/images/corner-decoration.svg b/pages/images/corner-decoration.svg new file mode 100644 index 0000000..01e7edd --- /dev/null +++ b/pages/images/corner-decoration.svg @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pages/images/devices-transparent.png b/pages/images/devices-transparent.png new file mode 100644 index 0000000..65f670c Binary files /dev/null and b/pages/images/devices-transparent.png differ diff --git a/pages/images/devices.png b/pages/images/devices.png new file mode 100644 index 0000000..fde9135 Binary files /dev/null and b/pages/images/devices.png differ diff --git a/pages/images/faq/disablecache.png b/pages/images/faq/disablecache.png new file mode 100644 index 0000000..aa47ec4 Binary files /dev/null and b/pages/images/faq/disablecache.png differ diff --git a/pages/images/faq/pending.png b/pages/images/faq/pending.png new file mode 100644 index 0000000..43ce73a Binary files /dev/null and b/pages/images/faq/pending.png differ diff --git a/pages/images/faq/publicjs.png b/pages/images/faq/publicjs.png new file mode 100644 index 0000000..585aa93 Binary files /dev/null and b/pages/images/faq/publicjs.png differ diff --git a/pages/images/releases/v0.5/borders.webp b/pages/images/releases/v0.5/borders.webp new file mode 100644 index 0000000..c748a9f Binary files /dev/null and b/pages/images/releases/v0.5/borders.webp differ diff --git a/pages/images/releases/v0.5/cardformats.webp b/pages/images/releases/v0.5/cardformats.webp new file mode 100644 index 0000000..20a086c Binary files /dev/null and b/pages/images/releases/v0.5/cardformats.webp differ diff --git a/pages/images/releases/v0.5/spotlights.webp b/pages/images/releases/v0.5/spotlights.webp new file mode 100644 index 0000000..479d542 Binary files /dev/null and b/pages/images/releases/v0.5/spotlights.webp differ diff --git a/pages/images/releases/v0.5/v0.5.png b/pages/images/releases/v0.5/v0.5.png new file mode 100644 index 0000000..1858f98 Binary files /dev/null and b/pages/images/releases/v0.5/v0.5.png differ diff --git a/pages/images/releases/v0.5/v0.5.webp b/pages/images/releases/v0.5/v0.5.webp new file mode 100644 index 0000000..1bef724 Binary files /dev/null and b/pages/images/releases/v0.5/v0.5.webp differ diff --git a/pages/images/releases/v0.5/wizard.webp b/pages/images/releases/v0.5/wizard.webp new file mode 100644 index 0000000..338be8c Binary files /dev/null and b/pages/images/releases/v0.5/wizard.webp differ diff --git a/pages/images/statistics.png b/pages/images/statistics.png index b0fb6f3..02a6b0a 100644 Binary files a/pages/images/statistics.png and b/pages/images/statistics.png differ diff --git a/pages/images/studios/AMC.svg b/pages/images/studios/AMC.svg new file mode 100644 index 0000000..87b39e4 --- /dev/null +++ b/pages/images/studios/AMC.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/pages/images/studios/Apple_TV.svg b/pages/images/studios/Apple_TV.svg new file mode 100644 index 0000000..ebe0487 --- /dev/null +++ b/pages/images/studios/Apple_TV.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/pages/images/studios/Columbia_Pictures.svg b/pages/images/studios/Columbia_Pictures.svg new file mode 100644 index 0000000..6be66a9 --- /dev/null +++ b/pages/images/studios/Columbia_Pictures.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/pages/images/studios/Disney+.svg b/pages/images/studios/Disney+.svg new file mode 100644 index 0000000..98248e4 --- /dev/null +++ b/pages/images/studios/Disney+.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pages/images/studios/DreamWorks_Animation.svg b/pages/images/studios/DreamWorks_Animation.svg new file mode 100644 index 0000000..222ff08 --- /dev/null +++ b/pages/images/studios/DreamWorks_Animation.svg @@ -0,0 +1,51 @@ + +image/svg+xml + + + diff --git a/pages/images/studios/FX.svg b/pages/images/studios/FX.svg new file mode 100644 index 0000000..499b87e --- /dev/null +++ b/pages/images/studios/FX.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/pages/images/studios/HBO.svg b/pages/images/studios/HBO.svg new file mode 100644 index 0000000..46f5122 --- /dev/null +++ b/pages/images/studios/HBO.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pages/images/studios/HBO_Max.svg b/pages/images/studios/HBO_Max.svg new file mode 100644 index 0000000..e2e662a --- /dev/null +++ b/pages/images/studios/HBO_Max.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/pages/images/studios/Hulu.svg b/pages/images/studios/Hulu.svg new file mode 100644 index 0000000..f616925 --- /dev/null +++ b/pages/images/studios/Hulu.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/pages/images/studios/Lucasfilm.svg b/pages/images/studios/Lucasfilm.svg new file mode 100644 index 0000000..1fbcb7e --- /dev/null +++ b/pages/images/studios/Lucasfilm.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pages/images/studios/Marvel_Studios.svg b/pages/images/studios/Marvel_Studios.svg new file mode 100644 index 0000000..15feb9d --- /dev/null +++ b/pages/images/studios/Marvel_Studios.svg @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pages/images/studios/New_Line_Cinema.svg b/pages/images/studios/New_Line_Cinema.svg new file mode 100644 index 0000000..d9b5be8 --- /dev/null +++ b/pages/images/studios/New_Line_Cinema.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/pages/images/studios/Paramount+_with_Showtime.svg b/pages/images/studios/Paramount+_with_Showtime.svg new file mode 100644 index 0000000..a93324f --- /dev/null +++ b/pages/images/studios/Paramount+_with_Showtime.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/images/studios/Paramount_Pictures.svg b/pages/images/studios/Paramount_Pictures.svg new file mode 100644 index 0000000..322e6f6 --- /dev/null +++ b/pages/images/studios/Paramount_Pictures.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/images/studios/Peacock.svg b/pages/images/studios/Peacock.svg new file mode 100644 index 0000000..51f8f00 --- /dev/null +++ b/pages/images/studios/Peacock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/images/studios/Pixar.svg b/pages/images/studios/Pixar.svg new file mode 100644 index 0000000..1ba7e4a --- /dev/null +++ b/pages/images/studios/Pixar.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/pages/images/studios/Prime_Video.svg b/pages/images/studios/Prime_Video.svg new file mode 100644 index 0000000..50a77e7 --- /dev/null +++ b/pages/images/studios/Prime_Video.svg @@ -0,0 +1,3 @@ + + + diff --git a/pages/images/studios/Showtime.svg b/pages/images/studios/Showtime.svg new file mode 100644 index 0000000..7a8987f --- /dev/null +++ b/pages/images/studios/Showtime.svg @@ -0,0 +1,16 @@ + + + + + + + Showtime_logo + + + + + + + + + \ No newline at end of file diff --git a/pages/images/studios/Universal_Pictures.svg b/pages/images/studios/Universal_Pictures.svg new file mode 100644 index 0000000..a4582c0 --- /dev/null +++ b/pages/images/studios/Universal_Pictures.svg @@ -0,0 +1,416 @@ + + + + diff --git a/pages/images/studios/Walt_Disney_Pictures.svg b/pages/images/studios/Walt_Disney_Pictures.svg new file mode 100644 index 0000000..1440f76 --- /dev/null +++ b/pages/images/studios/Walt_Disney_Pictures.svg @@ -0,0 +1,124 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pages/images/studios/Warner_Bros..svg b/pages/images/studios/Warner_Bros..svg new file mode 100644 index 0000000..591118a --- /dev/null +++ b/pages/images/studios/Warner_Bros..svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages/images/valentines.jpg b/pages/images/valentines.jpg new file mode 100644 index 0000000..d1ffa02 Binary files /dev/null and b/pages/images/valentines.jpg differ diff --git a/pages/images/wood.png b/pages/images/wood.png new file mode 100644 index 0000000..52b264a Binary files /dev/null and b/pages/images/wood.png differ diff --git a/pages/movieLut.css b/pages/movieLut.css new file mode 100644 index 0000000..42e57da --- /dev/null +++ b/pages/movieLut.css @@ -0,0 +1,313 @@ +/* Movie LUT – mockup-aligned card layout */ + +:root { + --mlut-bg: #2c2e33; + --mlut-card: #f4f1ea; + --mlut-text: #1a1b1e; + --mlut-muted: #5c5f66; + --mlut-border: #1a1b1e; + --mlut-btn: #1c7ed6; + --mlut-btn-text: #fff; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body.mlut-body { + font-family: + "Segoe UI", + system-ui, + -apple-system, + sans-serif; + background: var(--mlut-bg); + color: #e9ecef; + padding: 1.5rem 0.75rem 3rem; +} + +.mlut-app { + max-width: 1000px; + margin: 0 auto; + overflow: visible; +} + +.mlut-header { + text-align: center; + margin: 1.25rem 0; +} + +.mlut-header h1 { + margin: 0 0 0.35em; + font-size: 1.5rem; + font-weight: 700; + letter-spacing: 0.04em; +} + +.mlut-scorebar { + display: flex; + justify-content: center; + gap: 1.25rem; + margin: 0 0 1rem; + font-size: 0.9rem; + color: #ced4da; +} + +.mlut-scorebar strong { + color: #fff; +} + +.mlut-status { + text-align: center; + min-height: 1.4em; + margin: 0 0 1rem; + font-size: 0.9rem; + color: #ffd43b; +} + +.mlut-status[data-tone="muted"] { + color: #adb5bd; +} + +.mlut-status[data-tone="error"] { + color: #ffa8a8; +} + +.mlut-status[data-tone="ok"] { + color: #8ce99a; +} + +/* Stage: nav outside card (sides of swatch), full-width barcode under options */ +.mlut-stage { + display: grid; + grid-template-columns: 2.5rem minmax(0, 1fr) 2.5rem; + grid-template-rows: auto auto; + column-gap: 0.4rem; + row-gap: 0.35rem; + align-items: stretch; + overflow: visible; +} + +.mlut-card { + grid-column: 2; + grid-row: 1; + background: var(--mlut-card); + color: var(--mlut-text); + border-radius: 14px; + overflow: hidden; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); + display: none; + flex-direction: column; + min-width: 0; +} + +.mlut-card.is-visible { + display: flex; +} + +.mlut-options { + list-style: none; + margin: 0; + padding: 0; +} + +.mlut-option { + display: block; + width: 100%; + text-align: left; + border: none; + border-bottom: 1px solid var(--mlut-border); + background: transparent; + color: var(--mlut-text); + font: inherit; + font-size: 0.95rem; + font-weight: 600; + padding: 0.9rem 1.1rem; + cursor: pointer; + transition: background 0.12s ease; +} + +.mlut-option:last-of-type { + border-bottom: 1px solid var(--mlut-border); +} + +.mlut-option:hover:not(:disabled), +.mlut-option:focus-visible:not(:disabled) { + background: rgba(0, 0, 0, 0.05); + outline: none; +} + +.mlut-option:disabled { + cursor: default; +} + +.mlut-option .mlut-option-letter { + font-weight: 800; + margin-right: 0.35em; +} + +.mlut-option.is-correct { + background: rgba(85, 62, 242, 0.8) !important; +} + +.mlut-option.is-wrong { + background: rgba(197, 14, 14, 0.8) !important; +} + +.mlut-nav-btn { + grid-row: 1; + width: 100%; + height: 100% + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 10px; + background: rgba(0, 0, 0, 0.35); + color: #f1f3f5; + cursor: pointer; + padding: 0; + transition: + background 0.12s ease, + color 0.12s ease; +} + +#mlut-prev { + grid-column: 1; +} + +#mlut-next { + grid-column: 3; +} + +.mlut-nav-btn:hover, +.mlut-nav-btn:focus-visible { + background: rgba(28, 126, 214, 0.55); + color: #fff; + outline: none; +} + +.mlut-nav-btn[hidden] { + /* Keep the side columns so card width stays stable */ + display: flex; + visibility: hidden; + pointer-events: none; +} + +.mlut-swatch-wrap { + position: relative; + width: 100%; + height: 10rem; + background: #111; +} + +.mlut-swatch { + display: block; + width: 100%; + height: 100%; + image-rendering: pixelated; +} + +.mlut-swatch-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: #868e96; + font-size: 0.85rem; + padding: 1rem; + text-align: center; +} + +/* Post-guess stills under card; vertical pointers up to barcode stripes */ +.mlut-reveal { + grid-column: 2; + grid-row: 2; + position: relative; + padding: 0 0 0.5rem; + overflow: visible; +} + +.mlut-reveal[hidden] { + display: none !important; +} + +.mlut-reveal-connectors { + position: absolute; + left: 0; + top: 0; + width: 100%; + pointer-events: none; + overflow: visible; + z-index: 0; +} + +.mlut-reveal-connectors line { + stroke: #dee2e6; + stroke-width: 2; + stroke-linecap: round; +} + +.mlut-reveal-connectors polygon { + fill: #dee2e6; +} + +.mlut-reveal-tiles { + position: relative; + z-index: 1; + display: flex; + justify-content: stretch; + align-items: flex-start; + gap: 0.5rem; + width: 100%; + padding-top: 2.75rem; + overflow: visible; +} + +.mlut-reveal-tile { + /* Equal thirds under the swatch — left/right edges match barcode */ + flex: 1 1 0; + min-width: 0; + margin: 0; +} + +.mlut-reveal-tile canvas, +.mlut-reveal-tile img, +.mlut-reveal-tile .mlut-reveal-ph { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + border-radius: 8px; + border: 2px solid rgba(255, 255, 255, 0.4); + background: #111; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4); +} + +@media (min-width: 480px) { + .mlut-stage { + grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem; + } + + .mlut-swatch-wrap { + height: 12rem; + } + + .mlut-reveal-tiles { + padding-top: 3rem; + gap: 0.55rem; + } +} + +.mlut-footer { + position: fixed; + bottom: 5vh; + left: 50%; + transform: translateX(-50%); +} \ No newline at end of file diff --git a/pages/movieLut.html b/pages/movieLut.html new file mode 100644 index 0000000..0b17d84 --- /dev/null +++ b/pages/movieLut.html @@ -0,0 +1,109 @@ + + + + + + Movie LUT Game + + + +
+
+

Movie LUT Game

+
+ +
+ Score: 0 + Streak: 0 +
+ +

Loading…

+ +
+ + +
+
+
+ +
Preparing swatch…
+
+
+ + + + +
+ +
+ + + diff --git a/pages/movieLut.js b/pages/movieLut.js new file mode 100644 index 0000000..d1d7297 --- /dev/null +++ b/pages/movieLut.js @@ -0,0 +1,887 @@ +// Movie LUT — guess the movie from a trickplay color barcode +(function () { + 'use strict'; + + if (window.__kefinMovieLutLoaded) { + console.log('[MovieLUT] Already initialized; skipping re-entry'); + return; + } + window.__kefinMovieLutLoaded = true; + + const LOG = (...args) => console.log('[MovieLUT]', ...args); + const WARN = (...args) => console.warn('[MovieLUT]', ...args); + + const SAMPLE_COUNT = 256; + const RUNTIME_END_FRAC = 0.95; + const TICKS_PER_MS = 10000; + const MAX_ACTOR_TRIES = 24; + const MAX_MOVIE_PROBES_PER_ACTOR = 40; + const LETTERS = ['A', 'B', 'C']; + + const els = { + status: document.getElementById('mlut-status'), + card: document.getElementById('mlut-card'), + options: document.getElementById('mlut-options'), + swatch: document.getElementById('mlut-swatch'), + placeholder: document.getElementById('mlut-swatch-placeholder'), + prev: document.getElementById('mlut-prev'), + next: document.getElementById('mlut-next'), + score: document.getElementById('mlut-score'), + streak: document.getElementById('mlut-streak'), + reveal: document.getElementById('mlut-reveal'), + revealConnectors: document.getElementById('mlut-reveal-connectors'), + revealTiles: document.getElementById('mlut-reveal-tiles') + }; + + let score = 0; + let streak = 0; + let currentRound = null; + let roundBusy = false; + + /** Full session history of rounds (answered + current). */ + let history = []; + let historyIndex = -1; + + function setStatus(text, tone) { + if (!els.status) return; + els.status.textContent = text || ''; + els.status.dataset.tone = tone || 'muted'; + } + + function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); + } + + function shuffle(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const t = a[i]; + a[i] = a[j]; + a[j] = t; + } + return a; + } + + function pickRandom(arr) { + if (!arr || !arr.length) return null; + return arr[Math.floor(Math.random() * arr.length)]; + } + + function getApiClient() { + if (typeof ApiClient !== 'undefined' && ApiClient) return ApiClient; + if (window.parent && window.parent !== window && window.parent.ApiClient) { + return window.parent.ApiClient; + } + return null; + } + + function getPeopleCache() { + if (window.PeopleCache) return window.PeopleCache; + if (window.parent && window.parent !== window && window.parent.PeopleCache) { + return window.parent.PeopleCache; + } + return null; + } + + function getMoviesCache() { + if (window.MoviesCache) return window.MoviesCache; + if (window.parent && window.parent !== window && window.parent.MoviesCache) { + return window.parent.MoviesCache; + } + return null; + } + + function getApiHelper() { + if (window.apiHelper || window.ApiHelper) return window.apiHelper || window.ApiHelper; + if (window.parent && window.parent !== window) { + return window.parent.apiHelper || window.parent.ApiHelper || null; + } + return null; + } + + async function waitForApiClient(maxMs) { + const limit = maxMs || 15000; + const start = Date.now(); + while (Date.now() - start < limit) { + const client = getApiClient(); + if (client && typeof client.getCurrentUserId === 'function') { + try { + if (client.getCurrentUserId() && client.accessToken && client.accessToken()) { + return client; + } + } catch (e) { /* not ready */ } + } + await sleep(150); + } + return getApiClient(); + } + + async function waitForPeopleCache(maxMs) { + const limit = maxMs || 60000; + const start = Date.now(); + setStatus('Waiting for PeopleCache…', 'muted'); + while (Date.now() - start < limit) { + const cache = getPeopleCache(); + if (cache && typeof cache.getTopActors === 'function') { + try { + const actors = await cache.getTopActors(); + if (Array.isArray(actors) && actors.length > 0) { + return { cache, actors }; + } + if (typeof cache.isComplete === 'function' && cache.isComplete()) { + setStatus('No movies/actors available for Movie LUT.', 'error'); + return null; + } + setStatus('Building people cache (this may take a while on first run)…', 'muted'); + } catch (e) { + WARN('getTopActors failed', e); + } + } + await sleep(500); + } + return null; + } + + function getTrickplayInfoFromItem(item) { + const raw = item && item.Trickplay; + if (!raw || typeof raw !== 'object') return null; + if (typeof raw.Width !== 'undefined' && typeof raw.Interval !== 'undefined') { + return raw; + } + const firstKey = Object.keys(raw)[0]; + if (!firstKey) return null; + const inner = raw[firstKey]; + if (!inner || typeof inner !== 'object') return null; + if (typeof inner.Width !== 'undefined' && typeof inner.Interval !== 'undefined') { + return inner; + } + const secondKey = Object.keys(inner)[0]; + if (!secondKey) return null; + const info = inner[secondKey]; + if (info && typeof info.Width !== 'undefined' && typeof info.Interval !== 'undefined') { + return info; + } + return null; + } + + /** + * Resolve movie stubs for an actor on demand (PeopleCache no longer stores item ids). + * Prefers MoviesCache People arrays; falls back to Items?PersonIds=. + * @returns {Promise>} + */ + async function actorFilmography(actor) { + const actorId = actor?.Id; + if (!actorId) return []; + + const moviesCache = getMoviesCache(); + if (moviesCache?.getMovies) { + try { + const movies = await moviesCache.getMovies(); + const fromCache = (movies || []) + .filter((m) => { + if (!m?.Id || !Array.isArray(m.People)) return false; + return m.People.some((p) => p?.Id === actorId + && (!p.Type || p.Type === 'Actor')); + }) + .map((m) => ({ Id: m.Id })); + if (fromCache.length) return fromCache; + } catch (e) { + WARN('MoviesCache filmography lookup failed', e); + } + } + + const api = getApiHelper(); + const apiClient = getApiClient(); + if (api?.getItems) { + try { + const data = await api.getItems({ + PersonIds: actorId, + IncludeItemTypes: 'Movie', + Recursive: true, + Fields: '', + EnableTotalRecordCount: false, + Limit: 100 + }, false); + const items = data?.Items || []; + return items.filter((m) => m?.Id).map((m) => ({ Id: m.Id })); + } catch (e) { + WARN('PersonIds filmography fetch failed', e); + } + } + + if (apiClient?.getItems) { + try { + const userId = apiClient.getCurrentUserId(); + const data = await apiClient.getItems(userId, { + PersonIds: actorId, + IncludeItemTypes: 'Movie', + Recursive: true, + Fields: '', + EnableTotalRecordCount: false, + Limit: 100 + }); + const items = data?.Items || []; + return items.filter((m) => m?.Id).map((m) => ({ Id: m.Id })); + } catch (e) { + WARN('ApiClient filmography fetch failed', e); + } + } + + return []; + } + + function actorLikelyHasEnoughMovies(actor) { + const movieCount = actor?.movies?.actorCount; + if (Number.isFinite(movieCount) && movieCount >= 3) return true; + const count = actor?.count; + if (Number.isFinite(count) && count >= 3) return true; + return false; + } + + async function fetchFullItem(apiClient, itemId) { + const userId = apiClient.getCurrentUserId(); + if (!userId) return null; + try { + return await apiClient.getItem(userId, itemId); + } catch (e) { + WARN('getItem failed', itemId, e); + return null; + } + } + + function yearOf(item) { + if (item.ProductionYear) return item.ProductionYear; + if (item.PremiereDate) { + const y = new Date(item.PremiereDate).getFullYear(); + if (!Number.isNaN(y)) return y; + } + return null; + } + + function formatTitle(item) { + const y = yearOf(item); + return y != null ? `${item.Name} (${y})` : String(item.Name || 'Unknown'); + } + + function tileRef(trickplayInfo, positionTicks) { + const interval = trickplayInfo.Interval || 10000; + const tileWidth = trickplayInfo.TileWidth || 10; + const tileHeight = trickplayInfo.TileHeight || 10; + const currentTimeMs = positionTicks / TICKS_PER_MS; + const currentTile = Math.floor(currentTimeMs / interval); + const tileSize = tileWidth * tileHeight; + const sheetIndex = Math.floor(currentTile / tileSize); + const tileOffset = currentTile % tileSize; + const tileOffsetX = tileOffset % tileWidth; + const tileOffsetY = Math.floor(tileOffset / tileWidth); + return { sheetIndex, tileOffsetX, tileOffsetY }; + } + + function positionTicksForStripe(item, stripeIndex) { + const runEnd = Math.floor(item.RunTimeTicks * RUNTIME_END_FRAC); + const t = SAMPLE_COUNT <= 1 ? 0 : stripeIndex / (SAMPLE_COUNT - 1); + return Math.floor(runEnd * t); + } + + /** + * One random stripe per segment (frac of barcode width), not equal thirds — + * keeps connectors over the matching full-width tile: + * 0–30% / 35–65% / 70–100%. + */ + function pickThirdStripeIndices() { + const ranges = [ + [0.05, 0.3], + [0.35, 0.65], + [0.7, 0.95] + ]; + return ranges.map(([startFrac, endFrac]) => { + const lo = Math.max(0, Math.floor(startFrac * SAMPLE_COUNT)); + const hi = Math.min(SAMPLE_COUNT - 1, Math.ceil(endFrac * SAMPLE_COUNT) - 1); + const a = Math.min(lo, hi); + const b = Math.max(lo, hi); + return a + Math.floor(Math.random() * (b - a + 1)); + }); + } + + function loadImage(url) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Failed to load sheet')); + img.src = url; + }); + } + + function averageTileRgb(ctx, sx, sy, tw, th) { + let data; + try { + data = ctx.getImageData(sx, sy, tw, th).data; + } catch (e) { + WARN('getImageData blocked (CORS?)', e); + return null; + } + let r = 0; + let g = 0; + let b = 0; + let n = 0; + const step = Math.max(1, Math.floor((tw * th) / 400)); + for (let i = 0; i < data.length; i += 4 * step) { + r += data[i]; + g += data[i + 1]; + b += data[i + 2]; + n++; + } + if (!n) return { r: 0, g: 0, b: 0 }; + return { + r: Math.round(r / n), + g: Math.round(g / n), + b: Math.round(b / n) + }; + } + + async function loadSheetMap(apiClient, item, sheetIndexes) { + const trickplayInfo = getTrickplayInfoFromItem(item); + const mediaSourceId = item.MediaSourceId || item.Id; + const width = trickplayInfo.Width || 320; + const sheetCanvas = new Map(); + + for (const sheetIndex of sheetIndexes) { + if (sheetCanvas.has(sheetIndex)) continue; + const imgSrc = apiClient.getUrl( + 'Videos/' + item.Id + '/Trickplay/' + width + '/' + sheetIndex + '.jpg', + { + ApiKey: apiClient.accessToken(), + MediaSourceId: mediaSourceId + } + ); + try { + const img = await loadImage(imgSrc); + const c = document.createElement('canvas'); + c.width = img.naturalWidth || img.width; + c.height = img.naturalHeight || img.height; + const ctx = c.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(img, 0, 0); + sheetCanvas.set(sheetIndex, { canvas: c, ctx, width: c.width, height: c.height }); + } catch (e) { + WARN('sheet load failed', sheetIndex, e); + } + } + return sheetCanvas; + } + + function cropTileToCanvas(sheet, trickplayInfo, ref) { + const width = trickplayInfo.Width || 320; + const height = trickplayInfo.Height || 180; + const tileWidth = trickplayInfo.TileWidth || 10; + const tileHeight = trickplayInfo.TileHeight || 10; + let sx = ref.tileOffsetX * width; + let sy = ref.tileOffsetY * height; + let tw = width; + let th = height; + if (sx + tw > sheet.width || sy + th > sheet.height) { + const cellW = sheet.width / tileWidth; + const cellH = sheet.height / tileHeight; + sx = Math.floor(ref.tileOffsetX * cellW); + sy = Math.floor(ref.tileOffsetY * cellH); + tw = Math.max(1, Math.floor(cellW)); + th = Math.max(1, Math.floor(cellH)); + } + const out = document.createElement('canvas'); + out.width = tw; + out.height = th; + const octx = out.getContext('2d'); + octx.drawImage(sheet.canvas, sx, sy, tw, th, 0, 0, tw, th); + return out; + } + + async function sampleColorsFromTrickplay(apiClient, item) { + const trickplayInfo = getTrickplayInfoFromItem(item); + if (!trickplayInfo || !item.RunTimeTicks) { + throw new Error('No trickplay data'); + } + + const width = trickplayInfo.Width || 320; + const height = trickplayInfo.Height || 180; + const samples = []; + + for (let i = 0; i < SAMPLE_COUNT; i++) { + const positionTicks = positionTicksForStripe(item, i); + const ref = tileRef(trickplayInfo, positionTicks); + samples.push({ + sheetIndex: ref.sheetIndex, + tileOffsetX: ref.tileOffsetX, + tileOffsetY: ref.tileOffsetY + }); + } + + const sheetsNeeded = [...new Set(samples.map((s) => s.sheetIndex))]; + const sheetCanvas = await loadSheetMap(apiClient, item, sheetsNeeded); + + if (!sheetCanvas.size) { + throw new Error('Could not load any trickplay sheets'); + } + + const tileWidth = trickplayInfo.TileWidth || 10; + const tileHeight = trickplayInfo.TileHeight || 10; + const colors = []; + + for (const sample of samples) { + const sheet = sheetCanvas.get(sample.sheetIndex); + if (!sheet) { + colors.push({ r: 20, g: 20, b: 20 }); + continue; + } + let sx = sample.tileOffsetX * width; + let sy = sample.tileOffsetY * height; + let tw = width; + let th = height; + if (sx + tw > sheet.width || sy + th > sheet.height) { + const cellW = sheet.width / tileWidth; + const cellH = sheet.height / tileHeight; + sx = Math.floor(sample.tileOffsetX * cellW); + sy = Math.floor(sample.tileOffsetY * cellH); + tw = Math.max(1, Math.floor(cellW)); + th = Math.max(1, Math.floor(cellH)); + } + const rgb = averageTileRgb(sheet.ctx, sx, sy, tw, th); + colors.push(rgb || { r: 30, g: 30, b: 30 }); + } + return colors; + } + + /** + * Extract visible stills for three stripe indices (one per third of runtime / barcode). + */ + async function extractRevealStills(apiClient, item, stripeIndices) { + const trickplayInfo = getTrickplayInfoFromItem(item); + if (!trickplayInfo) return []; + + const refs = stripeIndices.map((stripeIndex) => { + const positionTicks = positionTicksForStripe(item, stripeIndex); + const ref = tileRef(trickplayInfo, positionTicks); + return { stripeIndex, ...ref }; + }); + + const sheets = await loadSheetMap( + apiClient, + item, + refs.map((r) => r.sheetIndex) + ); + + return refs.map((ref) => { + const sheet = sheets.get(ref.sheetIndex); + const canvas = sheet ? cropTileToCanvas(sheet, trickplayInfo, ref) : null; + const frac = SAMPLE_COUNT <= 1 ? 0.5 : (ref.stripeIndex + 0.5) / SAMPLE_COUNT; + return { + stripeIndex: ref.stripeIndex, + frac, + canvas + }; + }); + } + + function paintBarcode(canvas, colors) { + if (!canvas || !colors || !colors.length) return; + const dpr = window.devicePixelRatio || 1; + const cssW = canvas.clientWidth || 640; + const cssH = canvas.clientHeight || 200; + canvas.width = Math.max(1, Math.floor(cssW * dpr)); + canvas.height = Math.max(1, Math.floor(cssH * dpr)); + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + const w = cssW; + const h = cssH; + const n = colors.length; + for (let i = 0; i < n; i++) { + const c = colors[i]; + ctx.fillStyle = `rgb(${c.r},${c.g},${c.b})`; + const x0 = (i / n) * w; + const x1 = ((i + 1) / n) * w; + ctx.fillRect(x0, 0, Math.ceil(x1 - x0) + 0.5, h); + } + } + + function updateNavButtons() { + const round = history[historyIndex] || currentRound; + if (els.prev) { + els.prev.hidden = historyIndex <= 0; + } + if (els.next) { + // Forward when current view is already answered + els.next.hidden = !(round && round.guessed); + } + } + + function clearReveal() { + if (els.reveal) els.reveal.hidden = true; + if (els.revealConnectors) els.revealConnectors.innerHTML = ''; + if (els.revealTiles) els.revealTiles.innerHTML = ''; + } + + /** + * Straight vertical lines from each stripe on the barcode down to the tile below. + * Arrowheads sit on the barcode edge and point at the stripe. + */ + function layoutRevealConnectors(stills) { + if (!els.reveal || !els.revealConnectors || !els.swatch || !els.revealTiles) return; + const svg = els.revealConnectors; + svg.innerHTML = ''; + + const revealRect = els.reveal.getBoundingClientRect(); + const swatchRect = els.swatch.getBoundingClientRect(); + if (!revealRect.width || !swatchRect.width) return; + + // Extend SVG upward so connectors can reach the swatch above the reveal block + const lift = Math.max(0, revealRect.top - swatchRect.bottom + 1); + const svgH = revealRect.height + lift; + const svgW = revealRect.width; + + svg.style.top = `${-lift}px`; + svg.style.height = `${svgH}px`; + svg.setAttribute('viewBox', `0 0 ${svgW} ${svgH}`); + svg.setAttribute('width', String(svgW)); + svg.setAttribute('height', String(svgH)); + + const tileEls = els.revealTiles.querySelectorAll('.mlut-reveal-tile'); + const NS = 'http://www.w3.org/2000/svg'; + const arrowH = 10; + const arrowW = 7; + const originTop = revealRect.top - lift; + const originLeft = revealRect.left; + + stills.forEach((still, i) => { + const tileEl = tileEls[i]; + if (!tileEl) return; + + const tileRect = tileEl.getBoundingClientRect(); + // Pure vertical: stripe X, from barcode bottom down to tile top + const x = swatchRect.left + still.frac * swatchRect.width - originLeft; + const yStripe = swatchRect.bottom - originTop; + const yTile = tileRect.top - originTop; + + const line = document.createElementNS(NS, 'line'); + line.setAttribute('x1', String(x)); + line.setAttribute('y1', String(yTile)); + line.setAttribute('x2', String(x)); + line.setAttribute('y2', String(yStripe + arrowH)); + svg.appendChild(line); + + // Arrow pointing up at the stripe (tip on barcode, base toward tile) + const poly = document.createElementNS(NS, 'polygon'); + poly.setAttribute( + 'points', + `${x},${yStripe} ${x - arrowW},${yStripe + arrowH} ${x + arrowW},${yStripe + arrowH}` + ); + svg.appendChild(poly); + }); + } + + function showReveal(stills) { + if (!els.reveal || !els.revealTiles) return; + if (els.revealConnectors) els.revealConnectors.innerHTML = ''; + els.revealTiles.innerHTML = ''; + + stills.forEach((still) => { + const tile = document.createElement('figure'); + tile.className = 'mlut-reveal-tile'; + if (still.canvas) { + // Clone canvas so history restore can re-append without detaching cached ref + const c = document.createElement('canvas'); + c.width = still.canvas.width; + c.height = still.canvas.height; + c.getContext('2d').drawImage(still.canvas, 0, 0); + tile.appendChild(c); + } else { + const ph = document.createElement('div'); + ph.className = 'mlut-reveal-ph'; + tile.appendChild(ph); + } + els.revealTiles.appendChild(tile); + }); + + els.reveal.hidden = false; + requestAnimationFrame(() => { + layoutRevealConnectors(stills); + }); + } + + async function gatherThreeTrickplayMovies(apiClient, actor) { + const filmography = shuffle(await actorFilmography(actor)); + const found = []; + let probes = 0; + for (const entry of filmography) { + if (found.length >= 3) break; + if (probes >= MAX_MOVIE_PROBES_PER_ACTOR) break; + probes++; + const item = await fetchFullItem(apiClient, entry.Id); + if (!item || !item.RunTimeTicks || item.RunTimeTicks <= 0) continue; + if (!getTrickplayInfoFromItem(item)) continue; + if (found.some((f) => f.Id === item.Id)) continue; + found.push(item); + } + return found; + } + + async function pickRound(apiClient, actors) { + const preferred = actors.filter(actorLikelyHasEnoughMovies); + const actorPool = shuffle(preferred.length ? preferred : actors); + let tries = 0; + for (const actor of actorPool) { + if (tries >= MAX_ACTOR_TRIES) break; + tries++; + setStatus(`Checking films for ${actor.Name || 'actor'}…`, 'muted'); + const three = await gatherThreeTrickplayMovies(apiClient, actor); + if (three.length < 3) continue; + const answer = pickRandom(three); + const options = shuffle(three); + return { actor, answer, options }; + } + throw new Error( + 'Could not find an actor with three movies that have trickplay. Generate trickplay for more titles or wait for PeopleCache to finish.' + ); + } + + function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function promptForActor(actor) { + const name = (actor && actor.Name) || 'this actor'; + return `Which ${name} film is this?`; + } + + function renderOptions(round) { + els.options.innerHTML = ''; + + round.options.forEach((item, i) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'mlut-option'; + btn.dataset.itemId = item.Id; + btn.innerHTML = + `${LETTERS[i] || String(i + 1)})` + + escapeHtml(formatTitle(item)); + + if (round.guessed) { + btn.disabled = true; + if (item.Id === round.answer.Id) btn.classList.add('is-correct'); + if (round.selectedItemId && item.Id === round.selectedItemId && item.Id !== round.answer.Id) { + btn.classList.add('is-wrong'); + } + } else { + btn.addEventListener('click', () => onGuess(item.Id)); + } + els.options.appendChild(btn); + }); + } + + function restoreRound(round) { + currentRound = round; + if (!round) { + els.card.classList.remove('is-visible'); + clearReveal(); + updateNavButtons(); + return; + } + + renderOptions(round); + els.card.classList.add('is-visible'); + + if (els.placeholder) { + els.placeholder.style.display = round._colors ? 'none' : 'flex'; + } + + requestAnimationFrame(() => { + if (round._colors) paintBarcode(els.swatch, round._colors); + if (round.guessed && round.stills && round.stills.length) { + showReveal(round.stills); + const correct = round.selectedItemId === round.answer.Id; + setStatus( + correct ? 'Correct!' : `Wrong — it was ${formatTitle(round.answer)}.`, + correct ? 'ok' : 'error' + ); + } else { + clearReveal(); + setStatus(promptForActor(round.actor), 'muted'); + } + updateNavButtons(); + }); + } + + async function onGuess(itemId) { + if (!currentRound || currentRound.guessed || roundBusy) return; + currentRound.guessed = true; + currentRound.selectedItemId = itemId; + + const correctId = currentRound.answer.Id; + const correct = itemId === correctId; + + Array.from(els.options.querySelectorAll('.mlut-option')).forEach((btn) => { + btn.disabled = true; + const id = btn.dataset.itemId; + if (id === correctId) btn.classList.add('is-correct'); + if (id === itemId && !correct) btn.classList.add('is-wrong'); + }); + + if (correct) { + score += 1; + streak += 1; + setStatus('Correct!', 'ok'); + } else { + streak = 0; + setStatus(`Wrong — it was ${formatTitle(currentRound.answer)}.`, 'error'); + } + els.score.textContent = String(score); + els.streak.textContent = String(streak); + updateNavButtons(); + + try { + const apiClient = getApiClient() || currentRound.apiClient; + if (apiClient) { + setStatus( + (correct ? 'Correct! ' : `Wrong — it was ${formatTitle(currentRound.answer)}. `) + + 'Loading stills…', + correct ? 'ok' : 'error' + ); + const stripeIndices = pickThirdStripeIndices(); + const stills = await extractRevealStills(apiClient, currentRound.answer, stripeIndices); + currentRound.stills = stills; + // Only show if user is still on this round + if (history[historyIndex] === currentRound) { + showReveal(stills); + setStatus( + correct ? 'Correct!' : `Wrong — it was ${formatTitle(currentRound.answer)}.`, + correct ? 'ok' : 'error' + ); + } + } + } catch (e) { + WARN('reveal stills failed', e); + setStatus( + correct ? 'Correct!' : `Wrong — it was ${formatTitle(currentRound.answer)}.`, + correct ? 'ok' : 'error' + ); + } + + updateNavButtons(); + } + + async function startRound() { + if (roundBusy) return; + roundBusy = true; + clearReveal(); + if (els.placeholder) { + els.placeholder.style.display = 'flex'; + els.placeholder.textContent = 'Preparing swatch…'; + } + if (els.next) els.next.hidden = true; + + try { + const apiClient = await waitForApiClient(15000); + if (!apiClient) { + throw new Error('ApiClient not available. Open this page from within Jellyfin while logged in.'); + } + + setStatus('Loading top actors…', 'muted'); + const people = await waitForPeopleCache(90000); + if (!people || !people.actors.length) { + throw new Error( + 'You don\'t have enough movies in your library to play this game. Add more movies to your library and try again.' + ); + } + + setStatus('Picking actor and films…', 'muted'); + const round = await pickRound(apiClient, people.actors); + + setStatus(`Building swatch from still frames…`, 'muted'); + const colors = await sampleColorsFromTrickplay(apiClient, round.answer); + + const entry = { + ...round, + guessed: false, + selectedItemId: null, + stills: null, + _colors: colors, + apiClient + }; + + // Drop any future after branching (shouldn't exist); append new + history = history.slice(0, historyIndex + 1); + history.push(entry); + historyIndex = history.length - 1; + currentRound = entry; + + renderOptions(entry); + els.card.classList.add('is-visible'); + + requestAnimationFrame(() => { + paintBarcode(els.swatch, colors); + if (els.placeholder) els.placeholder.style.display = 'none'; + }); + + setStatus(promptForActor(round.actor), 'muted'); + updateNavButtons(); + } catch (e) { + WARN(e); + setStatus(e && e.message ? e.message : String(e), 'error'); + currentRound = history[historyIndex] || null; + // Allow next only if we already finished a round before + updateNavButtons(); + if (!history.length) { + if (els.next) els.next.hidden = false; // retry start via next when cold-fail + } + } finally { + roundBusy = false; + } + } + + function goPrev() { + if (historyIndex <= 0 || roundBusy) return; + historyIndex -= 1; + restoreRound(history[historyIndex]); + } + + async function goNext() { + if (roundBusy) return; + const round = history[historyIndex]; + if (!round || !round.guessed) return; + + if (historyIndex < history.length - 1) { + historyIndex += 1; + restoreRound(history[historyIndex]); + return; + } + + // At tip of history → load a new round + await startRound(); + } + + function wire() { + if (els.prev) els.prev.addEventListener('click', () => goPrev()); + if (els.next) els.next.addEventListener('click', () => goNext()); + window.addEventListener('resize', () => { + if (currentRound && currentRound._colors) { + paintBarcode(els.swatch, currentRound._colors); + } + if (currentRound && currentRound.guessed && currentRound.stills) { + layoutRevealConnectors(currentRound.stills); + } + }); + + startRound(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', wire); + } else { + wire(); + } + + LOG('ready'); +})(); diff --git a/pages/quiz.html b/pages/quiz.html new file mode 100644 index 0000000..1c45f15 --- /dev/null +++ b/pages/quiz.html @@ -0,0 +1,1070 @@ + + + + + + + + +
+
+
+

Rules:

+
    +
  • 60 seconds to guess as many movies as possible
  • +
  • 9 possible movies to guess at any one time
  • +
  • 3 re-rolls total to roll 9 new images (careful!)
  • +
  • Unlimited guesses
  • +
+
+
+
+ Loading... + + +
+ 01:00 +
Score: 0
+
Re-rolls: 3/3
+
+ + +
+ +
+ Enter your guess: + +
+ +
+ + + +
+
+
+ Enter a name to submit your score

+ Name: + + Score: + + + +
+
+
+ +
+ + + + + + diff --git a/pages/quiz.js b/pages/quiz.js new file mode 100644 index 0000000..3c5619b --- /dev/null +++ b/pages/quiz.js @@ -0,0 +1,894 @@ +// Jellyfin Quiz Game - Enhanced with animations and preloading +(function() { + 'use strict'; + + if (window.__kefinQuizLoaded) { + console.log('[Quiz] Already initialized; skipping re-entry'); + return; + } + window.__kefinQuizLoaded = true; + + var itemList = []; + var currentItemList = []; + var titlesCorrect = []; + var serverId; + var timer; + var quizScore = 0; + var correctGuesses = 0; + var quickGuesses = 0; + var username = ''; + var resetTimer = false; + var preloadedImages = []; + var imagePreloader = null; + var isQuizActive = false; + var rerollCount = 0; + var maxRerolls = 3; + var correctGuessesData = []; + + // Initialize UI state + document.getElementById('quizInitializeButton').disabled = true; + document.getElementById("quizStartButton").disabled = true; + document.getElementById('quizInitializeButton').style.display = "none"; + document.getElementById('quizStartButton').style.display = "none"; + document.getElementById('quizRerollButton').disabled = true; + document.getElementById('quizRerollButton').style.display = "none"; + + function initializeQuiz(apiClient) { + console.log('initing quiz'); + document.getElementById('quizLoading').classList.remove("hide"); + apiClient.getItems(apiClient.getCurrentUserId(), { + IncludeItemTypes: 'Movie', + Recursive: true, + Fields: 'PrimaryImageAspectRatio,ImageTags,BackdropImageTags' + }).then((data) => { + itemList = (data && data.Items) || []; + currentItemList = itemList.slice(); + document.getElementById('quizLoading').classList.add("hide"); + initializeQuizData(); + }); + + apiClient.getCurrentUser().then((user) => { username = user.Name; }); + + fetch('/files/KefinTweaks/pages/scripts/hiscores.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + } + }) + .then(response => response.text()) + .then(data => { + var hiscores = data.split("\n"); + + hiscores.sort((s1, s2) => { + var s1Value = parseInt(s1.split(';')[1]); + var s2Value = parseInt(s2.split(';')[1]); + return s1Value < s2Value ? 1 : s1Value > s2Value ? -1 : 0; + }); + + var topScores = hiscores.slice(0,10); + + // Create table HTML + var tableHtml = ` +
+

🏆 Leaderboard

+
+
+ + + + + + + + + + + + `; + + topScores.forEach((score, index) => { + if (score.trim()) { + var scoreName = score.split(';')[0]; + var scorePoints = score.split(';')[1]; + var scoreGuesses = score.split(';')[2]; + var scoreDate = score.split(';')[3]; + + // Format date + var date = new Date(scoreDate); + var formattedDate = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + + // Add rank styling + var rankClass = ''; + if (index === 0) rankClass = 'rank-gold'; + else if (index === 1) rankClass = 'rank-silver'; + else if (index === 2) rankClass = 'rank-bronze'; + + tableHtml += ` + + + + + + + + `; + } + }); + + tableHtml += ` + +
#PlayerScoreGuessedDate
${index + 1}${scoreName}${parseInt(scorePoints).toLocaleString()}${scoreGuesses}${formattedDate}
+
+ `; + + document.getElementById("quizHiscoresContainer").innerHTML = tableHtml; + }) + .catch(error => { + console.log('Error fetching hiscores:', error); + }); + } + + function initializeQuizData() { + document.getElementById('quizInputContainer').classList.add("hide"); + document.getElementById('quizInitializeButton').disabled = true; + document.getElementById('quizInitializeButton').style.display = "none"; + document.getElementById("quizStartButton").disabled = true; + document.getElementById('quizContainer').classList.remove("active"); + document.getElementById('quizContainer').classList.add("inactive"); + document.getElementById('quizContainer').classList.add("hide"); + document.getElementById('quizContainer').innerHTML = ''; + document.getElementById("quizTimer").textContent = "01:00"; + document.getElementById("quizTimer").classList.remove("warning"); + document.getElementById('quizScoreDisplay').classList.add("hide"); + document.getElementById('quizRerollDisplay').classList.add("hide"); + document.getElementById('quizControlButtons').classList.add("hide"); + document.getElementById('quizControlButtons').style.display = "none"; + quizScore = 0; + correctGuesses = 0; + quickGuesses = 0; + rerollCount = 0; + titlesCorrect = []; + correctGuessesData = []; + resetTimer = true; + isQuizActive = false; + + // Clear any existing answer overlays + document.querySelectorAll('.answer-overlay').forEach((overlay) => { + overlay.remove(); + }); + + currentItemList = [...itemList]; // Create a copy + + console.log('initing quiz data'); + + // Preload images and add them to quiz container + preloadImages().then(() => { + // Add the preloaded images to the quiz container + for (var i = 0; i < 9; i++) { + addImageToQuiz(i); + } + document.getElementById("quizStartButton").disabled = false; + document.getElementById('quizStartButton').style.display = "inline-block"; + }); + } + + async function preloadImages() { + console.log('Preloading images...'); + preloadedImages = []; + imagePreloader = document.getElementById('imagePreloader'); + imagePreloader.innerHTML = ''; // Clear previous images + + const preloadPromises = []; + + for (let i = 0; i < 9; i++) { + const randomItem = getRandomItem(); + const imageUrl = getImageUrl(randomItem); + console.log(`Preloading image ${i}:`, imageUrl); + + if (!imageUrl) { + console.warn(`No image URL generated for index ${i}, skipping`); + preloadedImages.push({ + item: randomItem, + url: null, + loaded: false + }); + continue; + } + + const preloadPromise = new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + console.log(`Image ${i} loaded successfully`); + preloadedImages.push({ + item: randomItem, + url: imageUrl, + loaded: true + }); + resolve(); + }; + img.onerror = () => { + console.warn('Failed to load image:', imageUrl); + // Try to get a different image + const fallbackItem = getRandomItem(); + const fallbackUrl = getImageUrl(fallbackItem); + if (!fallbackUrl) { + console.warn(`No fallback URL for index ${i}`); + preloadedImages.push({ + item: randomItem, + url: imageUrl, + loaded: false + }); + resolve(); + return; + } + const fallbackImg = new Image(); + fallbackImg.onload = () => { + console.log(`Fallback image ${i} loaded successfully`); + preloadedImages.push({ + item: fallbackItem, + url: fallbackUrl, + loaded: true + }); + resolve(); + }; + fallbackImg.onerror = () => { + console.warn(`Both original and fallback images failed for index ${i}`); + preloadedImages.push({ + item: randomItem, + url: imageUrl, + loaded: false + }); + resolve(); // Continue even if image fails + }; + fallbackImg.src = fallbackUrl; + }; + img.src = imageUrl; + }); + + preloadPromises.push(preloadPromise); + } + + await Promise.all(preloadPromises); + console.log('Image preloading complete:', preloadedImages.length, 'images loaded'); + console.log('Preloaded images array:', preloadedImages); + } + + function getRandomItem() { + if (!itemList || itemList.length === 0) { + return null; + } + + if (currentItemList.length === 0) { + currentItemList = itemList.slice(); + } + + function hasBanner(item) { + return !!(item && item.ImageTags && item.ImageTags.Banner); + } + + function hasBackdrop(item) { + return !!(item && item.BackdropImageTags && item.BackdropImageTags.length > 0); + } + + // Prefer Banner-tagged titles; fall back to any with Backdrop (Banner is often missing). + function pickFromList(predicate) { + var attempts = 0; + var maxAttempts = currentItemList.length * 2; + while (attempts < maxAttempts && currentItemList.length > 0) { + var randomIndex = Math.floor(Math.random() * currentItemList.length); + var candidateItem = currentItemList[randomIndex]; + if (predicate(candidateItem)) { + currentItemList.splice(randomIndex, 1); + return candidateItem; + } + currentItemList.splice(randomIndex, 1); + attempts++; + } + return null; + } + + var randomItem = pickFromList(hasBanner); + if (!randomItem) { + currentItemList = itemList.slice(); + randomItem = pickFromList(hasBackdrop); + } + if (!randomItem) { + console.warn('[Quiz] No movies with Banner or Backdrop images available'); + currentItemList = itemList.slice(); + return null; + } + + console.log('randomItem', randomItem); + return randomItem; + } + + function getImageUrl(item) { + if (!item) return null; + console.log('getting img url for item:', item.Name); + console.log('Item backdrop tags:', item.BackdropImageTags); + var backdropCount = item.BackdropImageTags ? item.BackdropImageTags.length : 0; + if (backdropCount === 0) { + console.warn('No backdrop images for item:', item.Name); + var newItem = getRandomItem(); + if (!newItem) { + console.error('No backdrop images available'); + return null; + } + backdropCount = newItem.BackdropImageTags ? newItem.BackdropImageTags.length : 0; + if (backdropCount === 0) { + console.error('No backdrop images available'); + return null; + } + item = newItem; + } + var imageUrl = ApiClient.getImageUrl(item.Id, { type: 'Backdrop/' + Math.floor(Math.random() * backdropCount) }); + console.log('Generated image URL:', imageUrl); + return imageUrl; + } + + function addImageToQuiz(index) { + console.log('Adding image to quiz at index:', index); + console.log('Preloaded images:', preloadedImages); + + // Use preloaded image if available + if (preloadedImages[index] && preloadedImages[index].loaded && preloadedImages[index].url) { + var imageData = preloadedImages[index]; + var imageUrl = imageData.url; + var randomItem = imageData.item; + console.log('Using preloaded image:', imageUrl); + } else { + // Fallback to generating new image + console.log('Using fallback image generation'); + var randomItem = getRandomItem(); + var imageUrl = getImageUrl(randomItem); + } + + if (!randomItem || !imageUrl) { + console.error('No image URL available for index', index); + return; + } + + var existingImage = document.getElementById('quizContainer').querySelectorAll('img')[index]; + console.log('Existing image at index', index, ':', existingImage); + + var timeStarted = new Date(); + + if (existingImage) { + // Smooth image replacement + console.log('Replacing existing image'); + replaceImageWithAnimation(existingImage, imageUrl, randomItem, timeStarted); + return; + } + + // Create new image container + console.log('Creating new image container'); + var imageContainer = document.createElement("div"); + imageContainer.className = "quiz-image-container"; + + var image = document.createElement("IMG"); + image.src = imageUrl; + image.dataset.movieName = randomItem.Name; + image.dataset.timeStarted = timeStarted.toString(); + image.className = "fade-in"; + + imageContainer.appendChild(image); + document.getElementById('quizContainer').appendChild(imageContainer); + console.log('Added image container to quiz container'); + + // Preload next image for this slot + preloadNextImage(index); + } + + function replaceImageWithAnimation(oldImage, newImageUrl, newItem, timeStarted) { + const container = oldImage.parentElement; + + // Create new image + const newImage = document.createElement("IMG"); + newImage.src = newImageUrl; + newImage.dataset.movieName = newItem.Name; + newImage.dataset.timeStarted = timeStarted.toString(); + newImage.className = "fade-in"; + newImage.style.position = "absolute"; + newImage.style.top = "0"; + newImage.style.left = "0"; + newImage.style.width = "100%"; + newImage.style.height = "100%"; + newImage.style.objectFit = "cover"; + newImage.style.borderRadius = "8px"; + + // Add new image behind old one + container.appendChild(newImage); + + // Animate old image out + oldImage.classList.add("fade-out"); + + // Remove old image after animation + setTimeout(() => { + if (oldImage.parentElement) { + oldImage.parentElement.removeChild(oldImage); + } + newImage.style.position = "static"; + }, 500); + + // Preload next image for this slot + preloadNextImage(Array.from(container.parentElement.children).indexOf(container)); + } + + function preloadNextImage(slotIndex) { + // Preload a new image for this slot + const randomItem = getRandomItem(); + const imageUrl = getImageUrl(randomItem); + if (!randomItem || !imageUrl) return; + + const img = new Image(); + img.onload = () => { + // Store preloaded image for this slot + if (!preloadedImages[slotIndex]) { + preloadedImages[slotIndex] = []; + } + preloadedImages[slotIndex] = { + item: randomItem, + url: imageUrl, + loaded: true + }; + }; + img.src = imageUrl; + } + + function quizListeners() { + console.log('adding listener'); + document.getElementById('quizGuess').addEventListener('input', checkGuess); + + document.getElementById('quizStartButton').addEventListener('click', startQuiz); + document.getElementById('quizInitializeButton').addEventListener('click', initializeQuizData); + document.getElementById('quizRerollButton').addEventListener('click', rerollQuiz); + + // Modal listeners + document.getElementById('modalCloseButton').addEventListener('click', hideScoreModal); + document.getElementById('scoreModal').addEventListener('click', function(e) { + if (e.target === this) { + hideScoreModal(); + } + }); + } + + function startQuiz() { + resetTimer = false; + isQuizActive = true; + document.getElementById('quizHeaderContainer').classList.add("hide"); + document.getElementById('quizContainer').classList.add("active"); + document.getElementById('quizContainer').classList.remove("inactive"); + document.getElementById('quizContainer').classList.remove("hide"); + document.getElementById('quizControlButtons').classList.remove("hide"); + document.getElementById('quizTimerContainer').classList.remove("hide"); + document.getElementById('quizScoreDisplay').classList.add("show"); + document.getElementById('quizRerollDisplay').classList.add("show"); + document.getElementById('quizInitializeButton').disabled = false; + document.getElementById('quizStartButton').disabled = false; + document.getElementById('quizRerollButton').disabled = false; + document.getElementById('quizStartButton').style.display = "none"; + document.getElementById('quizControlButtons').style.display = "flex"; + + // Update reroll display and button + updateRerollDisplay(); + updateRerollButton(); + + // Clear input field and focus + document.getElementById('quizGuess').value = ''; + document.getElementById('quizGuess').focus(); + + startTimer(59, document.getElementById("quizTimer")); + + var quizStartedTime = new Date(); + console.log('setting time started'); + document.querySelectorAll('#quizContainer img').forEach((item) => { + item.dataset["timeStarted"] = quizStartedTime.toString(); + console.log('setting time started'); + }); + document.getElementById("quizGuess").focus(); + } + + function startTimer(duration, display) { + tickTimer(duration, display); + } + + function tickTimer(duration, display) { + timer = duration; + var minutes, seconds; + setTimeout(function () { + minutes = parseInt(timer / 60, 10); + seconds = parseInt(timer % 60, 10); + + minutes = minutes < 10 ? "0" + minutes : minutes; + seconds = seconds < 10 ? "0" + seconds : seconds; + + display.textContent = minutes + ":" + seconds; + + // Add warning animation when time is low + if (timer <= 10 && timer > 0) { + display.classList.add("warning"); + } + + // Update reroll cost display + if (isQuizActive) { + updateRerollButton(); + updateRerollDisplay(); + } + + if (resetTimer) { + timer = 0; + display.textContent = "01:00"; + display.classList.remove("warning"); + resetTimer = false; + } else if (--timer < -1) { + timer = 0; + display.classList.remove("warning"); + finishQuiz(); + } else { + tickTimer(timer, display); + } + }, 1000); + } + + function finishQuiz() { + isQuizActive = false; + document.getElementById("quizStartButton").disabled = true; + // Keep control buttons visible so user can reset quiz + // Don't hide the quiz container, just show answers overlay + showAnswers(); + + console.log('submitting data for ' + username + ' with total ' + correctGuesses + ' correct'); + + // Show modal immediately + showScoreModal(); + + const formData = new FormData(); + formData.append('username', username); + formData.append('score', Math.round(quizScore)); + formData.append('correctItemCount', correctGuesses); + + fetch('/files/KefinTweaks/pages/scripts/quiz.php', { + method: 'POST', + body: formData + }) + .then(response => response.text()) + .then(data => { + console.log(data); + // Update modal message with server response + if (quizScore > 0) { + document.getElementById('modalMessage').textContent = data; + } else { + document.getElementById('modalMessage').textContent = 'Try to finish with more than 0 points next time!'; + } + }) + .catch(error => { + console.log('Error submitting quiz score:', error); + document.getElementById('modalMessage').textContent = 'Error submitting score. Please try again.'; + }); + + document.getElementById("quizTimer").textContent = "01:00"; + document.getElementById("quizTimer").classList.remove("warning"); + } + + function showAnswers() { + // Add answer overlays directly to each image container + document.querySelectorAll('#quizContainer .quiz-image-container').forEach((container) => { + var img = container.querySelector('img'); + if (img) { + // Check if answer overlay already exists + var existingAnswer = container.querySelector('.answer-overlay'); + if (existingAnswer) { + existingAnswer.remove(); + } + + // Create answer overlay + var answerOverlay = document.createElement('div'); + answerOverlay.className = 'answer-overlay'; + answerOverlay.innerHTML = img.dataset.movieName; + + // Style the answer overlay + answerOverlay.style.position = 'absolute'; + answerOverlay.style.bottom = '0'; + answerOverlay.style.left = '0'; + answerOverlay.style.right = '0'; + answerOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'; + answerOverlay.style.color = '#4ecdc4'; + answerOverlay.style.fontSize = '14px'; + answerOverlay.style.fontWeight = 'bold'; + answerOverlay.style.textAlign = 'center'; + answerOverlay.style.padding = '8px'; + answerOverlay.style.borderRadius = '0 0 8px 8px'; + answerOverlay.style.zIndex = '10'; + answerOverlay.style.textShadow = '2px 2px 4px rgba(0,0,0,0.8)'; + answerOverlay.style.lineHeight = '1.2'; + answerOverlay.style.wordWrap = 'break-word'; + answerOverlay.style.boxSizing = 'border-box'; + + // Add to container + container.appendChild(answerOverlay); + } + }); + } + + function checkGuess() { + if (!isQuizActive) return; + + var index = 0; + var quizItems = document.querySelectorAll('#quizContainer img'); + var foundMatch = false; + + quizItems.forEach((item) => { + if (foundMatch) return; + + var guessValue = this.value.replace(/[^a-zA-Z ]/g, ""); + var movieName = item.dataset.movieName.replace(/[^a-zA-Z ]/g, ""); + var similarityScore = similarity(guessValue, movieName); + if (similarityScore > 0.5) { + console.log(`Similarity Score ${similarityScore} for guess ${guessValue} compared to ${movieName}`); + if (similarityScore > 0.95) { + console.log('match based on similarity ' + similarityScore); + + var currentTime = new Date(); + var timeStarted = new Date(item.dataset.timeStarted); + var timePassed = (currentTime - timeStarted) / 1000; + var timeMultiplier = (120 - timePassed) / 120; + var pointsEarned = 120 * timeMultiplier; + titlesCorrect.push(item.dataset.movieName); + quizScore += pointsEarned; + correctGuesses++; + + // Track correct guess data + correctGuessesData.push({ + movieName: item.dataset.movieName, + timePassed: timePassed, + pointsEarned: pointsEarned, + timestamp: currentTime + }); + + console.log('Points earned: ' + pointsEarned + ' for ' + item.dataset.movieName + ' in ' + timePassed.toFixed(2) + ' seconds'); + + // Show points animation + showPointsAnimation(item, Math.round(pointsEarned)); + + // Update score display + updateScoreDisplay(); + + // Replace image + addImageToQuiz(index); + this.value = ''; + foundMatch = true; + return; + } + } + index++; + }); + } + + function showPointsAnimation(imageElement, points) { + const container = imageElement.closest('.quiz-image-container') || imageElement.parentElement; + const rect = container.getBoundingClientRect(); + + const pointsElement = document.createElement('div'); + pointsElement.className = 'points-animation'; + pointsElement.textContent = `+${points}`; + pointsElement.style.left = (rect.left + rect.width / 2) + 'px'; + pointsElement.style.top = (rect.top + rect.height / 2) + 'px'; + + document.body.appendChild(pointsElement); + + // Remove after animation + setTimeout(() => { + if (pointsElement.parentElement) { + pointsElement.parentElement.removeChild(pointsElement); + } + }, 2000); + } + + function showRerollAnimation() { + const remaining = maxRerolls - rerollCount; + const rerollButton = document.getElementById('quizRerollButton'); + const rect = rerollButton.getBoundingClientRect(); + + const rerollElement = document.createElement('div'); + rerollElement.className = 'reroll-animation'; + rerollElement.textContent = `${remaining} rolls remaining`; + rerollElement.style.left = (rect.left + rect.width / 2) + 'px'; + rerollElement.style.top = (rect.top - 20) + 'px'; + + document.body.appendChild(rerollElement); + + // Remove after animation + setTimeout(() => { + if (rerollElement.parentElement) { + rerollElement.parentElement.removeChild(rerollElement); + } + }, 2500); + } + + function updateScoreDisplay() { + const scoreDisplay = document.getElementById('quizScoreDisplay'); + scoreDisplay.textContent = `Score: ${Math.round(quizScore)}`; + scoreDisplay.classList.add('show'); + } + + function calculateRerollCost() { + return 50; + // Cost scales from 200 at start to 120 at end (60 second timer) + // Formula: 200 - (timeElapsed / 60) * 80 + const timeElapsed = 60 - timer; // timer counts down from 60 + const cost = Math.max(50, 200 - (timeElapsed / 60) * 80); + return Math.round(cost); + } + + function updateRerollDisplay() { + const rerollDisplay = document.getElementById('quizRerollDisplay'); + const remaining = maxRerolls - rerollCount; + const currentCost = calculateRerollCost(); + rerollDisplay.textContent = `Re-rolls: ${remaining}/${maxRerolls} (${currentCost} pts)`; + rerollDisplay.classList.add('show'); + } + + function updateRerollButton() { + const rerollButton = document.getElementById('quizRerollButton'); + const currentCost = calculateRerollCost(); + rerollButton.textContent = `Re-roll (-${currentCost} pts)`; + } + + async function rerollQuiz() { + if (rerollCount >= maxRerolls || !isQuizActive) { + return; + } + + // Calculate and deduct dynamic cost + const rerollCost = calculateRerollCost(); + quizScore = Math.max(0, quizScore - rerollCost); + rerollCount++; + + // Show reroll animation + showRerollAnimation(); + + // Update displays + updateScoreDisplay(); + updateRerollDisplay(); + + // Clear and refocus input field + document.getElementById('quizGuess').value = ''; + document.getElementById('quizGuess').focus(); + + // Disable button if no rerolls left + if (rerollCount >= maxRerolls) { + document.getElementById('quizRerollButton').disabled = true; + } + + // Clear existing answer overlays + document.querySelectorAll('.answer-overlay').forEach((overlay) => { + overlay.remove(); + }); + + // Preload new images + await preloadImages(); + + // Clear the entire quiz container + document.getElementById('quizContainer').innerHTML = ''; + + // Add all 9 new images to the grid + for (var i = 0; i < 9; i++) { + addImageToQuiz(i); + } + + console.log('Quiz rerolled! All 9 images replaced.'); + } + + function similarity(s1, s2) { + var longer = s1; + var shorter = s2; + if (s1.length < s2.length) { + longer = s2; + shorter = s1; + } + var longerLength = longer.length; + if (longerLength == 0) { + return 1.0; + } + return (longerLength - editDistance(longer, shorter)) / parseFloat(longerLength); + } + + function editDistance(s1, s2) { + s1 = s1.toLowerCase(); + s2 = s2.toLowerCase(); + + var costs = new Array(); + for (var i = 0; i <= s1.length; i++) { + var lastValue = i; + for (var j = 0; j <= s2.length; j++) { + if (i == 0) + costs[j] = j; + else { + if (j > 0) { + var newValue = costs[j - 1]; + if (s1.charAt(i - 1) != s2.charAt(j - 1)) + newValue = Math.min(Math.min(newValue, lastValue), + costs[j]) + 1; + costs[j - 1] = lastValue; + lastValue = newValue; + } + } + } + if (i > 0) + costs[s2.length] = lastValue; + } + return costs[s2.length]; + } + + function showScoreModal() { + // Update modal content + document.getElementById('modalFinalScore').textContent = Math.round(quizScore); + document.getElementById('modalCorrectGuesses').textContent = correctGuesses; + + // Update correct guesses list + updateCorrectGuessesList(); + + // Show modal + document.getElementById('scoreModal').classList.add('show'); + } + + function updateCorrectGuessesList() { + const guessesList = document.getElementById('modalGuessesList'); + if (!guessesList) return; + + // Sort guesses by time (fastest first) + const sortedGuesses = correctGuessesData.sort((a, b) => a.timePassed - b.timePassed); + + let html = ''; + sortedGuesses.forEach((guess, index) => { + const timeFormatted = guess.timePassed < 1 ? + `${Math.round(guess.timePassed * 1000)}ms` : + `${guess.timePassed.toFixed(1)}s`; + + html += ` +
+ ${index + 1}. + ${guess.movieName} + ${timeFormatted} + +${Math.round(guess.pointsEarned)} +
+ `; + }); + + guessesList.innerHTML = html; + } + + function hideScoreModal() { + document.getElementById('scoreModal').classList.remove('show'); + } + + // Main initialization function + function initializeQuizApp() { + console.log('Initializing quiz app'); + serverId = ApiClient.serverId(); + initializeQuiz(ApiClient); + quizListeners(); + } + + // Initialize the quiz when the script loads + if (typeof ApiClient !== 'undefined') { + // Wait for DOM to be ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeQuizApp); + } else { + initializeQuizApp(); + } + } else { + // Wait for ApiClient to be available + const checkApiClient = setInterval(() => { + if (typeof ApiClient !== 'undefined') { + clearInterval(checkApiClient); + initializeQuizApp(); + } + }, 100); + } +})(); \ No newline at end of file diff --git a/scripts/HomeScreenSection.ts b/scripts/HomeScreenSection.ts new file mode 100644 index 0000000..d3af570 --- /dev/null +++ b/scripts/HomeScreenSection.ts @@ -0,0 +1,213 @@ +interface HomeScreenSection { + // Identification + id: string; + name: string; + description?: string; // Description of the section, useful when the Section Name is insufficient at describing the section or for sharing Custom Sections + caption?: string; + enabled: boolean; + order: number; + category?: string; // Stable category id (custom sections); runtime may force discovery/pinned + + // Display Options + cardFormat?: "Poster" | "Thumb" | "Backdrop" | "Random"; + spotlight?: boolean; // Legacy: Use renderMode instead. Kept for backward compatibility. + renderMode?: "Normal" | "Spotlight" | "Random"; // Preferred: Controls rendering mode. "Spotlight" = spotlight section, "Random" = random spotlight/cards, "Normal" = cards only. + spotlightConfig?: object; + discoveryEnabled?: boolean; + flattenSeries?: boolean; + minimumItems?: number; + viewMoreUrl?: string; + captionUrl?: string; + hidden?: boolean; // legacy, use userConfigurable instead + hideName?: boolean; + + hideCardTitles?: boolean; // Legacy: use cardTitleVisibility instead + borderStyle?: string; // Applied as data-border on the section container + borderColor?: string; // CSS color for card borders (--kefin-card-border-color) + sectionCssClass?: string; // Extra CSS classes on the section container + itemsLayout?: "row" | "grid"; + cardTitleVisibility?: "visible" | "hidden"; + cardTitlePosition?: string; + cardTitleCapitalization?: "normal" | "uppercase" | "lowercase" | "capitalize"; + cardTitleFontFamily?: string; + cardTitleFontSize?: "small" | "normal" | "large"; + userConfigurable?: boolean; // Whether users can toggle/reorder this section + useGaplessCards?: boolean; // When true, remove gaps between cards (row or grid) + useParentCard?: boolean; // When true, use the parent item will be rendered as the card image and link + + // Loads items from an external URL and loads matches from that list from your Jellyfin library + // Currently supports: mdblist.com only + externalListUrls?: string[]; + + // Multi-Query (only used when queries.length > 1) + sortBy?: string; // Field to sort merged results by (e.g., 'DatePlayed', 'DateCreated') + sortOrder?: "Ascending" | "Descending"; // Sort direction for merged results + minPlayCount?: number; // Minimum play count to include in the section + maxPlayCount?: number; // Maximum play count to include in the section + useRandomQuery?: boolean; // Pick one query at load instead of merging; with useMultiQueryPicker, randomizes the initial picker selection + useMultiQueryPicker?: boolean; // Show a control to pick which query the section displays + multiQueryPickerLabel?: string; // Fixed label for the picker button; when unset, button shows the selected query name + useQueryNamesForSection?: boolean; // Use the selected query name in place of the section title + + // Optionally provide pre-defined items to display in the section, instead of using queries + items?: Array<{ + Name: string; + Id?: string; + Type?: string; + cardFooter?: string; + imageUrl: string; + backdropUrl?: string; + bannerUrl?: string; + logoUrl?: string; + thumbUrl?: string; + squareUrl?: string; + cardUrl: string; + hidden?: boolean; // Whether to hide the item from the section + }>; + + // Query Construction + queries: Array; + + // Surface / page kind: home | seasonal | discovery on the home catalog; + // other pages use series-episodes, search, watchlist, etc. Never "custom". + type?: string; + /** True for user-created sections stored in custom groups (surface type still home/seasonal/discovery). */ + isCustom?: boolean; + /** How a discovery section resolves dynamically: Person, Genre, Studio, Similar, Collection, … */ + discoveryType?: string; + discoveryItemType?: string; + discoveryPersonType?: string; + source?: string; + + /** Pool query used to resolve a dynamic discovery entity (optional on non-discovery sections). */ + discoverySourceQuery?: Query; + + /** Runtime stamp for custom discovery paging (1..N per custom group); not a persisted admin field. */ + pageNumber?: number; + + // Seasonal Config + startDate?: string; + endDate?: string; + + dataMonitors?: string[]; // A list of data monitor names to use for this section + + // Cache Settings + ttl?: number; +} + +interface Query { + name?: string; // Optional display name for multi-query picker and section title overrides + viewMoreUrl?: string; // Optional per-query title link when using multi-query picker + _sourceType?: "jellyfin" | "cache" | "static"; + path?: string; // Custom endpoint path (e.g., '/Shows/NextUp', '/Shows/Upcoming') + dataSource?: string; // Cache/custom data source (e.g., 'MoviesCache.getImdbTop250Movies') + /** Args passed to the cache method (e.g. ItemType, MinCount) — not post-processing */ + dataSourceOptions?: { + ItemType?: string; + MinCount?: number; + [key: string]: any; + }; + minAge?: number; // Minimum age in days + maxAge?: number; // Maximum age in days + queryOptions?: { + // Query parameters for /Items or custom endpoints / Client Cache post-processing + Ids?: string[]; + IncludeItemTypes?: string[]; + SearchTerm?: string; + SortBy?: string; + SortOrder?: "Ascending" | "Descending"; + Limit?: number; + Filters?: string; + Genres?: string; + GenreIds?: string; + Tags?: string; + PersonIds?: string; + Studios?: string; + StudioIds?: string; + ParentId?: string; + ExcludeItemIds?: string; + Fields?: string; + [key: string]: any; // Allow additional query params + }; +} + +interface HomeScreenSectionGroup { + author?: string; + name?: string; + description?: string; + sections: Array; +} + +interface SpotlightConfig { + spotlightLayout: string; + spotlightSize: string; + tileCount: number; + autoPlay: boolean; + interval: number; + showDots: boolean; + showNavButtons: boolean; + showClearArt: boolean; + panAnimation: boolean; + entranceAnimationFirst: string; + entranceAnimationSecond: string; + entranceAnimationThird: string; + slideAnimationFirst: string; + slideAnimationSecond: string; + slideAnimationThird: string; + cycleBackdrops: boolean; + cycleBackdropsTime?: number; // Legacy: use backdropsCount + interval instead + backdropsCount?: number; +} + +// Used for the customPrefs.kefinTweaks.homeScreen property +// All values are arrays of semi-colon separated strings +interface KefinHomeScreen { + sections: Array; // id;enabled;order;ttl;cardFormat;animationEnabled;hideName;hideCardTitles;cardTitlePosition;borderStyle;spotlightLayout;spotlightSize;spotlightTileCount;itemsLayout(row|grid; legacy true→grid);cardTitleCapitalization;cardTitleFontFamily;cardTitleFontSize;borderColor;cardTitleColor;useGaplessCards;renderMode(Normal|Spotlight|Random) + pinnedLists: Array; // name;id1;id2;id3;... + pinnedParents: Array; // name;id;type +} + +/** Non-home layout/appearance prefs (same semicolon section strings as homeScreen.sections). */ +interface KefinSectionState { + sections: Array; +} + +/* +User configuration for KefinTweaks Home Screen sections will be stored in the user's custom display preferences as kefinHomeScreen +Additionally, kefinPinnedItems and kefinPinnedParents will store an array of item IDs that the user has pinned to the home screen. +The kefinPinnedItems are individual items intended to appear in a Home Screen Section called "Pinned" +The kefinPinnedParents are parent items that should each have their own Home Screen Section which loads their child items. + +For example, a user could pin an MCU collection to their home screen as an item, and it would appear in the Pinned row with the MCU Collection poster. +By contrast, the user could choose to pin the MCU collection as a parent item, which would make the child items of the MCU Collection appear in a section called "MCU Collection". +*/ + +// An array of these sections is saved to the user's preferences as kefinHomeScreen +// These can be mapped by ID to override the section configuration provided by the server +interface KefinHomeScreenSectionUserConfig { + id: string; + enabled?: boolean; + order?: number; + ttl?: number; + cardFormat?: string; + animationEnabled?: boolean; + hideName?: boolean; + hideCardTitles?: boolean; + cardTitlePosition?: string; + borderStyle?: string; + borderColor?: string; + spotlightLayout?: string; + spotlightSize?: string; + spotlightTileCount?: number; + /** Items layout: row (default) or grid. Replaces legacy gridExpanded. */ + itemsLayout?: "row" | "grid"; + /** @deprecated Prefer itemsLayout; true maps to itemsLayout 'grid' */ + gridExpanded?: boolean; + /** When true, remove gaps between cards (row or grid). */ + useGaplessCards?: boolean; + cardTitleCapitalization?: string; + cardTitleFontFamily?: string; + cardTitleFontSize?: string; + /** User override of the section's render mode. */ + renderMode?: "Normal" | "Spotlight" | "Random"; +} diff --git a/scripts/apiHelper.js b/scripts/apiHelper.js index c614e2e..7bdff8d 100644 --- a/scripts/apiHelper.js +++ b/scripts/apiHelper.js @@ -13,6 +13,87 @@ throw new Error('ApiClient is not available'); } } + + const DEFAULT_ITEM_FIELDS = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,ProviderIds,MediaSourceCount'; + const SPOTLIGHT_ITEM_FIELDS = `${DEFAULT_ITEM_FIELDS},People,Genres,ParentBackdropImageTags,Studios`; + const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds']; + + function getDefaultFields(isSpotlight) { + return isSpotlight ? SPOTLIGHT_ITEM_FIELDS : DEFAULT_ITEM_FIELDS; + } + + /** + * Resolve Fields param for a query based on section type and explicit queryOptions.Fields. + * @param {Object} queryOptions + * @param {{ isSpotlight?: boolean, sectionType?: string }} context + * @returns {string} + */ + function resolveQueryFields(queryOptions, { isSpotlight = false, sectionType = '' } = {}) { + const explicit = queryOptions?.Fields; + if (!explicit) { + return getDefaultFields(isSpotlight); + } + const isDefaultHome = sectionType === 'home'; + if (isDefaultHome && !isSpotlight) { + return mergeFields(null, explicit); + } + if (isDefaultHome && isSpotlight) { + return mergeFields(SPOTLIGHT_ITEM_FIELDS, explicit); + } + return mergeFields(getDefaultFields(isSpotlight), explicit); + } + + function getNextUpDateCutoff(userId) { + const raw = userId ? localStorage.getItem(`${userId}-maxDaysForNextUp`) : null; + const days = raw != null && raw !== '' ? parseInt(raw, 10) : 365; + const maxDays = Number.isFinite(days) && days >= 0 ? days : 365; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - maxDays); + return cutoff.toISOString().split('T')[0]; + } + + function getEnableRewatchingInNextUp(userId) { + if (!userId) return false; + return localStorage.getItem(`${userId}-enableRewatchingInNextUp`) === 'true'; + } + + function mergeFields(base, extra) { + const seen = new Set(); + const result = []; + const add = (value) => { + if (value == null || value === '') return; + const parts = Array.isArray(value) ? value : String(value).split(','); + for (const part of parts) { + const field = String(part).trim(); + if (!field) continue; + const key = field.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(field); + } + }; + add(base); + add(extra); + return result.join(','); + } + + function applyQueryOptionsToParams(params, queryOptions, { skipFields = false } = {}) { + Object.entries(queryOptions || {}).forEach(([key, value]) => { + if (value === undefined || value === null) return; + if (key === 'Limit' && value === 0) return; + if (key === 'Fields') { + if (skipFields) return; + params.set('Fields', mergeFields(params.get('Fields'), value)); + return; + } + if (Array.isArray(value)) { + const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ','; + params.set(key, value.join(delimiter)); + return; + } + params.set(key, value); + }); + } /** * Retrieves the active playback session for this device @@ -154,24 +235,38 @@ } /** - * Build MediaBrowser Authorization header - * @returns {string} - Authorization header + * Build MediaBrowser Authorization header for an explicit access token. + * When token is empty/missing, Token is omitted (required for AuthenticateByName). + * @param {string} [token] + * @returns {string} */ - function getAuthHeader() { - const token = ApiClient.accessToken(); - const client = typeof ApiClient.applicationName === 'function' ? ApiClient.applicationName() : 'Jellyfin Web'; - const device = typeof ApiClient.deviceName === 'function' ? ApiClient.deviceName() : (navigator.userAgent.includes('Chrome') ? 'Chrome' : 'Browser'); - const deviceId = typeof ApiClient.deviceId === 'function' ? ApiClient.deviceId() : ''; - const version = ApiClient._appVersion || ApiClient._serverVersion || ''; + function getAuthHeaderForToken(token) { + const api = (typeof ApiClient !== 'undefined' && ApiClient) || window.ApiClient || null; + const client = api && typeof api.applicationName === 'function' ? api.applicationName() : 'Jellyfin Web'; + const device = api && typeof api.deviceName === 'function' ? api.deviceName() : (navigator.userAgent.includes('Chrome') ? 'Chrome' : 'Browser'); + const deviceId = api && typeof api.deviceId === 'function' ? api.deviceId() : ''; + const version = (api && (api._appVersion || api._serverVersion)) || ''; const parts = [ `Client="${encodeURIComponent(client)}"`, `Device="${encodeURIComponent(device)}"`, `DeviceId="${encodeURIComponent(deviceId)}"`, - `Version="${encodeURIComponent(version)}"`, - `Token="${encodeURIComponent(token)}"` + `Version="${encodeURIComponent(version)}"` ]; + if (token) { + parts.push(`Token="${encodeURIComponent(token)}"`); + } return `MediaBrowser ${parts.join(', ')}`; } + + /** + * Build MediaBrowser Authorization header for the current ApiClient session. + * @returns {string} - Authorization header + */ + function getAuthHeader() { + return getAuthHeaderForToken(typeof ApiClient !== 'undefined' && ApiClient.accessToken + ? ApiClient.accessToken() + : ''); + } /** * Query cache for API responses @@ -244,11 +339,367 @@ return `${serverAddress}/Users/${userId}/Items?${params.toString()}`; } + + const state = { + cachedServerVersion: null + } + + /** + * Fields used by progressive Watchlist sections. Must match getWatchlistQuery / + * invalidateWatchlistQueries so cache clear hits the same URL as fetch. + */ + const WATCHLIST_PROGRESSIVE_FIELDS = + 'PrimaryImageAspectRatio,DateCreated,ProductionYear,PremiereDate,ProviderIds,UserData'; + + /** + * Build a single top-level Likes query def (shared by getWatchlistItems / getWatchlistQuery). + * Callers pass IncludeItemTypes (and other options) to narrow results. + * Default Fields match progressive sections so invalidate clears the fetched URL. + * @param {Object} options + * @returns {{ queryOptions: Object }} + */ + function buildWatchlistQueryDef(options = {}) { + return { + queryOptions: { + Recursive: true, + ImageTypeLimit: 1, + EnableImageTypes: 'Primary,Backdrop,Thumb', + Fields: WATCHLIST_PROGRESSIVE_FIELDS, + ...options, + Filters: 'Likes' + } + }; + } /** * API Helper functions for Jellyfin operations */ const apiHelper = { + /** + * Get the MediaBrowser Authorization header for an explicit access token + * @param {string} [token] + * @returns {string} + */ + getAuthHeaderForToken: getAuthHeaderForToken, + + /** + * Get the MediaBrowser Authorization header + * @returns {string} - Authorization header + */ + getAuthHeader: getAuthHeader, + + /** + * Generic data fetcher with universal caching and progressive loading support + * @param {string} url - Full URL to fetch + * @param {Object} options - Cache options + * @param {boolean} options.useCache - Whether to return cached data first (default: false) + * @param {number} options.ttl - Cache time to live in milliseconds (default: 300000) + * @returns {Object|Promise} - If useCache is true, returns progressive stub { data, ensureData, isStalePromise } synchronously. Otherwise returns Promise. + */ + getQuery: function(url, options = {}) { + ensureApiClient(); + + const useCache = options.useCache || false; + let ttl = 60000; + if (Number(options.ttl) >= 0) { + ttl = Number(options.ttl); + } + const forceRefresh = options.forceRefresh || false; + const cacheKey = url; + + // Standard fetch function + const fetchData = async () => { + try { + const response = await fetch(url, { + headers: { + 'Authorization': getAuthHeader(), + 'Accept': 'application/json' + } + }); + + 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); + // Also persist to IndexedDB if available + if (window.IndexedDBCache) { + const cache = window.IndexedDBCache; + // We use the full URL as part of the key, but hashed or sanitized might be safer. + // For now using URL as is common in this codebase. + // IndexedDBCache expects a "name" which it prefixes. + // We'll use a hash of the URL to keep keys clean? + // Or just pass the URL as the cacheName but it might get messy. + // Let's use a simple mapping or just stringify. + // Actually, let's just stick to the in-memory cache for 'getQuery' unless we want persistent storage for home screen. + // The requirement says "use existing indexeddbcache functionality". + // So let's use it. + await cache.set(url, data, null, ttl); + } + } + + return data; + } catch (error) { + ERR('Failed to fetch URL:', error); + throw error; + } + }; + + // If not using cache, just fetch + if (!useCache) { + return fetchData(); + } + + // Progressive loading Logic + const result = { + data: null, + dataPromise: null, + isStalePromise: null + }; + + // 1. Get cached data immediately (async but fast) + const cachePromise = (async () => { + // Try memory cache first + let cached = getCachedQuery(cacheKey, ttl); + + // If not in memory, try IndexedDB + if (!cached && window.IndexedDBCache) { + const dbCache = window.IndexedDBCache; + const entry = await dbCache.getCacheEntry(url); + if (entry) { + cached = entry; // Keep structure { payload, timestamp, ttl } + LOG(`[Cache] Found in IndexedDB: ${url.substring(0, 60)}...`); + } + } else if (cached) { + // Wrap memory cache to match structure + cached = { payload: cached, timestamp: Date.now(), ttl: ttl }; + LOG(`[Cache] Found in Memory: ${url.substring(0, 60)}...`); + } else { + LOG(`[Cache] Miss: ${url.substring(0, 60)}...`); + } + + return cached; + })(); + + // 2. Define staleness check + result.isStalePromise = cachePromise.then(async (entry) => { + if (forceRefresh) { + LOG(`[StaleCheck] Force Refresh -> Stale`); + return true; // Force Refresh = stale + } + + if (!entry) { + LOG(`[StaleCheck] No cache entry -> Stale`); + return true; // No cache = stale + } + + const now = Date.now(); + const age = now - entry.timestamp; + const isExpired = age > ttl; + + result.data = entry.payload; + + if (!isExpired) { + LOG(`[StaleCheck] Cache valid (${(age/1000).toFixed(1)}s old) -> Not Stale`); + return false; // Valid + } + + LOG(`[StaleCheck] Cache expired (${(age/1000).toFixed(1)}s old). Refreshing...`); + return true; + + /* LOG(`[StaleCheck] Cache expired (${(age/1000).toFixed(1)}s old). Validating...`); + + // Cache is expired, check if we can validate it + // Logic: Check SortBy + const urlObj = new URL(url); + const sortBy = urlObj.searchParams.get('SortBy'); + const isRandom = !sortBy || sortBy.includes('Random'); + + if (isRandom) { + LOG(`[StaleCheck] Random sort detected -> Stale (Force Refresh)`); + return true; // Random + Expired = Stale + } + + // Fixed Sort: Fetch first 3 items + try { + // Clone URL and modify params + const checkUrl = new URL(url); + checkUrl.searchParams.set('Limit', '3'); + //checkUrl.searchParams.set('StartIndex', '0'); // Ensure start + + const checkResponse = await fetch(checkUrl.toString(), { + headers: { + 'Authorization': getAuthHeader(), + 'Accept': 'application/json' + } + }); + + if (!checkResponse.ok) { + LOG(`[StaleCheck] Validation fetch failed -> Stale`); + return true; + } + + const checkData = await checkResponse.json(); + + // Compare IDs + const cachedItems = entry.payload.Items || entry.payload || []; + const freshItems = checkData.Items || checkData || []; + + if (freshItems.length === 0 && cachedItems.length === 0) { + LOG(`[StaleCheck] Both empty -> Not Stale`); + return false; + } + if (freshItems.length !== Math.min(cachedItems.length, 3)) { + LOG(`[StaleCheck] Count mismatch (Fresh: ${freshItems.length}, Cached: ${Math.min(cachedItems.length, 3)}) -> Stale`); + return true; + } + + for (let i = 0; i < freshItems.length; i++) { + if (freshItems[i].Id !== cachedItems[i].Id) { + LOG(`[StaleCheck] Item mismatch at index ${i} -> Stale`); + return true; // Mismatch found + } + + // Also check the UserData for the item + if (freshItems[i].UserData) { + // Check each key in the UserData object + for (const key in freshItems[i].UserData) { + if (freshItems[i].UserData[key] !== cachedItems[i].UserData[key]) { + LOG(`[StaleCheck] UserData mismatch for key ${key} -> Stale`); + return true; // Mismatch found + } + } + } + + // Also check the ImageTags for the item + if (freshItems[i].ImageTags) { + // Check each key in the ImageTags object + for (const key in freshItems[i].ImageTags) { + if (freshItems[i].ImageTags[key] !== cachedItems[i].ImageTags[key]) { + LOG(`[StaleCheck] ImageTags mismatch for key ${key} -> Stale`); + return true; // Mismatch found + } + } + } + } + + // Validation Successful: Update timestamp + LOG(`[StaleCheck] Content matches -> Not Stale (Updating Timestamp)`); + if (window.IndexedDBCache) { + const dbCache = window.IndexedDBCache; + // Update with new timestamp but same data + dbCache.set(url, entry.payload, null, ttl); + } + + result.data = entry.payload; // Use cached data + return false; // Not stale + + } catch (e) { + WARN('Error validating cache:', e); + return true; // Assume stale on error + } */ + }); + + // 3. Lazy data promise — do not start fetchData until ensureData()/dataPromise is used + // Stale-while-revalidate: return usable cache immediately; refresh in _refreshPromise + const hasUsableCachedPayload = (data) => { + if (!data) return false; + if (Array.isArray(data)) return data.length > 0; + if (Array.isArray(data.Items)) return data.Items.length > 0; + return false; + }; + + result.ensureData = function() { + if (!result._dataPromise) { + result._dataPromise = result.isStalePromise.then(isStale => { + if (!isStale) { + LOG(`[Fetch] Using cached data for: ${url}...`); + return result.data; + } + + const refreshPromise = fetchData().then((fresh) => { + if (fresh != null) result.data = fresh; + return fresh; + }); + + if (hasUsableCachedPayload(result.data)) { + result._refreshPromise = refreshPromise; + LOG(`[Fetch] Returning stale cache; refreshing in background: ${url}...`); + return result.data; + } + + LOG(`[Fetch] Refreshing data for: ${url}...`); + return refreshPromise; + }); + } + return result._dataPromise; + }; + Object.defineProperty(result, 'dataPromise', { + configurable: true, + enumerable: true, + get() { + return result.ensureData(); + } + }); + + // Sync stub: do not await IndexedDB before return (home first paint). + // cachePromise / isStalePromise hydrate result.data in the background. + // Thenable waits for hydrate but must NOT fulfill with `result` itself + // (Promise assimilation would recurse on result.then forever). + result.data = { Items: [] }; + result.isStale = true; + const readyPromise = cachePromise.then((entry) => { + if (entry) { + result.data = entry.payload; + result.isStale = forceRefresh || (Date.now() - entry.timestamp > ttl); + } else { + // Cache miss: clear placeholder so data?.Items cannot fake a hit + result.data = null; + result.isStale = true; + } + }).catch(() => { + result.data = null; + result.isStale = true; + }); + + function toSettledView() { + return { + data: result.data, + isStale: result.isStale, + isStalePromise: result.isStalePromise, + ensureData: result.ensureData, + get dataPromise() { + return result.ensureData(); + } + }; + } + + result.then = function (onFulfilled, onRejected) { + return readyPromise.then( + () => (typeof onFulfilled === 'function' ? onFulfilled(toSettledView()) : toSettledView()), + onRejected + ); + }; + result.catch = function (onRejected) { + return result.then(undefined, onRejected); + }; + return result; + }, + + invalidateCache: function(url) { + const dbCache = window.IndexedDBCache; + if (dbCache) { + dbCache.clear(url); + } + if (queryCache) { + queryCache.delete(url); + } + }, + /** * Generic data fetcher with universal caching * Always caches successful responses, but only checks cache when useCache is true @@ -300,52 +751,94 @@ }, 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.getItems(); - - // We now want to include the library items with ColletionType "tvshows", or "movies" - const supportedCollectionTypes = ['tvshows', 'movies', 'homevideos', 'boxsets', 'playlists']; - const supportedLibraryItems = libraryItems.Items.filter(item => supportedCollectionTypes.includes(item.CollectionType) || !item.CollectionType); - - // 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) - ); + const query = buildWatchlistQueryDef(options); + const data = await this.getItems({ ...query.queryOptions }, useCache); + const items = Array.isArray(data?.Items) ? data.Items : (Array.isArray(data) ? data : []); return { - Items: watchlistItems, - TotalRecordCount: watchlistItems.length + Items: items, + TotalRecordCount: data?.TotalRecordCount ?? items.length }; }, + + /** + * Progressive watchlist query (single top-level Likes getQuery). + * @param {Object} options - Same shape as getWatchlistItems (IncludeItemTypes, Fields, ...) + * @param {Object} [cacheOptions] + * @param {number} [cacheOptions.ttl=300000] + * @param {boolean} [cacheOptions.forceRefresh=false] + * @returns {Promise} Progressive result: { data, isStale, isStalePromise, ensureData, dataPromise, urls } + */ + getWatchlistQuery: async function(options = {}, cacheOptions = {}) { + ensureApiClient(); + + let ttl = 300000; + if (Number(cacheOptions.ttl) >= 0) { + ttl = Number(cacheOptions.ttl); + } + const forceRefresh = !!cacheOptions.forceRefresh; + const userId = ApiClient.getCurrentUserId(); + const serverUrl = ApiClient.serverAddress(); + const query = buildWatchlistQueryDef(options); + const url = this.buildQueryFromSection(query, userId, serverUrl); + if (typeof url !== 'string') { + throw new Error('Invalid watchlist query URL'); + } + + const queryResult = this.getQuery(url, { + useCache: true, + ttl, + forceRefresh + }); + + const normalize = (data) => { + const items = Array.isArray(data?.Items) ? data.Items : (Array.isArray(data) ? data : []); + return { Items: items, TotalRecordCount: data?.TotalRecordCount ?? items.length }; + }; + + let mappedDataPromise = null; + const ensureData = () => { + if (!mappedDataPromise) { + const raw = typeof queryResult.ensureData === 'function' + ? queryResult.ensureData() + : queryResult.dataPromise; + mappedDataPromise = Promise.resolve(raw).then(normalize); + } + return mappedDataPromise; + }; + + const result = { + data: { Items: [] }, + isStale: true, + isStalePromise: queryResult.isStalePromise || Promise.resolve(false), + ensureData, + urls: [url] + }; + Object.defineProperty(result, 'dataPromise', { + configurable: true, + enumerable: true, + get() { + return ensureData(); + } + }); + + return result; + }, + + /** + * Invalidate cached watchlist query URL for the given IncludeItemTypes options. + * @param {Object} [options] + */ + invalidateWatchlistQueries: async function(options = {}) { + ensureApiClient(); + const query = buildWatchlistQueryDef(options); + const userId = ApiClient.getCurrentUserId(); + const serverUrl = ApiClient.serverAddress(); + const url = this.buildQueryFromSection(query, userId, serverUrl); + if (typeof url === 'string') { + this.invalidateCache(url); + } + }, /** * Get items from Jellyfin API with optional caching @@ -529,6 +1022,319 @@ LOG('Queue updated successfully'); }, + /** + * Lookup a genre ID from the local movie-genres catalog (localStorage / memory). + * Never awaits network — schedules a background sync when missing or older than 24h. + * @param {string} genreName - Name of the genre to find + * @returns {Promise} - Genre ID or null if not found / not yet cached + */ + getGenreId: async function(genreName) { + if (!genreName) return null; + + hydrateMovieGenresFromStorage(); + const empty = !cachedMovieGenres || cachedMovieGenres.length === 0; + if (empty || isMovieGenresSyncDue()) { + ensureGenreSyncScheduled(); + } + if (!cachedMovieGenres || cachedMovieGenres.length === 0) return null; + + const needle = String(genreName).toLowerCase(); + const genre = cachedMovieGenres.find((g) => g?.Name && g.Name.toLowerCase() === needle); + return genre ? genre.Id : null; + }, + + /** + * Subscribe to movie-genres catalog sync completion (localStorage + memory refreshed). + * @param {Function} callback + * @returns {Function} unsubscribe + */ + onMovieGenresSynced: function(callback) { + if (typeof callback !== 'function') return () => {}; + movieGenresSyncedListeners.add(callback); + return () => movieGenresSyncedListeners.delete(callback); + }, + + /** + * Build query URL from section query configuration + * Handles minAge/maxAge conversion to MinPremiereDate/MaxPremiereDate + * @param {Object} query - Query object with optional path, dataSource, minAge, maxAge, and queryOptions + * @param {string} userId - User ID + * @param {string} serverUrl - Server URL + * @returns {string|Object} - Query URL string or dataSource marker object + */ + buildQueryFromSection: function(query, userId, serverUrl, isSpotlight = false, options = {}) { + ensureApiClient(); + + const sectionType = options.sectionType || ''; + const queryOptions = { ...(query.queryOptions || {}) }; + + // Convert minAge/maxAge to premiere date range + if (query.minAge !== undefined || query.maxAge !== undefined) { + const now = new Date(); + + if (query.maxAge !== undefined) { + const minDate = new Date(now); + minDate.setDate(minDate.getDate() - query.maxAge); + queryOptions.MinPremiereDate = minDate.toISOString().split('T')[0]; + } + + if (query.minAge !== undefined) { + const maxDate = new Date(now); + maxDate.setDate(maxDate.getDate() - query.minAge); + queryOptions.MaxPremiereDate = maxDate.toISOString().split('T')[0]; + } + } + + const buildContext = { isSpotlight, sectionType }; + + if (query.path) { + return this.buildCustomEndpoint(query.path, queryOptions, userId, serverUrl, buildContext); + } + + if (query.dataSource) { + return { + dataSource: query.dataSource, + options: queryOptions, + dataSourceOptions: query.dataSourceOptions || {} + }; + } + + return this.buildStandardQuery(queryOptions, userId, serverUrl, buildContext); + }, + + /** + * Build standard /Items query URL + * @param {Object} queryOptions - Query options + * @param {string} userId - User ID + * @param {string} serverUrl - Server URL + * @param {{ isSpotlight?: boolean, sectionType?: string }} context + * @returns {string} - Query URL + */ + buildStandardQuery: function(queryOptions, userId, serverUrl, context = {}) { + if (context === true || context === false) { + context = { isSpotlight: context === true }; + } + const params = new URLSearchParams({ + Recursive: 'true', + Fields: resolveQueryFields(queryOptions, context), + }); + + applyQueryOptionsToParams(params, queryOptions, { skipFields: true }); + + return userId ? `${serverUrl}/Users/${userId}/Items?${params.toString()}` : `${serverUrl}/Items?${params.toString()}`; + }, + + /** + * Build custom endpoint URL + * @param {string} path - Custom endpoint path (e.g., '/Shows/NextUp') + * @param {Object} queryOptions - Query options + * @param {string} userId - User ID + * @param {string} serverUrl - Server URL + * @param {{ isSpotlight?: boolean, sectionType?: string }} context + * @returns {string} - Query URL + */ + buildCustomEndpoint: function(path, queryOptions, userId, serverUrl, context = {}) { + if (context === true || context === false) { + context = { isSpotlight: context === true }; + } + const params = new URLSearchParams({ + UserId: userId, + Fields: resolveQueryFields(queryOptions, context), + }); + + applyQueryOptionsToParams(params, queryOptions, { skipFields: true }); + + if (path === '/Shows/NextUp') { + // If NextUpDateCutoff is not set, set it to the current date + if (!params.has('NextUpDateCutoff') && queryOptions.NextUpDateCutoff === undefined) { + params.set('NextUpDateCutoff', getNextUpDateCutoff(userId)); + } + + // If EnableRewatching is not set, set it to the current value + if (!params.has('EnableRewatching') && queryOptions.EnableRewatching === undefined) { + params.set('EnableRewatching', getEnableRewatchingInNextUp(userId) ? 'true' : 'false'); + } + + // If EnableResumable is not set, set it to false + if (!params.has('EnableResumable') && queryOptions.EnableResumable === undefined) { + params.set('EnableResumable', 'false'); + } + } + + if (!params.has('EnableTotalRecordCount')) { + params.set('EnableTotalRecordCount', 'false'); + } + + return `${serverUrl}${path}?${params.toString()}`; + }, + + /** + * Resolve args for a cache dataSource method. + * Prefers explicit dataSourceOptions; falls back to legacy ItemType/MinCount in queryOptions. + */ + resolveDataSourceMethodArgs: function(dataSourceOptions, queryOptions) { + if (dataSourceOptions && typeof dataSourceOptions === 'object') { + const keys = Object.keys(dataSourceOptions); + if (keys.length) return { ...dataSourceOptions }; + } + const qo = queryOptions || {}; + const out = {}; + if (qo.ItemType != null && qo.ItemType !== '') out.ItemType = qo.ItemType; + else if (qo.itemType != null && qo.itemType !== '') out.ItemType = qo.itemType; + if (qo.MinCount != null && qo.MinCount !== '') out.MinCount = qo.MinCount; + else if (qo.minCount != null && qo.minCount !== '') out.MinCount = qo.minCount; + else if (qo.minimumSeries != null && qo.minimumSeries !== '') out.minimumSeries = qo.minimumSeries; + return out; + }, + + /** + * Fetch from data source (cache-based) + * @param {string} dataSource - Data source in format "CacheName.methodName" + * @param {Object} queryOptions - Post-processing options (SortBy, SortOrder, Limit, etc.) + * @param {boolean} [useCache=true] + * @param {Object} [dataSourceOptions] - Args passed to the cache method (e.g. ItemType, MinCount) + * @returns {Object} - Object with data, dataPromise, and isStalePromise + */ + fetchFromDataSource: async function(dataSource, queryOptions, useCache = true, dataSourceOptions) { + const [cacheName, methodName] = dataSource.split('.'); + const cache = window[cacheName]; + const options = queryOptions || {}; + const methodArgs = this.resolveDataSourceMethodArgs(dataSourceOptions, options); + + if (!cache || !cache[methodName]) { + WARN(`Data source not available: ${dataSource}`); + return { + data: { Items: [] }, + dataPromise: Promise.resolve({ Items: [] }), + isStalePromise: Promise.resolve(false) + }; + } + + try { + let dataPromise = Promise.resolve(cache[methodName](methodArgs)); + dataPromise = dataPromise.then(data => { + if (!Array.isArray(data)) { + data = []; + } + + // Apply options (sorting, limits, etc.) + if (options.SortBy === 'Random') { + data = data.sort(() => Math.random() - 0.5); + } else if (options.SortBy) { + data = data.sort((a, b) => { + const aVal = a[options.SortBy]; + const bVal = b[options.SortBy]; + if (aVal === bVal) return 0; + const comparison = aVal > bVal ? 1 : -1; + return options.SortOrder === 'Descending' ? -comparison : comparison; + }); + } + + if (options.Limit) { + data = data.slice(0, options.Limit); + } + return { + Items: data + }; + }); + + if (!useCache) { + return await dataPromise; + } + + return { + data: { Items: [] }, + dataPromise: dataPromise, + isStalePromise: Promise.resolve(false) + }; + } catch (error) { + ERR(`Failed to fetch from ${dataSource}:`, error); + return { + data: { Items: [] }, + dataPromise: Promise.resolve({ Items: [] }), + isStalePromise: Promise.resolve(false) + }; + } + }, + + /** + * Merge results from multiple queries + * @param {Array} results - Array of query result objects + * @param {Object} sectionConfig - Section configuration object + * @returns {Object} - Merged result object with config, result.data, result.dataPromise, result.isStalePromise + */ + mergeMultiQueryResults: function(results, sectionConfig) { + // Combine all items from all queries + const allItems = []; + const itemsByQuery = []; + + results.forEach(result => { + if (result.data?.Items) { + allItems.push(...result.data.Items); + itemsByQuery.push(result.data.Items); + } + }); + + // Sort merged items if sortBy specified + /* if (sectionConfig.sortBy) { + allItems.sort((a, b) => { + const aVal = a[sectionConfig.sortBy] || (a.UserData?.[sectionConfig.sortBy] ? new Date(a.UserData[sectionConfig.sortBy]).getTime() : 0); + const bVal = b[sectionConfig.sortBy] || (b.UserData?.[sectionConfig.sortBy] ? new Date(b.UserData[sectionConfig.sortBy]).getTime() : 0); + + // Handle DatePlayed specifically for merged Continue/NextUp + if (sectionConfig.sortBy === 'DatePlayed') { + const aDate = a.UserData?.LastPlayedDate ? new Date(a.UserData.LastPlayedDate).getTime() : 0; + const bDate = b.UserData?.LastPlayedDate ? new Date(b.UserData.LastPlayedDate).getTime() : 0; + const comparison = aDate > bDate ? 1 : aDate < bDate ? -1 : 0; + return sectionConfig.sortOrder === 'Descending' ? -comparison : comparison; + } + + const comparison = aVal > bVal ? 1 : aVal < bVal ? -1 : 0; + return sectionConfig.sortOrder === 'Descending' ? -comparison : comparison; + }); + } */ + + let dataPromise = null; + const ensureData = function() { + if (!dataPromise) { + dataPromise = Promise.all(results.map((r) => ( + typeof r.ensureData === 'function' ? r.ensureData() : r.dataPromise + ))).then((allData) => { + const itemsByFreshQuery = []; + allData.forEach((data) => { + if (data?.Items) { + itemsByFreshQuery.push(data.Items); + } + }); + return window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByFreshQuery); + }); + } + return dataPromise; + }; + + const mergedResult = { + data: { Items: window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByQuery) }, + isStale: results.some((r) => r.isStale), + isStalePromise: Promise.all(results.map(r => r.isStalePromise || Promise.resolve(false))) + .then(staleFlags => staleFlags.some(s => s)), + ensureData + }; + Object.defineProperty(mergedResult, 'dataPromise', { + configurable: true, + enumerable: true, + get() { + return ensureData(); + } + }); + + return { + config: sectionConfig, + result: mergedResult + }; + }, + + isAdmin: isAdmin, + /** * Get the list of plugins from the server * @returns {Promise} - Array of plugin objects @@ -550,12 +1356,168 @@ return await response.json(); }, - - getAuthHeader, }; - + + const GENRE_TTL = 24 * 60 * 60 * 1000; // 24 hours + const GENRE_SETTLE_DELAY_MS = 1500; + let cachedMovieGenres = null; + let movieGenresLastSyncedAt = null; + let genreSyncScheduledPromise = null; + let genreSyncInFlightPromise = null; + const movieGenresSyncedListeners = new Set(); + + function normalizeServerAddressForStorage(serverAddress) { + if (!serverAddress) return ''; + return String(serverAddress).replace(/\/+$/, ''); + } + + function getMovieGenresStorageKey() { + try { + const serverAddress = normalizeServerAddressForStorage( + (typeof ApiClient !== 'undefined' && ApiClient?.serverAddress?.()) || '' + ); + if (!serverAddress) return null; + return `kefinTweaks_movieGenres_${encodeURIComponent(serverAddress)}`; + } catch (_) { + return null; + } + } + + function readMovieGenresFromStorage() { + const key = getMovieGenresStorageKey(); + if (!key) return null; + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.items)) return null; + return { + items: parsed.items.filter((g) => g && g.Id && g.Name), + lastSyncedAt: Number(parsed.lastSyncedAt) || 0 + }; + } catch (err) { + WARN('Failed to read movie genres from localStorage:', err); + return null; + } + } + + function writeMovieGenresToStorage(items, lastSyncedAt) { + const key = getMovieGenresStorageKey(); + if (!key) return; + try { + localStorage.setItem(key, JSON.stringify({ + items: (items || []).map((g) => ({ Id: g.Id, Name: g.Name })), + lastSyncedAt: lastSyncedAt || Date.now() + })); + } catch (err) { + WARN('Failed to write movie genres to localStorage:', err); + } + } + + function hydrateMovieGenresFromStorage() { + if (cachedMovieGenres && cachedMovieGenres.length > 0) return true; + const stored = readMovieGenresFromStorage(); + if (!stored?.items?.length) return false; + cachedMovieGenres = stored.items; + movieGenresLastSyncedAt = stored.lastSyncedAt || movieGenresLastSyncedAt; + LOG(`Hydrated movie genres from localStorage: ${cachedMovieGenres.length} items`); + return true; + } + + function isMovieGenresSyncDue() { + const stored = readMovieGenresFromStorage(); + const last = movieGenresLastSyncedAt || stored?.lastSyncedAt || 0; + const hasItems = (cachedMovieGenres && cachedMovieGenres.length > 0) || (stored?.items?.length > 0); + if (!hasItems) return true; + if (!last) return true; + return (Date.now() - last) >= GENRE_TTL; + } + + function waitForDocumentComplete() { + if (document.readyState === 'complete') return Promise.resolve(); + return new Promise((resolve) => { + window.addEventListener('load', () => resolve(), { once: true }); + }); + } + + function notifyMovieGenresSynced() { + try { + window.dispatchEvent(new CustomEvent('kefinTweaksMovieGenresSynced')); + } catch (_) { /* ignore */ } + movieGenresSyncedListeners.forEach((cb) => { + try { + cb(); + } catch (err) { + WARN('onMovieGenresSynced listener failed:', err); + } + }); + } + + async function runMovieGenresSync() { + if (genreSyncInFlightPromise) return genreSyncInFlightPromise; + + genreSyncInFlightPromise = (async () => { + try { + if (window.userHelper?.waitForLogin) { + await window.userHelper.waitForLogin(); + } + ensureApiClient(); + const url = `${ApiClient.serverAddress()}/Genres?IncludeItemTypes=Movie`; + const genres = await apiHelper.getData(url, true, GENRE_TTL); + const items = Array.isArray(genres?.Items) ? genres.Items : []; + cachedMovieGenres = items; + movieGenresLastSyncedAt = Date.now(); + writeMovieGenresToStorage(items, movieGenresLastSyncedAt); + LOG(`Synced movie genres: ${items.length} items`); + notifyMovieGenresSynced(); + } catch (err) { + ERR('Failed to sync movie genres:', err); + } finally { + genreSyncInFlightPromise = null; + } + })(); + + return genreSyncInFlightPromise; + } + + /** + * Deduped: after settle, sync Genres when cache is missing or older than 24h. + */ + function ensureGenreSyncScheduled() { + hydrateMovieGenresFromStorage(); + if (!isMovieGenresSyncDue()) { + return genreSyncScheduledPromise || Promise.resolve(); + } + if (genreSyncScheduledPromise) return genreSyncScheduledPromise; + + genreSyncScheduledPromise = (async () => { + try { + if (window.userHelper?.waitForLogin) { + await window.userHelper.waitForLogin(); + } + await waitForDocumentComplete(); + await new Promise((resolve) => setTimeout(resolve, GENRE_SETTLE_DELAY_MS)); + hydrateMovieGenresFromStorage(); + if (!isMovieGenresSyncDue()) return; + await runMovieGenresSync(); + } catch (err) { + ERR('Failed to schedule movie genres sync:', err); + } finally { + genreSyncScheduledPromise = null; + } + })(); + + return genreSyncScheduledPromise; + } + + // Shared with watchlist progressive sections / invalidate (same Fields → same cache URL) + apiHelper.WATCHLIST_PROGRESSIVE_FIELDS = WATCHLIST_PROGRESSIVE_FIELDS; + // Expose apiHelper to global window object window.apiHelper = apiHelper; - + + // Daily / empty-cache sync without waiting for a getGenreId caller + ensureGenreSyncScheduled(); + console.log('[KefinTweaks APIHelper] Module loaded and available at window.apiHelper'); })(); \ No newline at end of file diff --git a/scripts/backdropLeakFix.js b/scripts/backdropLeakFix.js index 752f216..d5d673c 100644 --- a/scripts/backdropLeakFix.js +++ b/scripts/backdropLeakFix.js @@ -9,15 +9,11 @@ LOG('Script loaded - JS Injector mode'); - // Step 1: watch for .backdropContainer to appear - const waitForContainer = new MutationObserver(() => { - const container = document.querySelector('.backdropContainer'); - if (!container) return; + function attachPruneObserver(container) { + if (!container || container.dataset.kefinBackdropPrune === 'true') return; + container.dataset.kefinBackdropPrune = 'true'; LOG('Found .backdropContainer, attaching prune observer'); - waitForContainer.disconnect(); - - // Step 2: prune children as needed const pruneObserver = new MutationObserver(() => { LOG('Mutation detected, checking children count:', container.children.length); while (container.children.length > 2) { @@ -25,16 +21,35 @@ container.removeChild(container.firstElementChild); } }); - pruneObserver.observe(container, { childList: true }); LOG('Prune observer is now active'); - }); + } - waitForContainer.observe(document.body, { - childList: true, - subtree: true - }); + function startWait() { + const utils = window.KefinTweaksUtils; + const existing = document.querySelector('.backdropContainer'); + if (existing) { + attachPruneObserver(existing); + return; + } + if (utils?.waitForElement) { + utils.waitForElement('.backdropContainer', { timeout: 60000, quiet: true }).then((container) => { + if (container) attachPruneObserver(container); + else WARN_MISSING(); + }); + return; + } + + // Fallback if utils not ready yet + setTimeout(startWait, 500); + } + + function WARN_MISSING() { + console.warn('[KefinTweaks BackdropLeakFix] .backdropContainer not found within timeout'); + } + + startWait(); LOG('Waiting for .backdropContainer to appear...'); LOG('Backdrop leak fix functionality initialized'); })(); diff --git a/scripts/breadcrumbNav.css b/scripts/breadcrumbNav.css index f7f23fb..5267e7c 100644 --- a/scripts/breadcrumbNav.css +++ b/scripts/breadcrumbNav.css @@ -1,103 +1,149 @@ /* KefinTweaks Breadcrumbs Styles */ .kefinTweaks-breadcrumbs-wrapper { - display: none; + display: none; } @media (min-width: 768px) { - .kefinTweaks-breadcrumbs-wrapper { - display: block; - margin-left: -.5em; - transition: background 0.2s ease; - } + .kefinTweaks-breadcrumbs-wrapper { + display: block; + margin-left: -0.5em; + transition: background 0.2s ease; + } - .kefinTweaks-breadcrumbs-wrapper:hover { - background: rgba(0, 0, 0, 0.5); - border-radius: 0.5em; - } + .kefinTweaks-breadcrumbs-wrapper:hover { + background: rgba(0, 0, 0, 0.5); + border-radius: 0.5em; + } - .kefinTweaks-breadcrumbs { - color: rgba(255, 255, 255, 0.8); - font-size: 1.17em; - font-family: inherit; - pointer-events: auto; - position: relative; - padding: 8px 12px; - border-radius: 4px; - word-wrap: break-word; - display: inline-block; - } + .MuiToolbar-root .kefinTweaks-breadcrumbs-wrapper:hover { + background: transparent; + border-radius: 0; + } - .kefinTweaks-popover-container { - position: absolute; - top: 100%; - left: 0; - z-index: 1001; - } + .kefinTweaks-breadcrumbs { + color: rgba(255, 255, 255, 0.8); + font-size: 1.17em; + font-family: inherit; + pointer-events: auto; + position: relative; + padding: 8px 12px; + border-radius: 4px; + word-wrap: break-word; + display: inline-block; + } - .kefinTweaks-breadcrumbs span { - display: inline; - } + .MuiToolbar-root .kefinTweaks-breadcrumbs, + .kefinTweaks-breadcrumbs-wrapper--modern .kefinTweaks-breadcrumbs { + font-size: 1em; + padding: 0px 12px 8px; + } - .kefinTweaks-breadcrumbs span:hover { - color: rgba(255, 255, 255, 1); - } + .MuiToolbar-root .kefinTweaks-popover { + margin-top: 20px; + } - /* Hover effects for clickable breadcrumbs only */ - .kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover { - text-decoration: underline !important; - } + .kefinTweaks-popover-container { + position: absolute; + top: 100%; + left: 0; + z-index: 1001; + pointer-events: all; + } - /* Popover styles for seasons and albums */ - .kefinTweaks-popover { - position: relative; - background-color: rgba(29, 38, 53, 0.95); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - padding: 0; - min-width: 200px; - max-height: 300px; - overflow-y: auto; - z-index: 1001; - display: block; - margin-top: 8px; - } + /* When breadcrumbs are at bottom of page, open popover above */ + .kefinTweaks-popover-container.kefinTweaks-popover-container--above { + top: auto; + bottom: 100%; + } - .kefinTweaks-popover-item { - padding: 8px 16px 8px 24px; - cursor: pointer; - color: rgba(255, 255, 255, 0.8); - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - position: relative; - } + .kefinTweaks-popover.kefinTweaks-popover--above { + margin-top: 0; + margin-bottom: 8px; + } - .kefinTweaks-popover-item:hover { - background-color: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 1); - } + .kefinTweaks-breadcrumbs span { + display: inline; + } - .kefinTweaks-popover-item:last-child { - border-bottom: none; - } + .kefinTweaks-breadcrumbs span:hover { + color: rgba(255, 255, 255, 1); + } - .kefinTweaks-popover-item.selected { - color: rgba(255, 255, 255, 1); - font-weight: bold; - } + /* Hover effects for clickable breadcrumbs only */ + .kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover { + text-decoration: underline !important; + } - .kefinTweaks-popover-item.selected::before { - content: '>'; - position: absolute; - left: 8px; - top: calc(50% - 1px); - transform: translateY(-50%); - font-weight: bold; - } + /* Popover styles for seasons and albums */ + .kefinTweaks-popover { + position: relative; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 0; + max-height: 300px; + overflow-y: auto !important; + z-index: 1001; + display: block; + margin-top: 8px; + } - .skinHeader { - contain: none; - } + .kefinTweaks-popover-item { + padding: 8px 16px 8px 24px; + cursor: pointer; + color: rgba(255, 255, 255, 0.8); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + } - .headerLeft { - overflow: visible; - } -} \ No newline at end of file + .kefinTweaks-popover-item:hover { + background-color: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 1); + } + + .kefinTweaks-popover-item:last-child { + border-bottom: none; + } + + .kefinTweaks-popover-item.selected { + color: rgba(255, 255, 255, 1); + font-weight: bold; + } + + .kefinTweaks-popover-item.selected::before { + content: ">"; + position: absolute; + left: 8px; + top: calc(50% - 1px); + transform: translateY(-50%); + font-weight: bold; + } + + .skinHeader { + contain: none; + } + + .headerLeft { + overflow: visible; + } +} + +.kefinTweaks-popover.itemDetailsGroup:has(.detailsGroupItem:not(.hide)) { + padding: 0 !important; + overflow-y: auto !important; + backdrop-filter: blur(50px); + margin-top: 0 !important; +} +.kefinTweaks-popover.itemDetailsGroup:has(.detailsGroupItem:not(.hide)) .kefinTweaks-popover-item.detailsGroupItem { + padding: 0.5em 2em; + margin: 0 !important; + border-radius: 0 !important; + width: initial !important; + max-width: initial !important; + white-space: nowrap; +} +header > .MuiToolbar-root:has(.kefinTweaks-breadcrumbs-wrapper) { + flex-wrap: wrap; +} +header > .MuiToolbar-root:has(.kefinTweaks-breadcrumbs-wrapper) #kefinTweaks-breadcrumbs-wrapper { + width: 100%; +} diff --git a/scripts/breadcrumbs.js b/scripts/breadcrumbs.js index 2088317..2fc3bc4 100644 --- a/scripts/breadcrumbs.js +++ b/scripts/breadcrumbs.js @@ -3,7 +3,7 @@ // Supports: Movie, Series, Season, Episode, MusicArtist, MusicAlbum, Audio // Requires: utils.js module to be loaded before this script -(function() { +(function () { 'use strict'; console.log('[KefinTweaks Breadcrumbs] Initializing...'); @@ -72,18 +72,26 @@ async function getTargetBreadcrumbStructure(item) { const structure = { elements: [], - item: item + item: item, }; - + // Don't append .html for server versions 10.11 and above - const urlSuffix = ApiClient._appVersion.split('.')[1] > 10 ? '' : '.html' - + const urlSuffix = ApiClient._appVersion.split('.')[1] > 10 ? '' : '.html'; + let queryParams = ''; if (item.ParentId) { // Get the top parent item from the ancestors with a Type = CollectionFolder - const ancestors = await ApiClient.getAncestorItems(item.Id); - const collectionFolder = ancestors.find(ancestor => ancestor.Type === 'CollectionFolder'); - const userRootFolder = ancestors.find(ancestor => ancestor.Type === 'UserRootFolder'); + // Use apiHelper.getQuery rather than getAncestorItems so we can use the cached data + const ancestorsUrl = `${ApiClient.serverAddress()}/Items/${item.Id}/Ancestors`; + const queryResult = await window.apiHelper.getQuery(ancestorsUrl, { useCache: true }); + let ancestors = queryResult.data; + + if (!ancestors || !Array.isArray(ancestors)) { + ancestors = await queryResult.dataPromise; + } + + const collectionFolder = ancestors.find((ancestor) => ancestor.Type === 'CollectionFolder'); + const userRootFolder = ancestors.find((ancestor) => ancestor.Type === 'UserRootFolder'); if (collectionFolder) { queryParams = `?topParentId=${collectionFolder.Id}&serverId=${ApiClient.serverId()}`; } else if (userRootFolder) { @@ -91,21 +99,20 @@ } else { queryParams = `?serverId=${ApiClient.serverId()}`; } - } - else if (ApiClient.serverId()) { + } else if (ApiClient.serverId()) { queryParams = `?serverId=${ApiClient.serverId()}`; } if (item.Type === 'Movie') { structure.elements = [ { text: 'Movies', url: `#/movies${urlSuffix}${queryParams}&tab=0`, clickable: true }, - { text: item.Name, url: null, clickable: false } + { text: item.Name, url: null, clickable: false }, ]; } else if (item.Type === 'Series') { structure.elements = [ { text: 'Shows', url: `#/tv${urlSuffix}${queryParams}&tab=0`, clickable: true }, { text: item.Name, url: null, clickable: false }, - { text: 'All Seasons', url: null, clickable: true, popover: true } + { text: 'All Seasons', url: null, clickable: true, popover: true }, ]; } else if (item.Type === 'Season') { // Get series details for the series name @@ -114,8 +121,12 @@ structure.elements = [ { text: 'Shows', url: `#/tv${urlSuffix}${queryParams}&tab=0`, clickable: true }, - { text: seriesName, url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true }, - { text: item.Name || `Season ${item.IndexNumber}`, url: null, clickable: true, popover: true } + { + text: seriesName, + url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, + clickable: true, + }, + { text: item.Name || `Season ${item.IndexNumber}`, url: null, clickable: true, popover: true }, ]; } else if (item.Type === 'Episode') { // Get series details for the series name @@ -124,26 +135,36 @@ // Get season details for the season name const seasonDetails = await getItemDetails(item.ParentId); - const seasonName = seasonDetails ? (seasonDetails.Name || `Season ${seasonDetails.IndexNumber}`) : `Season ${item.ParentIndexNumber}`; + const seasonName = seasonDetails + ? seasonDetails.Name || `Season ${seasonDetails.IndexNumber}` + : `Season ${item.ParentIndexNumber}`; structure.elements = [ { text: 'Shows', url: `#/tv${urlSuffix}${queryParams}&tab=0`, clickable: true }, - { text: seriesName, url: `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId}&serverId=${ApiClient.serverId()}`, clickable: true }, + { + text: seriesName, + url: `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId}&serverId=${ApiClient.serverId()}`, + clickable: true, + }, { text: seasonName, url: null, clickable: true, popover: true }, - { text: `${item.ParentIndexNumber}x${padNumber(item.IndexNumber)} - ${item.Name}`, url: null, clickable: false } + { text: `${item.ParentIndexNumber}x${padNumber(item.IndexNumber)} - ${item.Name}`, url: null, clickable: false }, ]; } else if (item.Type === 'MusicArtist') { structure.elements = [ { text: 'Music', url: `#/music${urlSuffix}${queryParams}&tab=0`, clickable: true }, { text: item.Name, url: null, clickable: false }, - { text: 'All Albums', url: null, clickable: true, popover: true } + { text: 'All Albums', url: null, clickable: true, popover: true }, ]; } else if (item.Type === 'MusicAlbum') { structure.elements = [ { text: 'Music', url: `#/music${urlSuffix}${queryParams}&tab=0`, clickable: true }, - { text: item.AlbumArtist, url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true }, + { + text: item.AlbumArtist, + url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, + clickable: true, + }, { text: item.Name, url: null, clickable: true, popover: true }, - { text: 'All Songs', url: null, clickable: true, popover: true } + { text: 'All Songs', url: null, clickable: true, popover: true }, ]; } else if (item.Type === 'Audio') { // Get album details for the album name and artist info @@ -154,9 +175,13 @@ structure.elements = [ { text: 'Music', url: `#/music${urlSuffix}${queryParams}&tab=0`, clickable: true }, - { text: artistName, url: artistId ? `${ApiClient._serverAddress}/web/#/details?id=${artistId}&serverId=${ApiClient.serverId()}` : null, clickable: !!artistId }, + { + text: artistName, + url: artistId ? `${ApiClient._serverAddress}/web/#/details?id=${artistId}&serverId=${ApiClient.serverId()}` : null, + clickable: !!artistId, + }, { text: albumName, url: null, clickable: true, popover: true }, - { text: item.Name, url: null, clickable: false } + { text: item.Name, url: null, clickable: false }, ]; } @@ -168,8 +193,8 @@ const differences = []; // Check if we have the right number of elements - const existingElements = Array.from(breadcrumbContainer.children).filter(child => - child.classList.contains('kefinTweaks-breadcrumb-element') + const existingElements = Array.from(breadcrumbContainer.children).filter((child) => + child.classList.contains('kefinTweaks-breadcrumb-element'), ); log('Comparing breadcrumbs - existing:', existingElements.length, 'target:', target.elements.length); @@ -192,14 +217,14 @@ const targetClickable = targetElement.clickable; // Check if popover functionality needs to be updated - const needsPopoverUpdate = targetElement.popover && ( - existingText !== targetElement.text || - existingClickable !== targetClickable || - // Force update for "All Seasons", "All Albums", and "All Songs" elements since the underlying data changes - targetElement.text === 'All Seasons' || - targetElement.text === 'All Albums' || - targetElement.text === 'All Songs' - ); + const needsPopoverUpdate = + targetElement.popover && + (existingText !== targetElement.text || + existingClickable !== targetClickable || + // Force update for "All Seasons", "All Albums", and "All Songs" elements since the underlying data changes + targetElement.text === 'All Seasons' || + targetElement.text === 'All Albums' || + targetElement.text === 'All Songs'); if (existingText !== targetElement.text || existingClickable !== targetClickable || needsPopoverUpdate) { differences.push({ action: 'update', index: i, element: targetElement }); @@ -220,8 +245,8 @@ log('Applying breadcrumb differences:', differences.length, 'changes needed'); // Get existing elements - const existingElements = Array.from(breadcrumbContainer.children).filter(child => - child.classList.contains('kefinTweaks-breadcrumb-element') + const existingElements = Array.from(breadcrumbContainer.children).filter((child) => + child.classList.contains('kefinTweaks-breadcrumb-element'), ); // Process differences in reverse order to maintain indices @@ -248,7 +273,6 @@ } log('Added new breadcrumb element:', diff.element.text); - } else if (diff.action === 'remove') { // Remove element and its separator const elementToRemove = existingElements[diff.index]; @@ -260,7 +284,6 @@ breadcrumbContainer.removeChild(elementToRemove); log('Removed breadcrumb element at index:', diff.index); } - } else if (diff.action === 'update') { // Update existing element const elementToUpdate = existingElements[diff.index]; @@ -378,7 +401,7 @@ window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${seasons[0].Id}&serverId=${ApiClient.serverId()}`; }); } else { - const currentSeason = seasons.find(season => season.Id === item.ParentId); + const currentSeason = seasons.find((season) => season.Id === item.ParentId); element.addEventListener('click', (e) => { e.stopPropagation(); const popover = createPopover(seasons, currentSeason, (selectedSeason) => { @@ -393,14 +416,12 @@ let albumArtists = item.AlbumArtists; if (!albumArtists || !Array.isArray(albumArtists) || albumArtists.length === 0) { const albumDetails = await getItemDetails(item.Id); - albumArtists = (albumDetails && albumDetails.AlbumArtists) ? albumDetails.AlbumArtists : []; + albumArtists = albumDetails && albumDetails.AlbumArtists ? albumDetails.AlbumArtists : []; } - - const artistIds = (albumArtists || []).map(a => a.Id).filter(Boolean); - const albums = artistIds.length > 0 - ? await getAlbumsByArtistIds(artistIds) - : await getAlbumsIfNeeded(item.ParentId); - + + const artistIds = (albumArtists || []).map((a) => a.Id).filter(Boolean); + const albums = artistIds.length > 0 ? await getAlbumsByArtistIds(artistIds) : await getAlbumsIfNeeded(item.ParentId); + if (albums.length === 1) { element.addEventListener('click', () => { window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`; @@ -417,12 +438,13 @@ } else if (item.Type === 'Audio') { // Audio (Song) page - clicking album name should show albums from all AlbumArtists of the album const albumDetails = await getItemDetails(item.ParentId); - const albumArtists = (albumDetails && albumDetails.AlbumArtists) ? albumDetails.AlbumArtists : []; - const artistIds = albumArtists.map(a => a.Id).filter(Boolean); - const albums = artistIds.length > 0 - ? await getAlbumsByArtistIds(artistIds) - : await getAlbumsIfNeeded(albumDetails ? albumDetails.ParentId : null); - + const albumArtists = albumDetails && albumDetails.AlbumArtists ? albumDetails.AlbumArtists : []; + const artistIds = albumArtists.map((a) => a.Id).filter(Boolean); + const albums = + artistIds.length > 0 + ? await getAlbumsByArtistIds(artistIds) + : await getAlbumsIfNeeded(albumDetails ? albumDetails.ParentId : null); + if (albums && albums.length === 1) { element.addEventListener('click', () => { window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`; @@ -463,15 +485,30 @@ } // DOM manipulation + function isModernUI() { + if (ApiClient._appVersion && ApiClient._appVersion.startsWith('12') && localStorage.getItem('layout')?.length === 0) { + return true; + } + + return !!document.querySelector('main.MuiBox-root'); + } + + function resolveBreadcrumbMount() { + if (isModernUI()) { + return document.querySelector('.MuiToolbar-root'); + } + return document.querySelector('.skinHeader .headerLeft'); + } + function createBreadcrumbContainer() { if (breadcrumbContainer) { return breadcrumbContainer; } - // Find the header left container - const headerLeft = document.querySelector('.skinHeader .headerLeft'); - if (!headerLeft) { - error('Could not find .skinHeader .headerLeft element'); + const isModern = isModernUI(); + const mount = resolveBreadcrumbMount(); + if (!mount) { + error(isModern ? 'Could not find .MuiToolbar-root for breadcrumbs' : 'Could not find .skinHeader .headerLeft element'); return null; } @@ -479,6 +516,9 @@ const wrapper = document.createElement('div'); wrapper.id = 'kefinTweaks-breadcrumbs-wrapper'; wrapper.className = 'kefinTweaks-breadcrumbs-wrapper'; + if (isModern) { + wrapper.classList.add('kefinTweaks-breadcrumbs-wrapper--modern'); + } wrapper.style.cssText = ` display: none; `; @@ -501,12 +541,39 @@ wrapper.appendChild(breadcrumbContainer); wrapper.appendChild(popoverContainer); - headerLeft.appendChild(wrapper); + mount.appendChild(wrapper); - log('Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft'); + log( + isModern + ? 'Created breadcrumb container inside .MuiToolbar-root' + : 'Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft', + ); return breadcrumbContainer; } + function ensureBreadcrumbContainer(retries = 20) { + const existing = createBreadcrumbContainer(); + if (existing) return Promise.resolve(existing); + + return new Promise((resolve) => { + let attempts = 0; + const tryMount = () => { + const container = createBreadcrumbContainer(); + if (container) { + resolve(container); + return; + } + attempts += 1; + if (attempts >= retries) { + resolve(null); + return; + } + setTimeout(tryMount, 100); + }; + tryMount(); + }); + } + function showBreadcrumbs() { const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper'); const wideScreen = isWideScreen(); @@ -538,7 +605,7 @@ } closePopover(); } - + function closePopover() { if (activePopover) { activePopover.remove(); @@ -548,16 +615,16 @@ function createPopover(items, currentItem, onItemClick) { const popover = document.createElement('div'); - popover.className = 'kefinTweaks-popover'; + popover.className = 'kefinTweaks-popover itemDetailsGroup'; popover.style.display = 'block'; // Ensure it's visible log('Creating popover with', items.length, 'items'); let selectedItemElement = null; - items.forEach(item => { + items.forEach((item) => { const itemElement = document.createElement('div'); - itemElement.className = 'kefinTweaks-popover-item'; + itemElement.className = 'kefinTweaks-popover-item detailsGroupItem'; if (currentItem && item.Id === currentItem.Id) { itemElement.classList.add('selected'); @@ -590,7 +657,7 @@ selectedItemElement.scrollIntoView({ behavior: 'instant', block: 'nearest', - inline: 'nearest' + inline: 'nearest', }); log('Auto-scrolled to selected item:', selectedItemElement.textContent); }, 10); @@ -610,16 +677,31 @@ const popoverContainer = document.getElementById('kefinTweaks-popover-container'); if (popoverContainer) { // Calculate the left offset to align with the trigger element - // Since breadcrumbs are now in the header, we need to calculate relative to the page const triggerRect = triggerElement.getBoundingClientRect(); const popoverContainerRect = popoverContainer.getBoundingClientRect(); - - // Calculate offset from popover container to trigger element const leftOffset = triggerRect.left - popoverContainerRect.left - 22; popover.style.left = `${leftOffset}px`; + // Decide whether to open above or below based on viewport space + // When breadcrumbs are at the bottom of the page, open above + const viewportHeight = window.innerHeight; + const spaceBelow = viewportHeight - triggerRect.bottom; + const spaceAbove = triggerRect.top; + const showAbove = spaceBelow < spaceAbove; + + popoverContainer.classList.toggle('kefinTweaks-popover-container--above', showAbove); + popover.classList.toggle('kefinTweaks-popover--above', showAbove); + // Clear inline top when above so CSS bottom: 100% takes effect; restore when below + if (showAbove) { + popoverContainer.style.top = ''; + popoverContainer.style.bottom = '100%'; + } else { + popoverContainer.style.top = 'calc(100% - 15px)'; + popoverContainer.style.bottom = ''; + } + popoverContainer.appendChild(popover); - log('Popover added to popover container with left offset:', leftOffset); + log('Popover added to popover container with left offset:', leftOffset, 'showAbove:', showAbove); } else { log('Popover container not found, falling back to trigger element'); triggerElement.style.position = 'relative'; @@ -646,11 +728,17 @@ if (!currentUserId) { throw new Error('No user ID found'); } - + const url = ApiClient.getUrl(`Users/${currentUserId}/Items/${itemId}`); - const response = await ApiClient.getJSON(url); - log('Retrieved item details:', response.Name, response.Type); - return response; + const response = await window.apiHelper.getQuery(url, { useCache: true }); + + if (!response.data) { + response.data = await response.dataPromise; + } + + const item = response.data; + log('Retrieved item details:', item.Name, item.Type); + return item; } catch (err) { console.error('Failed to get item details:', err); return null; @@ -659,13 +747,14 @@ async function getSeasons(seriesId) { try { - const url = ApiClient.getUrl(`Shows/${seriesId}/Seasons`, { - userId: ApiClient.getCurrentUserId(), - imageTypeLimit: 1 - }); - const response = await ApiClient.getJSON(url); - log('Retrieved seasons:', response.Items?.length || 0); - return response.Items || []; + const url = ApiClient.getUrl(`Shows/${seriesId}/Seasons`); + const response = await window.apiHelper.getQuery(url, { useCache: true }); + if (!response.data) { + response.data = await response.dataPromise; + } + const seasons = response.data.Items || response.data || []; + log('Retrieved seasons:', seasons.length); + return seasons; } catch (err) { error('Failed to get seasons:', err); return []; @@ -683,11 +772,15 @@ StartIndex: 0, CollapseBoxSetItems: false, AlbumArtistIds: artistId, - SortBy: 'PremiereDate,ProductionYear,Sortname' + SortBy: 'PremiereDate,ProductionYear,Sortname', }); - const response = await ApiClient.getJSON(url); - log('Retrieved albums:', response.Items?.length || 0); - return response.Items || []; + const response = await window.apiHelper.getQuery(url, { useCache: true }); + if (!response.data) { + response.data = await response.dataPromise; + } + const albums = response.data.Items || response.data || []; + log('Retrieved albums:', albums.length); + return albums; } catch (err) { error('Failed to get albums:', err); return []; @@ -706,10 +799,13 @@ StartIndex: 0, CollapseBoxSetItems: false, AlbumArtistIds: idsParam, - SortBy: 'PremiereDate,ProductionYear,Sortname' + SortBy: 'PremiereDate,ProductionYear,Sortname', }); - const response = await ApiClient.getJSON(url); - const items = response.Items || []; + const response = await window.apiHelper.getQuery(url, { useCache: true }); + if (!response.data) { + response.data = await response.dataPromise; + } + const items = response.data.Items || response.data || []; // Deduplicate by Id when multiple artists overlap const unique = []; const seen = new Set(); @@ -736,11 +832,15 @@ Fields: 'ParentId', Limit: 100, StartIndex: 0, - SortBy: 'IndexNumber,SortName' + SortBy: 'IndexNumber,SortName', }); - const response = await ApiClient.getJSON(url); - log('Retrieved songs:', response.Items?.length || 0); - return response.Items || []; + const response = await window.apiHelper.getQuery(url, { useCache: true }); + if (!response.data) { + response.data = await response.dataPromise; + } + const songs = response.data.Items || response.data || []; + log('Retrieved songs:', songs.length); + return songs; } catch (err) { error('Failed to get songs:', err); return []; @@ -755,6 +855,26 @@ return separator; } + // Serialize page-change work so overlapping view callbacks cannot race the DOM. + // Latest-wins pending: overwrite the single pending slot; drain runs one at a time. + let pageChangePending = undefined; // undefined = empty; null/item = pending job + let pageChangeDrain = Promise.resolve(); + + function enqueuePageChange(item = null) { + pageChangePending = item; + pageChangeDrain = pageChangeDrain + .then(async () => { + if (pageChangePending === undefined) return; + const next = pageChangePending; + pageChangePending = undefined; + await handlePageChange(next); + }) + .catch((err) => { + error('Breadcrumb page change queue failed:', err); + }); + return pageChangeDrain; + } + // Page change handler // Simplified page change handler async function handlePageChange(item = null) { @@ -770,7 +890,7 @@ hideBreadcrumbs(); return; } - + // Check if the user is logged in if (!ApiClient._loggedIn) { clearBreadcrumbs(); @@ -779,7 +899,7 @@ } // Extract item ID - if (!item || !item.Id) { + if (!item || !item.Id || (item.Type !== 'Series' && item.Type !== 'Season' && item.Type !== 'Episode')) { clearBreadcrumbs(); hideBreadcrumbs(); return; @@ -794,7 +914,12 @@ // Determine target breadcrumb structure const targetStructure = await getTargetBreadcrumbStructure(item); - log('Target breadcrumb structure:', targetStructure.elements.map(e => e.text)); + log( + 'Target breadcrumb structure:', + targetStructure.elements.map((e) => e.text), + ); + + const breadcrumbContainer = getBreadcrumbContainer(); // Check if we need to create breadcrumbs from scratch if (!breadcrumbContainer || !currentBreadcrumbs) { @@ -807,13 +932,16 @@ } // Update state - currentBreadcrumbs = targetStructure; + // Update the currentBreadcrumbs from the current page + if (breadcrumbContainer && breadcrumbContainer.children) { + currentBreadcrumbs = Array.from(breadcrumbContainer.children); + } + //currentBreadcrumbs = targetStructure; currentItemId = item.Id; log('About to show breadcrumbs...'); showBreadcrumbs(); log('Breadcrumbs updated successfully'); - } catch (err) { error('Error handling page change:', err); clearBreadcrumbs(); @@ -823,9 +951,13 @@ // Create breadcrumbs from scratch async function createBreadcrumbsFromScratch(targetStructure) { - // Create container if it doesn't exist + // Create container if it doesn't exist (retry briefly on ModernUI if toolbar not ready) if (!breadcrumbContainer) { - breadcrumbContainer = createBreadcrumbContainer(); + breadcrumbContainer = await ensureBreadcrumbContainer(); + } + if (!breadcrumbContainer) { + error('Breadcrumb container unavailable'); + return; } // Build all elements first @@ -846,11 +978,15 @@ breadcrumbContainer.innerHTML = ''; // Append all elements at once - elements.forEach(el => breadcrumbContainer.appendChild(el)); + elements.forEach((el) => breadcrumbContainer.appendChild(el)); log('Created breadcrumbs from scratch with', targetStructure.elements.length, 'elements'); } + function getBreadcrumbContainer() { + return document.getElementById('kefinTweaks-breadcrumbs'); + } + // Window resize handler function handleResize() { if (currentBreadcrumbs && breadcrumbContainer) { @@ -871,18 +1007,19 @@ log('Registering breadcrumb handler with KefinTweaksUtils'); // Register handler for all pages (breadcrumbs can appear on any detail page) - window.KefinTweaksUtils.onViewPage(async (view, element, hash, itemPromise) => { - try { - // Await the item promise to get the actual item data - const item = await itemPromise; - // Run our custom code - handlePageChange(item); - } catch (err) { - error('Breadcrumb page change handler failed:', err); - } - }, { - pages: [] // Empty array means all pages - }); + window.KefinTweaksUtils.onViewPage( + async (view, element, hash, itemPromise) => { + try { + const item = await itemPromise; + await enqueuePageChange(item); + } catch (err) { + error('Breadcrumb page change handler failed:', err); + } + }, + { + pages: [], // Empty array means all pages + }, + ); } else { error('KefinTweaksUtils not available, breadcrumbs may not work correctly'); } @@ -891,7 +1028,7 @@ window.addEventListener('resize', handleResize); // Initial check - handlePageChange(); + enqueuePageChange(null); log('Breadcrumbs initialized successfully'); } @@ -902,5 +1039,4 @@ } else { initialize(); } - })(); diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css index 88c9bd6..27ef324 100644 --- a/scripts/cardBuilder.css +++ b/scripts/cardBuilder.css @@ -1,410 +1,4797 @@ /* Spotlight Section Styles */ +@import url("https://fonts.googleapis.com/css2?family=Alfa+Slab+One&family=Anton&family=Changa+One:ital@0;1&family=Exo:ital,wght@0,100..900;1,100..900&family=Orbitron:wght@400..900&family=Oswald:wght@200..700&family=Prata&family=Roboto:ital,wght@0,100..900;1,100..900&family=Unbounded:wght@200..900&family=VT323&display=swap"); .spotlight-section { - position: relative; - width: calc(100% - (2 * max(env(safe-area-inset-left), 3.3%))); - margin-bottom: 2em; + position: relative; + width: calc(100% - (1 * max(env(safe-area-inset-left), 3.3%))); + margin: 2em 0; } .spotlight-banner-container { - position: relative; - width: 100%; - height: 450px; - overflow: hidden; - border-radius: 8px; + position: relative; + border-radius: 8px; +} + +.layout-mobile .spotlight-banner-container { + display: flex; + justify-items: center; + align-items: center; + justify-content: center; +} + +.spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-banner-container { + height: 40vh; +} + +/* Size variants */ +/* .spotlight-section[data-layout="Borderless"][data-size="full"][data-order="1"] .spotlight-banner-container { + height: 94vh !important; +} */ + +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-banner-container { + height: 50vh; +} + +.spotlight-section[data-size="full"] .spotlight-banner-container { + height: 94vh !important; +} + +.layout-mobile .spotlight-section[data-size="full"] .spotlight-banner-container { + height: 85vh !important; } .spotlight-items-container { - position: relative; - width: 100%; - height: 100%; + 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; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center; + transition: opacity 0.8s ease-in-out; + isolation: isolate; } -.spotlight-item[data-index="0"] { - opacity: 1; +.spotlight-item:not([data-active]):not([data-fade-out]) { + display: none; } -.spotlight-item:not([data-index="0"]) { - opacity: 0; +/* Crossfade: outgoing slide fades to 0 */ +.spotlight-item[data-fade-out] { + opacity: 0; + pointer-events: none; +} + +/* Crossfade: incoming slide starts at 0, then transitions to 1 when data-entering is removed */ +.spotlight-item[data-active][data-entering] { + opacity: 0; +} + +.spotlight-item[data-active]:not([data-entering]) { + opacity: 1; +} + +/* Skeleton spotlight: single full-container placeholder; fill area and match layout/size */ +.skeleton-spotlight-item { + right: 0; + bottom: 0; + width: 100%; + height: 100%; +} +.spotlight-section[data-layout="Border"] .skeleton-spotlight-item { + border-radius: 8px; +} + +.spotlight-section[data-layout="Border"] .spotlight-background-dual { + border-radius: 8px; +} + +.spotlight-background-layer { + position: absolute; + top: 0; + bottom: 0; + overflow: hidden; +} + +.spotlight-background-layer img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; +} + +.spotlight-background-layer img.animate, +.spotlight-background-single img.animate { + filter: blur(0); + transition: filter 0.3s ease-in-out; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + transform-origin: center center; + animation: var(--spotlight-slide-animation, kenBurnsZoomIn) 15s linear both; +} + +/* Single backdrop (when tileCount is 1 or only one image) */ +.spotlight-background-single, +.spotlight-background-dual { + position: absolute; + z-index: 1; + overflow: hidden; + width: 100%; + height: 160%; + transform: translateY(-15%); + -webkit-mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 2), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 2)), transparent 100% ); + mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 2), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 2)), transparent 100% ); + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; + -webkit-mask-position: 0 0; + mask-position: 0 0; +} + +.spotlight-section[data-size="large"] .spotlight-background-single, +.spotlight-section[data-size="large"] .spotlight-background-dual { + height: 140%; + transform: translateY(-10%); + -webkit-mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 4), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 4)), transparent 100% ); + mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 4), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 4)), transparent 100% ); +} + +.spotlight-section[data-size="full"] .spotlight-background-single, +.spotlight-section[data-size="full"] .spotlight-background-dual { + height: 125%; + transform: translateY(-5%); + -webkit-mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 2), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 4)), transparent 100% ); + mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 2), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 4)), transparent 100% ); +} + +.spotlight-background-single img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; +} + +/* Cycle backdrops: two stacked layers for crossfade (same as slide transition) */ +.spotlight-background-single.cycle-backdrops img { + position: absolute; + inset: 0; +} +.spotlight-background-single.cycle-backdrops img:first-child { + z-index: 1; +} +.spotlight-background-single.cycle-backdrops img:last-child { + z-index: 2; +} + +@keyframes kenBurnsZoomIn { + from { + transform: scale3d(1, 1, 0); + } + to { + transform: scale3d(1.12, 1.12, 1); + } +} + +@keyframes kenBurnsZoomOut { + from { + transform: scale3d(1.15, 1.15, 0); + } + to { + transform: scale3d(1.02, 1.02, 1); + } +} + +/* Full-screen: start images pre-scaled so they extend past edges from the start (no hard edge before zoom) */ +@keyframes kenBurnsZoomInFullscreen { + from { + transform: scale3d(1.08, 1.08, 0); + } + to { + transform: scale3d(1.12, 1.12, 1); + } +} + +@keyframes kenBurnsZoomOutFullscreen { + from { + transform: scale3d(1.15, 1.15, 0); + } + to { + transform: scale3d(1.08, 1.08, 1); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: scale3d(1, 1, 0) translateY(30px); + } + to { + opacity: 1; + transform: scale3d(1, 1, 1) translateY(0); + } +} + +@keyframes fadeInDown { + from { + opacity: 0; + transform: scale3d(1, 1, 0) translateY(-30px); + } + to { + opacity: 1; + transform: scale3d(1, 1, 1) translateY(0); + } +} + +@keyframes kenBurnsPanRight { + from { + transform: scale3d(1.1, 1.1, 0) translateX(-5%); + } + to { + transform: scale3d(1.1, 1.1, 1) translateX(5%); + } +} + +@keyframes kenBurnsPanLeft { + from { + transform: scale3d(1.1, 1.1, 0) translateX(5%); + } + to { + transform: scale3d(1.1, 1.1, 0) translateX(-5%); + } +} + +@keyframes kenBurnsPanUp { + from { + transform: scale3d(1.1, 1.1, 0) translateY(5%); + } + to { + transform: scale3d(1.1, 1.1, 0) translateY(-5%); + } +} + +@keyframes kenBurnsDiagonal { + from { + transform: scale3d(1.08, 1.08, 0) translate(-5%, 5%); + } + to { + transform: scale3d(1.13, 1.13, 0) translate(5%, -5%); + } +} + +@keyframes fadeInScale { + from { + opacity: 0; + transform: scale3d(0.95, 0.95, 0); + } + to { + opacity: 1; + transform: scale3d(1, 1, 0); + } +} + +@keyframes parallaxFloat { + 0%, + 100% { + transform: scale3d(1, 1, 0) translateY(0); + } + 50% { + transform: scale3d(1, 1, 1) translateY(-12px); + } +} + +@keyframes depthPulse { + 0%, + 100% { + transform: scale3d(1, 1, 0); + } + 50% { + transform: scale3d(1.03, 1.03, 1); + } +} + +@keyframes slowRotate { + from { + transform: scale3d(1.15, 1.15, 0) rotate(0deg); + } + to { + transform: scale3d(1.15, 1.15, 1) rotate(3deg); + } +} + +@keyframes breathe { + 0%, + 100% { + transform: scale3d(1, 1, 0); + opacity: 0.9; + } + 50% { + transform: scale3d(1.04, 1.04, 1); + opacity: 1; + } +} + +@keyframes heatHaze { + 0%, + 100% { + transform: scale3d(1.05, 1.05, 1) skewX(0deg); + } + 25% { + transform: scale3d(1.06, 1.06, 1) skewX(0.3deg); + } + 75% { + transform: scale3d(1.05, 1.05, 1) skewX(-0.3deg); + } +} + +@keyframes colorWash { + 0% { + filter: saturate(1) brightness(1); + } + 50% { + filter: saturate(1.5) brightness(1.25); + } + 100% { + filter: saturate(1) brightness(1); + } +} + +@keyframes vignetteIn { + from { + box-shadow: inset 0 0 0px rgba(0, 0, 0, 0); + } + to { + box-shadow: inset 0 0 120px rgba(0, 0, 0, 0.6); + } +} + +/* Layer masks for elegant crossfade - overlap in center (45-55%) */ +.spotlight-background-layer-left { + left: 0; + width: 55%; + -webkit-mask-image: linear-gradient(to right, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 1) 75%, rgba(0, 0, 0, 0.5) 90%, rgba(0, 0, 0, 0) 100%); + mask-image: linear-gradient(to right, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 1) 75%, rgba(0, 0, 0, 0.5) 90%, rgba(0, 0, 0, 0) 100%); + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +.spotlight-background-layer-right { + right: 0; + width: 55%; + -webkit-mask-image: linear-gradient( + to right, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0.25) 8%, + rgba(0, 0, 0, 0.5) 18%, + rgba(0, 0, 0, 1) 35%, + rgba(0, 0, 0, 1) 100% + ); + mask-image: linear-gradient( + to right, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0.25) 8%, + rgba(0, 0, 0, 0.5) 18%, + rgba(0, 0, 0, 1) 35%, + rgba(0, 0, 0, 1) 100% + ); + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +/* Three-tile: center layer – symmetric soft fade on both edges for seamless left/center and center/right blend */ +.spotlight-background-layer-center { + left: 31.25%; + width: 33.334%; + -webkit-mask-image: linear-gradient( + to right, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0.25) 5%, + rgba(0, 0, 0, 0.5) 10%, + rgba(0, 0, 0, 1) 15%, + rgba(0, 0, 0, 1) 85%, + rgba(0, 0, 0, 0.5) 90%, + rgba(0, 0, 0, 0.25) 95%, + rgba(0, 0, 0, 0) 100% + ); + mask-image: linear-gradient( + to right, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0.25) 5%, + rgba(0, 0, 0, 0.5) 10%, + rgba(0, 0, 0, 1) 15%, + rgba(0, 0, 0, 1) 85%, + rgba(0, 0, 0, 0.5) 90%, + rgba(0, 0, 0, 0.25) 95%, + rgba(0, 0, 0, 0) 100% + ); + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +/* Three-tile: give the center more visual weight */ +.spotlight-section[data-tile-count="3"] .spotlight-background-layer-left, +.spotlight-section[data-tile-count="3"] .spotlight-background-layer-right { + width: 35%; +} + +.spotlight-section[data-tile-count="3"] .spotlight-background-layer-center { + left: 30%; + width: 40%; +} + +.spotlight-section .spotlight-item .spotlight-background-dual { + background: black; +} + +/* Mobile: show only left backdrop (full width) */ +.layout-mobile .spotlight-background-layer-center, +.layout-mobile .spotlight-background-layer-right { + display: none !important; +} +.layout-mobile .spotlight-background-layer-left { + width: 100% !important; + mask-image: none !important; +} + +/* Logo section heights per size - fixed to prevent layout shift when sliding between items */ +.spotlight-section { + --spotlight-logo-height: 118px; +} +.spotlight-section[data-size="large"] { + --spotlight-logo-height: 180px; +} +.spotlight-section[data-size="full"] { + --spotlight-logo-height: 240px; } .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; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 45%; + padding: 40px; + color: white; + display: grid; + grid-template-rows: var(--spotlight-logo-height) auto 1fr minmax(48px, auto); + grid-template-areas: "logo" "metadata" "overview" "buttons"; + align-content: start; + gap: 1em; + z-index: 2; + pointer-events: none; +} + +.spotlight-overlay a, +.spotlight-overlay button, +.spotlight-overlay .spotlight-buttons-section, +.spotlight-overlay .spotlight-buttons-container, +.spotlight-overlay .spotlight-info-button, +.spotlight-overlay .emby-button, +.spotlight-overlay button.metadata-container { + pointer-events: auto; +} + +.spotlight-overlay .spotlight-logo-section { + grid-area: logo; + height: 100%; + min-height: 0; + display: flex; + align-items: flex-end; + overflow: hidden; + align-content: end; + flex-wrap: wrap; + max-width: 700px; +} +.spotlight-overlay .spotlight-metadata-section { + grid-area: metadata; +} +.spotlight-overlay .spotlight-overview-section { + grid-area: overview; + display: flex; + flex-direction: column; + justify-content: flex-start; + overflow: hidden; + gap: 0; +} +.spotlight-overlay .spotlight-buttons-section { + grid-area: buttons; + display: flex; + align-items: start; } .spotlight-overlay.has-title { - padding: 70px 40px 20px 40px; + padding: 50px 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); + font-size: 18px; + font-weight: 600; + z-index: 2; + letter-spacing: 2px; + word-spacing: 2px; +} + +.layout-mobile .spotlight-section-title { + letter-spacing: 1px; +} + +.spotlight-section-title a { + font-size: 1.1rem; } .spotlight-item-title { - margin: 0 0 16px 0; - font-size: 1.6em; - font-weight: bold; - height: 80px; - align-content: end; + margin: 0; + font-size: 3.5em; + font-weight: bold; + min-height: 0; + display: flex; + align-items: flex-end; + overflow: hidden; + max-height: 100%; +} + +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-item-title { + font-size: 3rem; +} + +.layout-mobile .spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-item-title { + font-size: 2rem; } .spotlight-item-logo { - max-width: 206.8px; - height: 80px; - object-fit: contain; - margin-bottom: 16px; - margin-top: 10px; + height: 100%; + width: 100%; + object-fit: contain; + margin: 0; + background-size: contain; + background-repeat: no-repeat; + background-position: left; + max-height: 300px; +} + +.layout-mobile .spotlight-item-logo { + background-position: bottom; } .spotlight-clearart { - position: absolute; - bottom: 0px; - right: 0px; - max-height: 70%; - object-fit: contain; - z-index: 5; - opacity: 0.9; - pointer-events: none; + 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; + -webkit-align-items: center; + align-items: center; + display: -webkit-flex; + display: flex; + -webkit-justify-content: center; + justify-content: center; + vertical-align: middle; } .spotlight-rating-star { - color: #4A9EFF; - font-size: 24px; + color: #f2b01e; + font-size: 1.4em; + height: auto !important; + width: auto !important; + margin-left: 0em !important; + margin-right: 0em !important; } .spotlight-rating-value { - font-size: 20px; - font-weight: 500; + font-size: 20px; + font-weight: 500; } .spotlight-rating-value.na { - opacity: 0.7; + opacity: 0.7; } .spotlight-metadata-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px; - font-size: 14px; - opacity: 0.9; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + font-size: 14px; + opacity: 0.9; + overflow: visible; + min-width: 0; } -.spotlight-metadata-row .spotlight-end-time { - opacity: 0.8; +.spotlight-metadata-row .sectionTabs { + font-size: inherit; + } + +.spotlight-metadata-row .itemDetailsGroup, +.spotlight-metadata-row .sectionTabs { + flex-shrink: 0; +} + +.spotlight-credits-row .spotlight-truncated-list { + flex-shrink: 1; + min-width: 0; +} + +/* Keep each name on one line (no mid-name wrapping) */ +.spotlight-credits-row .spotlight-person-link, +.spotlight-credits-row + .spotlight-truncated-list + .detailsGroupItem + > span:not(.spotlight-list-separator):not(.spotlight-truncated-list-label), +.spotlight-credits-row + .spotlight-truncated-list + .emby-tab-button + > span:not(.spotlight-list-separator):not(.spotlight-truncated-list-label) { + white-space: nowrap; +} + +/* Metadata row icons (end time, etc.) - same pattern as truncated list icons */ +.spotlight-metadata-row .emby-tab-button[data-icon] { + display: inline-flex; + align-items: center; +} +.spotlight-metadata-row .emby-tab-button[data-icon]::before { + content: attr(data-icon); + font-family: "Material Icons"; + font-size: 1.1em; + margin-right: 6px; + opacity: 0.9; + text-transform: none; +} + +.spotlight-metadata-row .spotlight-studio { + display: inline-flex; + align-items: center; +} +.spotlight-metadata-row .spotlight-studio::before { + content: "\ea40"; /* Material Icons 'apartment' codepoint */ + font-family: "Material Icons"; + font-weight: normal; + font-style: normal; + font-size: 1.1em; + margin-right: 6px; + opacity: 0.9; } .metadata-container { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 2em; + display: flex; + flex-direction: column; + align-items: start; + gap: 8px; + overflow: visible; + min-width: 0; + width: 100%; } .spotlight-truncated-list { - font-size: 14px; - opacity: 0.9; - position: relative; + font-size: 14px; + opacity: 0.9; + position: relative; + overflow: hidden; + min-width: 0; + max-width: 100%; } -.spotlight-truncated-list .spotlight-ellipsis { - cursor: pointer; +/* Tooltip for truncated lists - full list on hover after 1s, no layout shift */ +.spotlight-truncated-list-tooltip { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 8px; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.95); + color: white; + border-radius: 8px; + font-size: 14px; + line-height: 1.5; + max-width: 400px; + min-width: 200px; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + word-wrap: break-word; + display: flex; + flex-direction: column; + gap: 4px; } -.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-truncated-list-tooltip-items { + display: flex; + flex-direction: column; + gap: 4px; +} + +.spotlight-truncated-list-tooltip-item { + display: block; +} + +.spotlight-truncated-list-tooltip.visible { + opacity: 1; + pointer-events: auto; /* Allow hovering tooltip to keep it visible */ +} + +.spotlight-truncated-list-tooltip .spotlight-person-link, +.spotlight-truncated-list-tooltip .spotlight-genre-link, +.spotlight-truncated-list-tooltip .spotlight-studio-link { + color: inherit; + text-decoration: underline; + cursor: pointer; +} + +.spotlight-list-separator { + opacity: 0.75; + margin: 0 0.4em; + user-select: none; +} + +/* Overview: shown for all layouts with line clamping to avoid cut-off text */ +.spotlight-overview { + display: -webkit-box; + margin: 0; + font-size: 15px; + font-style: normal; + opacity: 0.9; + line-height: 1.45; + max-width: min(700px, 75%); + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +.spotlight-section[data-size="large"] .spotlight-overview { + -webkit-line-clamp: 4; +} + +.spotlight-section[data-size="full"] .spotlight-overview { + font-size: 1.05em; + -webkit-line-clamp: 4; } .spotlight-buttons-container { - display: flex; - gap: 12px; - align-items: center; - margin-top: auto; + display: flex; + gap: 5px; + align-items: center; + margin-top: auto; + position: relative !important; } .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; + width: 48px; + height: 48px; + 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); + background: rgba(255, 255, 255, 1); } .spotlight-play-button .material-icons { - font-size: 28px; + 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-buttons-container button:not(.btnPlay) { + width: 48px; + height: 48px; + border-radius: 50%; + 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[data-active="true"] .material-icons { + color: #4caf50; } .spotlight-watchlist-button .material-icons { - font-size: 28px; - color: white; + font-size: 28px; } .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; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + position: relative; + overflow: visible; } .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; + position: absolute !important; + text-align: left; + bottom: 100%; + left: 50%; + transform: translateX(-50%) !important; + margin-bottom: 12px !important; + 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; + visibility: hidden; + pointer-events: none; + transition: opacity 0.2s ease, visibility 0s linear 0.2s; + transition-delay: 0s; + z-index: 1000 !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + word-wrap: break-word; + max-height: unset !important; } -.spotlight-info-button:hover .spotlight-overview-tooltip { - opacity: 1; +/* Show only while hovering the info icon glyph — not the button/tooltip footprint */ +.spotlight-info-button:is(.emby-button):hover .spotlight-overview-tooltip { + opacity: 1; + visibility: visible; + pointer-events: none; + transition-delay: 0.5s, 0.5s; + display: block; } -.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; +/* Hover overview only on Normal desktop spotlights */ +.spotlight-section[data-size="large"] .spotlight-overview-tooltip, +.spotlight-section[data-size="full"] .spotlight-overview-tooltip, +.layout-mobile .spotlight-overview-tooltip { + display: none !important; +} + +/* TV: no hover tooltips; keep them out of the focus tree */ +.layout-tv .spotlight-truncated-list-tooltip, +.layout-tv .spotlight-overview-tooltip { + display: none !important; + pointer-events: none !important; + visibility: hidden !important; +} + +.spotlight-section:not(.hide)[data-section-id] > .spotlight-banner-container .spotlight-nav-container { + display: flex; + gap: 8px; + flex-shrink: 0; } .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; + background: transparent; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + margin: 0 !important; + 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; + background: rgba(255, 255, 255, 0.2); } .spotlight-dots { - position: absolute; - bottom: 20px; - left: 50%; - transform: translateX(-50%); - display: flex; - gap: 8px; - z-index: 10; + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 8px; + z-index: 2; } .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; + 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; + background: white; +} + +/* Numeric slide state: "N / X" via data attributes */ +.spotlight-dots.spotlight-dots-numeric { + background: rgb(0 0 0 / 40%); + border-radius: 5px; + padding: 0.25rem 0.5rem; + gap: 0; +} + +.spotlight-dots.spotlight-dots-numeric .spotlight-dot { + display: none; +} + +.spotlight-dots.spotlight-dots-numeric::after { + content: attr(data-index) " / " attr(data-total-items); + font-size: 14px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + letter-spacing: 0.5px; } .spotlight-genre-link, -.spotlight-person-link { - color: inherit; - text-decoration: underline; - cursor: pointer; +.spotlight-person-link, +.spotlight-studio-link { + color: inherit; + text-decoration: underline; + cursor: pointer; } .spotlight-empty-writer-container { - font-size: 14px; - opacity: 0.9; - height: 1em; + 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; - } + .metadata-container { + display: none !important; + } - .spotlight-clearart { - display: none !important; - } - - .spotlight-overlay { - width: calc(100% - 2rem); - padding: 5rem 1rem 3rem !important; - align-items: center; - background: rgba(0, 0, 0, 0.7); - } - - .spotlight-tagline { - max-height: 175px !important; - -webkit-line-clamp: 4; - } + .spotlight-nav-container { + display: none !important; + } - .spotlight-item-logo { - max-width: 100%; - height: 125px; - } + .spotlight-pause-button { + display: none !important; + } + + .spotlight-clearart { + display: none !important; + } + + .spotlight-overlay { + width: calc(100% - 2rem); + padding: 5rem 1rem 3rem !important; + align-items: center; + } + + .spotlight-tagline { + -webkit-line-clamp: 1; + bottom: 60px; + } } + +/* Match .cardText line boxes so skeleton→real swap does not shift section height */ +.skeleton-text-line { + display: inline-block; + border-radius: 4px; + background: rgba(255, 255, 255, 0.12); +} +.cardText-first .skeleton-text-line { + padding: 0 22.5% !important; +} +.cardText-secondary .skeleton-text-line { + padding: 0 15% !important; +} +.cardText-secondary:nth-child(3) .skeleton-text-line { + padding: 0 10% !important; +} +.skeleton-card .cardText-secondary { + opacity: 0.8; +} + +.skeleton-section-title { + width: 50%; + min-width: 8rem; +} + +.skeleton-section-caption { + width: 35%; + min-width: 6rem; + margin-top: 0.15em; +} + +/* One shimmer overlay per skeleton section — real sibling node, gated by .skeleton-shimmer-active (IO) */ +.itemsContainer, +.emby-scroller, +.custom-scroller, +.spotlight-banner-container { + position: relative; +} + +.skeleton-shimmer { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 2; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.08) 45%, + rgba(255, 255, 255, 0.16) 50%, + rgba(255, 255, 255, 0.08) 55%, + transparent 100% + ); + transform: translateX(-100%); + opacity: 0; +} + +.skeleton-section.skeleton-shimmer-active .skeleton-shimmer { + opacity: 1; + animation: skeleton-shimmer-sweep 1.4s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .skeleton-section.skeleton-shimmer-active .skeleton-shimmer { + animation: none !important; + opacity: 0 !important; + } +} + +/* Localize style/layout invalidation to section shells during enhance/lazy paint */ +.emby-scroller-container.custom-scroller-container, +.spotlight-section { + contain: layout style; +} + +/* Tagline always 1 line - Movie-specific override kept for any edge cases */ +[data-item-type="Movie"] .spotlight-tagline { + -webkit-line-clamp: 1; +} + +// Remove this if we switch back to Jellyfin's default scroll buttons +.emby-scrollbuttons.padded-right { + -webkit-align-items: center; + align-items: center; + color: #fff; + display: -webkit-flex; + display: flex; + -webkit-justify-content: center; + justify-content: center; + min-height: 24px; + min-width: 104px; + padding-top: .85em; + position: absolute; + top: 0; + z-index: 1; +} + +/* Blurhash placeholder canvas: fill card image area so it's visible until the real image loads */ +/* .cardScalable { + position: relative; +} +.cardScalable .blurhash-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; +} +.cardScalable .blurhash-canvas.lazy-hidden { + display: none !important; +} */ + +/* Progressive Section Reveal Animation */ +.cardbuilder-section-reveal { + opacity: 0; + transform: translateY(20px); + transition: + opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1), + transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* When section enters viewport, trigger animation */ +.cardbuilder-section-reveal.in-viewport { + opacity: 1; + transform: translateY(0); +} + +/* Shared section control buttons (show-all / refresh / configure) */ +.show-all-button, +.section-refresh-button, +.section-configure-button { + padding: 4px; + min-width: auto; + width: 32px; + height: 32px; + background: transparent; + border: none; + border-radius: 50%; + cursor: pointer; + display: inline-block; + align-items: center; + justify-content: center; + align-self: center; + color: var(--main-text, rgba(255, 255, 255, 0.87)); + font-size: 18px; + line-height: 1; + opacity: 0; + visibility: hidden; + transition: + opacity 0.25s ease, + visibility 0s linear 0.25s, + transform 0.3s ease, + color 0.3s ease, + background 0.2s ease !important; +} + +/* Show controls only while the section is hovered */ +.custom-scroller-container:hover .show-all-button, +.custom-scroller-container:hover .section-refresh-button, +.custom-scroller-container:hover .section-configure-button, +.emby-scroller-container:hover .show-all-button, +.emby-scroller-container:hover .section-refresh-button, +.emby-scroller-container:hover .section-configure-button, +.spotlight-section:hover .section-refresh-button, +.spotlight-section:hover .section-configure-button { + opacity: 0.65; + visibility: visible; + pointer-events: auto; + transition: + opacity 0.25s ease, + visibility 0s linear 0s, + transform 0.3s ease, + color 0.3s ease, + background 0.2s ease !important; +} + +.custom-scroller-container:hover .show-all-button:hover, +.custom-scroller-container:hover .section-refresh-button:hover, +.custom-scroller-container:hover .section-configure-button:hover, +.emby-scroller-container:hover .show-all-button:hover, +.emby-scroller-container:hover .section-refresh-button:hover, +.emby-scroller-container:hover .section-configure-button:hover, +.spotlight-section:hover .section-refresh-button:hover, +.spotlight-section:hover .section-configure-button:hover { + background: rgba(255, 255, 255, 0.1); + opacity: 1; +} + +.custom-scroller-container h2.sectionTitle.sectionTitle-cards { + align-items: center; + text-align: left; +} + +.custom-scroller-container .sectionTitleContainer { + display: flex; + align-items: center; + gap: 0.35em; +} + +.layout-mobile .custom-scroller-container .section-controls { + margin-left: auto !important; +} + +.section-controls { + display: flex; + align-items: center; + gap: 0.15em; + flex-shrink: 0; + position: relative; +} + +.section-controls-inline { + display: flex; + align-items: center; + gap: 0.15em; +} + +.section-controls-more { + padding: 4px; + min-width: auto; + width: 32px; + height: 32px; + background: transparent; + border: none; + border-radius: 50%; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + align-self: center; + color: var(--main-text, rgba(255, 255, 255, 0.87)); + font-size: 18px; + line-height: 1; + opacity: 0; + visibility: hidden; + transition: + opacity 0.25s ease, + visibility 0s linear 0.25s, + transform 0.3s ease, + color 0.3s ease, + background 0.2s ease; +} + +.custom-scroller-container:hover .section-controls-more, +.emby-scroller-container:hover .section-controls-more, +.spotlight-section:hover .section-controls-more { + opacity: 0.65; + visibility: visible; + pointer-events: auto; +} + +.custom-scroller-container:hover .section-controls-more:hover, +.emby-scroller-container:hover .section-controls-more:hover, +.spotlight-section:hover .section-controls-more:hover { + background: rgba(255, 255, 255, 0.1); + opacity: 1; +} + +.section-controls-more-popover { + position: absolute !important; + z-index: 1001; + display: block; + background-color: rgba(0, 0, 0, 0.95); + right: 0; + left: auto; + min-width: 12rem; +} + +/* Modal-hosted more menu: normal flow so the dialog body does not collapse */ +.section-controls-more-menu { + position: static; + display: block; + min-width: 12rem; + background: transparent; +} + +.section-controls-more-popover .section-controls-more-item, +.section-controls-more-menu .section-controls-more-item { + align-items: center; +} + +.section-controls-more-item { + display: flex; + align-items: center; + gap: 0.65em; + cursor: pointer; + white-space: nowrap; +} + +.section-controls-more-item-icon { + font-size: 1.15em; + opacity: 0.9; +} + +.section-controls-more-item-label { + flex: 1; +} + +:not(.layout-mobile) .section-controls-more { + display: none !important; +} + +@media (max-width: 899px) { + :not(.layout-mobile) .section-controls-more { + display: inline-flex !important; + opacity: 1; + visibility: visible; + pointer-events: auto; + } + + :not(.layout-mobile) .section-controls-inline .show-all-button, + :not(.layout-mobile) .section-controls-inline .section-refresh-button, + :not(.layout-mobile) .section-controls-inline .section-configure-button { + display: none !important; + } + + .spotlight-section { + position: relative; + } + + .spotlight-section .section-controls { + position: absolute; + right: 0.5rem; + bottom: 0.5rem; + z-index: 5; + margin: 0; + } +} + +.layout-mobile .spotlight-section { + position: relative; +} + +.layout-mobile .spotlight-section .section-controls { + position: absolute; + right: -0.5rem; + bottom: unset; + top: -4px; + z-index: 5; + margin: 0; +} + +.layout-mobile .section-controls-inline .show-all-button, +.layout-mobile .section-controls-inline .section-refresh-button, +.layout-mobile .section-controls-inline .section-configure-button { + display: none !important; +} + +.layout-mobile .spotlight-section .section-controls-more { + opacity: 0; +} + +.layout-mobile .section-controls-more { + display: inline-flex !important; + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.layout-mobile .spotlight-section:hover .section-controls-more { + opacity: 1; +} + +.kefinTweaks-popover.section-controls-more-menu > .kefinTweaks-popover-item { + align-items: center; + justify-content: space-between; + margin: 0 !important; + padding: 0.5em 0.9em; + max-width: unset !important; +} + +/* Grid (non-scrolling) item layouts — CSS Grid with JF card.scss column breakpoints. + Shape inferred from data-card-format (see resolveGridShape); default = portrait. + Columns use @container against the scroller width (not viewport), so inset / + max-width sections (e.g. 67vw series episodes) get fewer, correctly sized cards. */ +.custom-scroller-container > .custom-scroller { + container-type: inline-size; + container-name: kefin-items; +} + +.itemsContainer[data-layout="grid"] { + display: grid !important; + flex-wrap: unset; + white-space: normal !important; + transform: none !important; + transition: none !important; + grid-template-columns: repeat(3, 1fr); + gap: 1em !important; +} + +/* Cards fill tracks; override JF portraitCard/backdropCard % widths and Banner 33vw */ +.itemsContainer[data-layout="grid"] > .card, +.itemsContainer[data-layout="grid"] > .homeLibraryButton, +.emby-scroller-container[data-card-format="Banner"] .itemsContainer[data-layout="grid"] > .card { + width: unset !important; + max-width: 100% !important; + min-width: 0; + box-sizing: border-box; + align-self: start; +} + +.itemsContainer[data-layout="grid"] > .card > .cardBox, +.itemsContainer[data-layout="grid"] > .card .cardScalable { + width: unset !important; + max-width: 100% !important; + height: auto; +} + +.itemsContainer[data-layout="grid"] > .card > .cardBox { + margin-right: 0 !important; + } + +/* Portrait / Square column table (JF .portraitCard / .squareCard) — default on grid */ +@container kefin-items (min-width: 31.25em) { + .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(3, 1fr); + } +} +@container kefin-items (min-width: 43.75em) { + .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(4, 1fr); + } +} +@container kefin-items (min-width: 50em) { + .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(5, 1fr); + } +} +@container kefin-items (min-width: 75em) { + .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(6, 1fr); + } +} +@container kefin-items (min-width: 87.5em) { + .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(7, 1fr); + } +} +@container kefin-items (min-width: 100em) { + .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(8, 1fr); + } +} +@container kefin-items (min-width: 120em) { + .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(9, 1fr); + } +} +@container kefin-items (min-width: 131.25em) { + .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(10, 1fr); + } +} + +/* Backdrop shape: Backdrop, Thumb, Series Thumb, Logo, Clear Art, Button */ +.emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"], +.emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"], +.emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"], +.emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"], +.emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"], +.emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(1, 1fr); +} +@container kefin-items (min-width: 25em) { + .emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(2, 1fr); + } +} +@container kefin-items (min-width: 48.125em) { + .emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(3, 1fr); + } +} +@container kefin-items (min-width: 75em) { + .emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(4, 1fr); + } +} +@container kefin-items (min-width: 100em) { + .emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(5, 1fr); + } +} +@container kefin-items (min-width: 156.25em) { + .emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"], + .emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(6, 1fr); + } +} + +/* Banner shape */ +.emby-scroller-container[data-card-format="Banner"] .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(1, 1fr); +} +@container kefin-items (min-width: 50em) { + .emby-scroller-container[data-card-format="Banner"] .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(2, 1fr); + } +} +@container kefin-items (min-width: 75em) { + .emby-scroller-container[data-card-format="Banner"] .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(3, 1fr); + } +} +@container kefin-items (min-width: 131.25em) { + .emby-scroller-container[data-card-format="Banner"] .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(4, 1fr); + } +} + +/* TV: portrait/square → 6, backdrop → 4 (fixed, not container-width) */ +html.layout-tv .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(6, 1fr); +} +html.layout-tv .emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"], +html.layout-tv .emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"], +html.layout-tv .emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"], +html.layout-tv .emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"], +html.layout-tv .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"], +html.layout-tv .emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] { + grid-template-columns: repeat(4, 1fr); +} + +.itemsContainer[data-gapless="true"], +[data-border-style="film-reel"] .itemsContainer, +[data-border-style="wood"] .itemsContainer { + gap: 0 !important; +} + +.emby-scroller-container:not([data-card-title-visibility="hidden"]) .itemsContainer[data-layout="grid"]:not([data-gapless="true"]) { + gap: 1.5rem !important; +} + +.itemsContainer[data-gapless="true"] .card, +[data-border-style="film-reel"] .itemsContainer .card, +[data-border-style="wood"] .itemsContainer .card { + padding: 0 !important; +} + +.itemsContainer[data-gapless="true"] > .card > .cardBox, +[data-border-style="film-reel"] .itemsContainer .card > .cardBox, +[data-border-style="wood"] .itemsContainer .card > .cardBox { + margin-left: 0 !important; + margin-right: 0 !important; + margin-top: 0 !important; + margin-bottom: 0 !important; +} + +.itemsContainer[data-gapless="true"] .cardContent, +[data-border-style="film-reel"] .itemsContainer .cardContent, +[data-border-style="wood"] .itemsContainer .cardContent { + border-radius: 0 !important; +} + +/* Hide section name text only; keep refresh/configure/show-all controls (incl. inside wrapper) */ +[data-hide-section-name="true"] .sectionTitleContainer > .sectionTitle, +[data-hide-section-name="true"] .sectionTitleContainer > .sectionTitle-link, +[data-hide-section-name="true"] .sectionTitle-wrapper > .sectionTitle, +[data-hide-section-name="true"] .sectionTitle-wrapper > .sectionTitle-link, +[data-hide-section-name="true"] .sectionTitle-wrapper > .sectionTitle.sectionTitle-cards, +[data-hide-section-name="true"] .sectionTitle-wrapper > .sectionCaption, +[data-hide-section-name="true"] .spotlight-section-title-container > .spotlight-section-title, +[data-hide-section-name="true"] .spotlight-section-title, +.spotlight-section[data-hide-section-name="true"] .spotlight-section-title { + display: none !important; +} + +.layout-mobile .sectionTitle.sectionCaption { + font-size: 0.9em !important; +} + +.sectionTitle.sectionCaption { + font-size: 0.9em !important; + text-align: left; + grid-column: 1 / -1; + grid-row: 2; + white-space: nowrap; + text-overflow: ellipsis; + display: inline-block !important; + overflow: hidden; + max-width: calc(100% - max(env(safe-area-inset-left), 3.3%)); + justify-self: start; +} + +.sectionTitle-wrapper { + display: grid; + align-items: center; + /* title | picker (optional; collapses when absent) | controls */ + grid-template-columns: auto auto 1fr; + flex: 1; + min-width: 0; + width: 100%; +} + +.sectionTitle-wrapper > .sectionTitle-link, +.sectionTitle-wrapper > .sectionTitle.sectionTitle-cards { + grid-column: 1; + grid-row: 1; + min-width: 0; +} + +.sectionTitle-wrapper > .multi-query-picker-button { + grid-column: 2; + grid-row: 1; + align-self: center; +} + +.sectionTitle-wrapper > .section-controls { + grid-column: 3; + grid-row: 1; + align-self: center; +} + +/* Spinning animation when refreshing */ +[data-refreshing="true"] .section-refresh-button { + animation: spin 1s linear infinite; +} + +/* Checkmark state - green color to indicate success */ +.section-refresh-button.check_circle { + animation: none; +} + +/* Keep icon-state transitions without overriding the shared hover fade */ +.section-refresh-button.refresh, +.section-refresh-button.check_circle { + transition: + opacity 0.25s ease, + visibility 0s linear 0.25s, + transform 0.3s ease, + color 0.3s ease, + background 0.2s ease; +} + +.custom-scroller-container:hover .section-refresh-button.refresh, +.custom-scroller-container:hover .section-refresh-button.check_circle, +.emby-scroller-container:hover .section-refresh-button.refresh, +.emby-scroller-container:hover .section-refresh-button.check_circle, +.spotlight-section:hover .section-refresh-button.refresh, +.spotlight-section:hover .section-refresh-button.check_circle { + transition: + opacity 0.25s ease, + visibility 0s linear 0s, + transform 0.3s ease, + color 0.3s ease, + background 0.2s ease; +} + +/* Fade in/out transitions for icon changes (glyph lives on child .material-icons) */ +.section-refresh-button .material-icons { + transition: opacity 0.2s ease; +} + +.section-refresh-button.icon-fade-out .material-icons, +.section-refresh-button .material-icons.icon-fade-out { + opacity: 0; +} + +.section-refresh-button.icon-fade-in .material-icons, +.section-refresh-button .material-icons.icon-fade-in { + opacity: 1; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Borderless layout: full width, no border, negative margins for overlap. + Banner has NO padding so the background image overflows edge-to-edge; only content is inset. */ +.spotlight-section[data-layout="Borderless"] { + --spotlight-fullscreen-vertical: 20px; + --spotlight-fullscreen-horizontal: max(env(safe-area-inset-left), env(safe-area-inset-right), 4%); + padding: 0; + width: 100%; + margin-top: calc(-1 * calc(var(--spotlight-fullscreen-vertical) * 1)); + margin-bottom: calc(-1 * calc(var(--spotlight-fullscreen-vertical) * 2)); +} + +.layout-mobile .spotlight-section[data-layout="Borderless"] { + margin-top: -4rem; + margin-bottom: -6rem; +} + +.spotlight-section[data-layout="Borderless"] .spotlight-banner-container { + border-radius: 0; + position: relative; +} + +.spotlight-section[data-layout="Border"] .spotlight-banner-container { + overflow: hidden; +} + +/* Dark gradient overlays at top and bottom (like left-side overlay): graceful fade and better visibility when content overlaps. + z-index above cycle-backdrops imgs (1/2) so vignettes do not flash during backdrop crossfade. */ +.spotlight-section .spotlight-item .spotlight-background-single::before, +.spotlight-section .spotlight-item .spotlight-background-single::after, +.spotlight-section .spotlight-item .spotlight-background-dual::before, +.spotlight-section .spotlight-item .spotlight-background-dual::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 100%; + pointer-events: none; + z-index: 5; +} + +.spotlight-background-single::before, +.spotlight-background-dual::before { + width: 55%; + background: radial-gradient(farthest-corner at bottom left, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.8) 50%, rgba(0, 0, 0, 0) 70%); +} + +.layout-mobile .spotlight-background-single::before, +.layout-mobile .spotlight-background-dual::before { + width: 100%; + background: linear-gradient(to bottom, transparent 0%, transparent 40%, rgba(0, 0, 0, 0.65) 50%, rgba(0, 0, 0, 0.85) 100%) !important; +} + +.spotlight-background-single::after, +.spotlight-background-dual::after { + width: 100%; + background: linear-gradient( 180deg, black 0%, transparent 35%, transparent 65%, black 100% ); +} + +/* .spotlight-section[data-layout="Borderless"] .spotlight-item .spotlight-background-single:::before { + top: 0; + background: linear-gradient(0deg, transparent, black); +} + +.spotlight-section[data-layout="Borderless"] .spotlight-item .spotlight-background-single::after { + bottom: 0; + background: linear-gradient(180deg, transparent, black); +} */ + +.spotlight-section[data-layout="Borderless"] .spotlight-items-container, +.spotlight-section[data-layout="Borderless"] .spotlight-item { + /* Full bleed: fill banner so background extends to edges (and past negative margin) */ + left: 0; + right: 0; + width: 100%; +} + +/* Borderless: use pre-scaled animations so image never shows hard edge at top */ +/* .spotlight-section[data-layout="Borderless"] .spotlight-background-single img.animate { + animation: kenBurnsZoomInFullscreen 20s ease-out forwards; +} +.spotlight-section[data-layout="Borderless"] .spotlight-background-layer-left img.animate { + animation: kenBurnsZoomOutFullscreen 20s ease-out forwards; +} +.spotlight-section[data-layout="Borderless"] .spotlight-background-layer-right img.animate { + animation: kenBurnsZoomInFullscreen 20s ease-out forwards; +} */ + +/* Overlay: same ~45% width as smaller spotlights for consistent gradient and content area. */ +.spotlight-section[data-layout="Borderless"] .spotlight-overlay { + left: 0; + width: 45%; + z-index: 3; + grid-template-rows: 1fr auto auto auto; + gap: 1.5em; +} + +.spotlight-section[data-layout="Borderless"][data-size="large"] .spotlight-overlay { + padding: calc(var(--spotlight-fullscreen-vertical) * 5) var(--spotlight-fullscreen-horizontal) + calc(var(--spotlight-fullscreen-vertical) * 5) var(--spotlight-fullscreen-horizontal); +} + +.spotlight-section[data-layout="Borderless"][data-size="full"] .spotlight-overlay { + padding: calc(var(--spotlight-fullscreen-vertical) * 5) var(--spotlight-fullscreen-horizontal) + calc(var(--spotlight-fullscreen-vertical) * 5) var(--spotlight-fullscreen-horizontal); + grid-template-rows: 1fr auto minmax(6rem, auto) minmax(48px, auto); + align-content: center; + gap: 1.5em; +} + +.spotlight-section[data-layout="Borderless"][data-size="full"] .spotlight-credits-row { + flex-wrap: nowrap; + flex-direction: row; +} + +/* Duplicate removed - already defined above for Borderless */ + +.spotlight-section[data-size="full"] .spotlight-item-title { + width: auto; + max-width: none; + font-size: 4rem; +} + +/* Duplicate removed - clearart display:none already set for Borderless above */ + +.spotlight-section[data-layout="Borderless"] .spotlight-header-container { + left: var(--spotlight-fullscreen-horizontal); + top: calc(var(--spotlight-fullscreen-vertical) * 2.5); + width: calc(100% - 2 * var(--spotlight-fullscreen-horizontal)); +} + +.spotlight-section[data-layout="Borderless"] .spotlight-dots { + bottom: calc(var(--spotlight-fullscreen-vertical) * 4.5); +} + +.spotlight-section .spotlight-background-layer img, +.spotlight-section .spotlight-background-single img { + object-position: center 0%; +} +.spotlight-section[data-layout="Borderless"] .spotlight-clearart { + display: none; +} + +/* Tagline at bottom center above navigation dots (all spotlights) - always 1 line max */ +.spotlight-section:not(.hide) > .spotlight-banner-container .spotlight-tagline { + position: absolute !important; + left: 50% !important; + transform: translateX(-50%) !important; + width: fit-content !important; + max-width: min(85%, 700px); + text-align: left !important; + font-style: italic; + font-size: 1.05em; + margin: 0 !important; + padding: 0 !important; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + border-radius: 6px; + z-index: 4; + pointer-events: none; + height: unset !important; +} +.spotlight-section:not(.hide) .spotlight-tagline p { + font-weight: initial !important; + transform: initial !important; + width: 100%; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + cursor: default; + height: unset; + line-height: 1.5; +} + +/* Normal and large: tagline above dots (dots at bottom: 20px) */ +.spotlight-section[data-layout="Border"] .spotlight-tagline { + bottom: 40px; +} + +.spotlight-section[data-layout="Borderless"] .spotlight-tagline { + bottom: calc(var(--spotlight-fullscreen-vertical) * 4.5 + 40px); +} + +/* Size full: modern stream-site metadata styles */ +.spotlight-section[data-size="full"] .metadata-container { + gap: 10px; +} + +.spotlight-section[data-size="full"] .spotlight-metadata-row { + font-size: 15px; + gap: 10px; +} + +/* Full-screen: uniform padding for all metadata pills (year, duration, genres, director, writer, series) */ +/* .spotlight-section[data-size="full"] .spotlight-metadata-row .detailsGroupItem, +.spotlight-section[data-size="full"] .metadata-container .detailsGroupItem { + border-radius: 6px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.2); +} */ + +.spotlight-section[data-size="full"] .spotlight-genre-link { + display: inline-block; + transition: + background 0.2s, + border-color 0.2s; +} + +/* Full-screen: director/writer use material icon instead of label text */ +/* Director/writer: always use icon, never show "Directed by"/"Written by" text */ +.spotlight-truncated-list[data-icon] .spotlight-truncated-list-label { + display: none; +} +.spotlight-truncated-list .detailsGroupItem[data-icon], +.spotlight-truncated-list .emby-tab-button[data-icon] { + display: inline-flex; + align-items: center; +} +.spotlight-truncated-list .detailsGroupItem[data-icon]::before, +.spotlight-truncated-list .emby-tab-button[data-icon]::before { + content: attr(data-icon); + font-family: "Material Icons"; + font-size: 1.1em; + margin-right: 6px; + opacity: 0.9; + text-transform: none; /* Prevent parent text-transform: capitalize (e.g. .spotlight-series-info) from breaking icon */ +} +.spotlight-truncated-list[data-icon] .detailsGroupItem[data-icon]::before, +.spotlight-truncated-list[data-icon] .emby-tab-button[data-icon]::before { + content: attr(data-icon); +} + +.spotlight-section a:not(:hover) { + text-decoration: none !important; +} + +.spotlight-section[data-size="full"] .spotlight-buttons-container { + margin-top: 0px !important; + margin-bottom: 0px !important; + display: flex; + flex-direction: row; + justify-content: start; +} + +.spotlight-section[data-size="full"] .spotlight-item-logo { + width: 95%; +} + +/* Mobile spotlight: uniform layout TITLE -> gap -> LOGO -> METADATA -> OVERVIEW -> BUTTONS -> DOTS */ +.layout-mobile .spotlight-tagline { + display: none !important; +} + +.layout-mobile .spotlight-overlay { + width: calc(100% - 2rem) !important; + padding: 4rem 1rem 1rem 1rem !important; + align-items: start; + align-content: start; + grid-template-rows: auto auto 1fr minmax(48px, auto); + grid-template-areas: + "logo" + "metadata" + "overview" + "buttons"; + gap: 0.75rem; +} + +.layout-mobile .spotlight-section[data-layout="Borderless"] .spotlight-overlay { + padding: 9rem 1rem 6rem 1rem !important; +} + +.layout-mobile .spotlight-section[data-layout="Borderless"] .spotlight-header-container { + top: 5rem; +} + +.layout-mobile .spotlight-section .spotlight-header-container { + top: 15px; + max-width: calc(100% - 2rem); + width: calc(100% - 2rem); + left: unset !important; + justify-content: center; + margin: 0 auto !important; + display: grid; +} + +.layout-mobile .metadata-container { + display: flex !important; +} + +.layout-mobile .spotlight-logo-section { + height: 100% !important; + min-height: 0 !important; + align-content: end; +} + +.layout-mobile .spotlight-overview { + max-width: 100%; +} + +.spotlight-series-info { + text-transform: capitalize; +} + +.spotlight-section[data-size="full"] .metadata-container * { + font-size: 1.1rem; +} + +.spotlight-section .itemDetailsGroup:not(.hide) .detailsGroupItem, +.spotlight-section .itemDetailsGroup:not(.hide) .emby-tab-button { + margin: 0px !important; + gap: 0px !important; +} + +.layout-mobile .spotlight-buttons-container .starRatingContainer { + display: none !important; +} + +.spotlight-section .spotlight-buttons-container.mainDetailButtons > button { + right: 0px !important; + margin-top: 0 !important; + margin-left: 0 !important; + margin-bottom: 0 !important; + margin-right: 0 !important; + min-width: unset; + flex: unset !important; +} +.spotlight-buttons-container > * { + margin: 0 !important; +} +.spotlight-section .spotlight-buttons-container.mainDetailButtons { + margin: 0px !important; + padding: 0px !important; + background: transparent !important; + backdrop-filter: none !important; +} +.spotlight-section .mainDetailButtons > :is(button.emby-button) { + padding: 0em !important; +} +.spotlight-section .mainDetailButtons > .btnPlay:is(.emby-button) { + padding: 0.25em 1em !important; + display: flex; + flex-direction: row; + height: auto; +} +.spotlight-section .mainDetailButtons > .btnPlay:is(.emby-button)::after { + content: attr(title); + padding-right: 0.5em; +} +.spotlight-section .spotlight-buttons-container.mainDetailButtons { + gap: 2em; +} +.spotlight-section .spotlight-buttons-container.mainDetailButtons > * { + padding: 0 !important; +} +.spotlight-section .spotlight-buttons-container.mainDetailButtons > * span { + font-size: 2em; +} + +.spotlight-section:not(.hide) .headerTabs, +.spotlight-section:not(.hide) .sectionTabs { + display: flex !important; + height: initial !important; + padding: 0px !important; + margin-top: 0px !important; + margin-bottom: 0px !important; + margin-left: 0px !important; + margin-right: 0px !important; + width: auto !important; + position: relative !important; + transform: unset !important; +} + +.spotlight-section:not(.hide) .emby-tab-button { + padding: 0.5em 1em !important; + margin: 0px !important; + top: initial !important; + transform: initial !important; + align-items: center; + height: auto !important; +} + +.spotlight-section-title.headerTabs.sectionTabs, +.spotlight-nav-container.headerTabs.sectionTabs { + display: flex !important; + flex-direction: row !important; + align-self: start; + align-items: start; + justify-content: start; + width: auto; + font-size: revert !important; + align-items: center; +} + +.spotlight-metadata-row .emby-tab-button { + color: #c0c0c0; +} + +:where(.spotlight-section-title.headerTabs.sectionTabs), +:where(.spotlight-section .headerTabs.sectionTabs), +:where(.spotlight-nav-container.headerTabs.sectionTabs) { + background-color: rgba(20, 20, 20, 0.85); + border-radius: 0.2em; +} + +.spotlight-section[data-hide-section-name="true"] .spotlight-section-title, +.spotlight-section[data-hide-section-name="true"] .spotlight-section-title.headerTabs.sectionTabs, +.layout-mobile .spotlight-section[data-hide-section-name="true"] .spotlight-section-title.sectionTabs { + display: none !important; +} + +.spotlight-section:not(.hide) .spotlight-nav-buttons-container { + display: flex !important; + padding: 0 0 !important; + height: auto !important; +} +.spotlight-section:not(.hide) .spotlight-nav-buttons-container button { + padding: 0.5em; +} +.spotlight-nav-button span.material-icons { + display: inline-block !important; + top: initial !important; +} +.spotlight-section-title > * { + font-size: 1em !important; +} + +.spotlight-metadata-row *:not(a) { + cursor: default; +} + +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-banner-container { + height: 55vh; +} + +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overview-section { + display: none !important; +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-buttons-section { + margin-top: -1.5em; +} +.spotlight-section[data-layout="Borderless"][data-size="large"] .spotlight-banner-container { + height: 72vh; +} + +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overlay.has-title { + padding: calc(var(--spotlight-fullscreen-vertical) * 5) var(--spotlight-fullscreen-horizontal) + calc(var(--spotlight-fullscreen-vertical) * 5) var(--spotlight-fullscreen-horizontal); +} + +.spotlight-section[data-layout="Borderless"][data-size="normal"] { + --spotlight-fullscreen-horizontal: max(env(safe-area-inset-left), env(safe-area-inset-right), 4%); +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-tagline { + bottom: calc(var(--spotlight-fullscreen-vertical) * 1.5 + 40px); +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-dots { + bottom: calc(var(--spotlight-fullscreen-vertical) * 1.5); +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-header-container { + left: var(--spotlight-fullscreen-horizontal); + width: calc(100% - 2 * var(--spotlight-fullscreen-horizontal)); +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overlay { + grid-template-rows: 1fr auto auto auto; +} +.spotlight-section[data-size="normal"] .spotlight-overlay { + grid-template-rows: var(--spotlight-logo-height) auto 1fr auto; +} +.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-overlay { + grid-template-rows: 3fr auto minmax(6rem, auto) auto; +} +.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-logo-section { + align-self: end; +} +.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-buttons-section { + align-self: start; +} +.spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-overlay { + display: flex; + flex-direction: column; + padding: 100px 40px 25px 40px; +} +.spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-overlay .spotlight-overview-section { + display: none; +} +.layout-mobile .spotlight-section[data-size="normal"] .spotlight-overlay .spotlight-overview-section, +.layout-mobile .spotlight-section[data-size="normal"] .spotlight-overlay .spotlight-metadata-section { + display: none; +} +.spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-dots { + display: none; +} +.spotlight-section[data-size="large"] .spotlight-overlay { + grid-template-rows: 2fr auto minmax(auto, 6rem) auto; +} +.spotlight-section[data-size="large"] .spotlight-overlay .spotlight-item-logo { + background-position: left bottom; +} +.spotlight-section[data-size="large"] .spotlight-overlay .spotlight-buttons-section { + align-items: start; +} +.spotlight-section[data-layout="Border"] .spotlight-overlay { + padding: 85px 40px 45px 40px; +} +.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overlay { + padding: 85px var(--spotlight-fullscreen-horizontal) 45px; +} +.spotlight-section[data-layout="Border"][data-size="large"] .spotlight-overlay { + padding: 100px 40px 45px 40px; +} +.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-overlay { + padding: 110px 40px 85px 40px; +} +.spotlight-section[data-layout="Border"] .spotlight-header-container { + top: 15px; + left: 40px; + width: calc(100% - 80px); +} +.spotlight-section[data-layout="Border"][data-size="large"] .spotlight-header-container, +.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-header-container { + top: 2rem; + left: 2rem; + width: calc(100% - 4rem); +} +.spotlight-section[data-layout="Border"] .spotlight-banner-container { + border: 1px solid rgb(255 255 255 / 10%); +} +.spotlight-section[data-layout="Border"][data-size="large"] .spotlight-banner-container { + height: 60vh; +} + +.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-banner-container { + height: 80vh !important; +} + +.spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-overview { + -webkit-line-clamp: 2; +} + +.layout-mobile .sectionTitleContainer-cards .sectionTitle-link > * { + padding-top: 0 !important; +} + +.layout-mobile .emby-scroller-container .sectionTitleContainer .section-controls { + order: 1; +} + +.layout-mobile .section-controls-inline .show-all-button, +.layout-mobile .section-controls-inline .section-refresh-button { + opacity: 1; + visibility: visible; +} + +.layout-mobile .spotlight-section .spotlight-nav-container { + display: none !important; +} + +.layout-mobile .spotlight-section .spotlight-overlay { + display: flex; + flex-direction: column; + justify-content: end; + align-items: center; + grid-gap: 3em; +} + +.layout-mobile .spotlight-overview-section, +.layout-mobile .spotlight-credits-row, +.layout-mobile .spotlight-series-info { + display: none; +} + +.layout-mobile .spotlight-logo-section { + width: 100%; + justify-content: center; +} + +.layout-mobile .spotlight-dots { + bottom: 1rem !important; + display: none; +} + +.layout-mobile .spotlight-section[data-layout="Borderless"] .spotlight-dots { + bottom: 5rem !important; +} + +.layout-mobile .spotlight-section .spotlight-buttons-container { + align-content: center; + align-items: center; + justify-content: center; + justify-items: center; +} + +.layout-mobile .spotlight-section .spotlight-section-title > * { + max-width: 100%; + min-width: 0; + text-align: center; +} +.layout-mobile .spotlight-section .emby-tab-button, +.layout-mobile .spotlight-metadata-section span a, +.layout-mobile .spotlight-metadata-section span { + font-size: 1em !important; +} + +.layout-mobile .spotlight-section .spotlight-item-title { + font-size: 2em !important; + text-align: center; +} + +.layout-mobile .spotlight-section:not(.hide) .spotlight-tagline { + display: none !important; +} + +.layout-mobile .spotlight-section .spotlight-metadata-row { + justify-content: center; + align-self: center; +} + +.layout-mobile .spotlight-buttons-section { + justify-self: center; +} + +.emby-scroller-container[data-card-format="Button"] .cardScalable { + display: none; +} +.emby-scroller-container[data-card-format="Button"] .cardText { + display: flex !important; + padding: 0 !important; +} +.emby-scroller-container[data-card-format="Button"] .card > .cardBox { + width: 100%; + justify-content: center; + flex-direction: column; + display: flex; + padding: 1rem !important; + margin-right: 0 !important; + margin-left: 0 !important; +} +.emby-scroller-container[data-card-format="Button"] .card { + width: auto !important; +} +.emby-scroller-container[data-card-format="Button"] .itemsContainer { + gap: 0.5rem; + margin-top: 0.5rem; +} +.emby-scroller-container[data-card-format="Button"] { + margin-bottom: 1rem; +} + +.emby-scroller-container[data-card-format="Logo"]:not(.visualCardBox) .cardScalable, +.emby-scroller-container[data-card-format="Logo"]:not(.visualCardBox) .cardPadder, +.emby-scroller-container[data-card-format="Logo"]:not(.visualCardBox) .cardBox, +.emby-scroller-container[data-card-format="Clear Art"]:not(.visualCardBox) .cardScalable, +.emby-scroller-container[data-card-format="Clear Art"]:not(.visualCardBox) .cardPadder, +.emby-scroller-container[data-card-format="Clear Art"]:not(.visualCardBox) .cardBox, +.emby-scroller-container[data-card-format="Disc"]:not(.visualCardBox) .cardScalable, +.emby-scroller-container[data-card-format="Disc"]:not(.visualCardBox) .cardPadder, +.emby-scroller-container[data-card-format="Disc"]:not(.visualCardBox) .cardBox, +.emby-scroller-container[data-section-id="liveTv-channels"]:not(.visualCardBox) .cardScalable, +.emby-scroller-container[data-section-id="liveTv-channels"]:not(.visualCardBox) .cardPadder, +.emby-scroller-container[data-section-id="liveTv-channels"]:not(.visualCardBox) .cardBox, +html.sleekfin-main-ui .emby-scroller-container[data-card-format="Logo"]:not(.visualCardBox) .cardPadder, +html.sleekfin-main-ui .emby-scroller-container[data-card-format="Clear Art"]:not(.visualCardBox) .cardPadder, +html.sleekfin-main-ui .emby-scroller-container[data-card-format="Disc"]:not(.visualCardBox) .cardPadder, +html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels"]:not(.visualCardBox) .cardPadder, +html.sleekfin-main-ui .emby-scroller-container[data-card-format="Logo"]:not(.visualCardBox) .cardBox, +html.sleekfin-main-ui .emby-scroller-container[data-card-format="Clear Art"]:not(.visualCardBox) .cardBox, +html.sleekfin-main-ui .emby-scroller-container[data-card-format="Disc"]:not(.visualCardBox) .cardBox, +html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels"]:not(.visualCardBox) .cardBox { + background: transparent !important; + box-shadow: none !important; + border: none !important; + background-color: transparent !important; +} + +/* Skeletons need a visible padder even when Logo/Clear Art/Disc force transparent cards */ +.emby-scroller-container[data-card-format="Logo"] .skeleton-card .cardPadder, +.emby-scroller-container[data-card-format="Logo"] .skeleton-card .cardScalable, +.emby-scroller-container[data-card-format="Clear Art"] .skeleton-card .cardPadder, +.emby-scroller-container[data-card-format="Clear Art"] .skeleton-card .cardScalable, +.emby-scroller-container[data-card-format="Disc"] .skeleton-card .cardPadder, +.emby-scroller-container[data-card-format="Disc"] .skeleton-card .cardScalable, +.emby-scroller-container[data-section-id="liveTv-channels"] .skeleton-card .cardPadder, +.emby-scroller-container[data-section-id="liveTv-channels"] .skeleton-card .cardScalable { + background: rgba(255, 255, 255, 0.12) !important; +} + +.emby-scroller-container[data-card-format="Logo"] .cardImageContainer, +.emby-scroller-container[data-card-format="Clear Art"] .cardImageContainer, +.emby-scroller-container[data-section-id="liveTv-channels"] .cardImageContainer { + background-size: contain !important; + box-shadow: none !important; +} + +.emby-scroller-container[data-card-format="Banner"] .card { + width: 33vw !important; +} + +.layout-mobile .emby-scroller-container[data-card-format="Banner"] .card { + width: 95vw !important; +} + +/* .emby-scroller-container[data-card-format="Logo"] .cardImageContainer, +.emby-scroller-container[data-card-format="Clear Art"] .cardImageContainer, +.emby-scroller-container[data-card-format="Disc"] .cardImageContainer { + transition: opacity 0.15s ease-in-out; +} + +.emby-scroller-container[data-card-format="Logo"] .cardImageContainer.lazy-hidden, +.emby-scroller-container[data-card-format="Clear Art"] .cardImageContainer.lazy-hidden, +.emby-scroller-container[data-card-format="Disc"] .cardImageContainer.lazy-hidden { + opacity: 0; +} + +.emby-scroller-container[data-card-format="Logo"] .cardImageContainer.lazy-loaded, +.emby-scroller-container[data-card-format="Clear Art"] .cardImageContainer.lazy-loaded, +.emby-scroller-container[data-card-format="Disc"] .cardImageContainer.lazy-loaded { + opacity: 1; +} */ + +:not(.layout-mobile) .emby-scroller-container[data-card-format="Logo"]:not([data-border-style]) .itemsContainer, +:not(.layout-mobile) .emby-scroller-container[data-card-format="Clear Art"]:not([data-border-style]) .itemsContainer, +:not(.layout-mobile) .emby-scroller-container[data-card-format="Disc"]:not([data-border-style]) .itemsContainer { + gap: 4rem !important; +} + +.layout-mobile .emby-scroller-container[data-card-format="Logo"]:not([data-border-style]) .itemsContainer, +.layout-mobile .emby-scroller-container[data-card-format="Clear Art"]:not([data-border-style]) .itemsContainer, +.layout-mobile .emby-scroller-container[data-card-format="Disc"]:not([data-border-style]) .itemsContainer { + gap: 2rem !important; +} + +.emby-scroller-container[data-hide-card-footer] .card .cardText { + display: none; +} + +.spotlight-header-container { + position: absolute; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + width: calc(100% - 2 * var(--spotlight-fullscreen-horizontal, max(env(safe-area-inset-left), 3.3%))); + left: var(--spotlight-fullscreen-horizontal, max(env(safe-area-inset-left), 3.3%)); + isolation: isolate; + transform: translateZ(0); + pointer-events: none; +} + +.spotlight-section-title-container { + display: flex; + align-items: center; + gap: 0.35em; + min-width: 0; +} + +.layout-mobile .spotlight-section-title-container .section-controls { + margin-left: auto; + flex-shrink: 0; +} + +.spotlight-rating-container:hover { + background-color: transparent !important; + cursor: auto; +} + +.spotlight-section .emby-tab-button { + text-shadow: none !important; +} + +.custom-scroller-container { + position: relative; + z-index: 1; +} + +/* Desktop custom path: clip frame; slidee uses translateX. Mobile .scrollX keeps native overflow. */ +.custom-scroller-container .custom-scroller:not(.scrollX), +.emby-scroller-container .custom-scroller:not(.scrollX) { + overflow: hidden; +} + +.custom-scroller-container .scrollSlider, +.emby-scroller-container .scrollSlider { + will-change: transform; +} + +[dir="ltr"] .emby-scrollbuttons { + right: 0; +} + +.emby-scrollbuttons { + -webkit-align-items: center; + align-items: center; + color: #fff; + display: -webkit-flex; + display: flex; + -webkit-justify-content: center; + justify-content: center; + min-height: 24px; + min-width: 104px; + padding-top: 0.85em; + position: absolute; + top: 0; + z-index: 1; +} + +/* Section-level card title presentation */ +.verticalSection .cardText, +.emby-scroller-container .cardText { + color: var(--kefin-card-title-color, inherit) !important; +} + +.verticalSection[data-card-title-visibility="hidden"] .cardText, +.verticalSection[data-card-title-visibility="hidden"] .cardFooter, +.verticalSection[data-card-title-visibility="hidden"] .cardTextStack, +.emby-scroller-container[data-card-title-visibility="hidden"] .cardText, +.emby-scroller-container[data-card-title-visibility="hidden"] .cardFooter, +.emby-scroller-container[data-card-title-visibility="hidden"] .cardTextStack { + display: none !important; +} + +.verticalSection[data-card-title-capitalization="uppercase"] .cardText, +.emby-scroller-container[data-card-title-capitalization="uppercase"] .cardText { + text-transform: uppercase; +} + +.verticalSection[data-card-title-capitalization="lowercase"] .cardText, +.emby-scroller-container[data-card-title-capitalization="lowercase"] .cardText { + text-transform: lowercase; +} + +.verticalSection[data-card-title-capitalization="capitalize"] .cardText, +.emby-scroller-container[data-card-title-capitalization="capitalize"] .cardText { + text-transform: capitalize; +} + +.verticalSection[data-font-family="alfa-slab-one"] .cardText, +.emby-scroller-container[data-font-family="alfa-slab-one"] .cardText { + font-family: "Alfa Slab One", serif; +} + +.verticalSection[data-font-family="anton"] .cardText, +.emby-scroller-container[data-font-family="anton"] .cardText { + font-family: Anton, sans-serif; +} + +.verticalSection[data-font-family="changa-one"] .cardText, +.emby-scroller-container[data-font-family="changa-one"] .cardText { + font-family: "Changa One", cursive; +} + +.verticalSection[data-font-family="exo"] .cardText, +.emby-scroller-container[data-font-family="exo"] .cardText { + font-family: Exo, sans-serif; +} + +.verticalSection[data-font-family="orbitron"] .cardText, +.emby-scroller-container[data-font-family="orbitron"] .cardText { + font-family: Orbitron, sans-serif; +} + +.verticalSection[data-font-family="oswald"] .cardText, +.emby-scroller-container[data-font-family="oswald"] .cardText { + font-family: Oswald, sans-serif; +} + +.verticalSection[data-font-family="prata"] .cardText, +.emby-scroller-container[data-font-family="prata"] .cardText { + font-family: Prata, serif; +} + +.verticalSection[data-font-family="roboto"] .cardText, +.emby-scroller-container[data-font-family="roboto"] .cardText { + font-family: Roboto, sans-serif; +} + +.verticalSection[data-font-family="unbounded"] .cardText, +.emby-scroller-container[data-font-family="unbounded"] .cardText { + font-family: Unbounded, sans-serif; +} + +.verticalSection[data-font-family="vt323"] .cardText, +.emby-scroller-container[data-font-family="vt323"] .cardText { + font-family: VT323, monospace; +} + +.verticalSection[data-font-family="verdana"] .cardText, +.emby-scroller-container[data-font-family="verdana"] .cardText { + font-family: Verdana, Geneva, sans-serif; +} + +.verticalSection[data-font-family="courier-new"] .cardText, +.emby-scroller-container[data-font-family="courier-new"] .cardText { + font-family: "Courier New", Courier, monospace; +} + +.verticalSection[data-font-size="small"] .cardText, +.emby-scroller-container[data-font-size="small"] .cardText { + font-size: 0.8em; +} + +.verticalSection[data-font-size="large"] .cardText, +.emby-scroller-container[data-font-size="large"] .cardText { + font-size: 1.25em; +} + +.verticalSection[data-card-title-position="left"] .cardText, +.verticalSection[data-card-title-position$="-left"] .cardText, +.emby-scroller-container[data-card-title-position="left"] .cardText, +.emby-scroller-container[data-card-title-position$="-left"] .cardText { + text-align: left !important; +} + +.verticalSection[data-card-title-position="center"] .cardText, +.verticalSection[data-card-title-position="overlay-center"] .cardText, +.verticalSection[data-card-title-position$="-center"] .cardText, +.emby-scroller-container[data-card-title-position="center"] .cardText, +.emby-scroller-container[data-card-title-position="overlay-center"] .cardText, +.emby-scroller-container[data-card-title-position$="-center"] .cardText { + text-align: center !important; +} + +.verticalSection[data-card-title-position="right"] .cardText, +.verticalSection[data-card-title-position$="-right"] .cardText, +.emby-scroller-container[data-card-title-position="right"] .cardText, +.emby-scroller-container[data-card-title-position$="-right"] .cardText { + text-align: right !important; +} + +/* Overlay titles: real .cardTextStack over the image (title + footers) */ +.verticalSection[data-card-title-position^="overlay-"] .card > .cardBox, +.emby-scroller-container[data-card-title-position^="overlay-"] .card > .cardBox { + position: relative; +} + +.verticalSection[data-card-title-position^="overlay-"] .cardBox.cardBox-bottompadded, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardBox.cardBox-bottompadded { + padding-bottom: 0 !important; +} + +/* Keep cardScalable without a z-index so overlay chrome (z-index 3) can sit above the text stack (z-index 2). */ +.verticalSection[data-card-title-position^="overlay-"] .cardScalable, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardScalable { + position: relative; +} + +.verticalSection[data-card-title-position^="overlay-"] .cardScalable > .cardOverlayContainer, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardScalable > .cardOverlayContainer { + z-index: 3; +} + +.verticalSection[data-card-title-position^="overlay-"] .cardScalable > .cardBorder, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardScalable > .cardBorder { + z-index: 1; +} + +.verticalSection[data-card-title-position^="overlay-"] .cardTextStack, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack { + position: absolute; + z-index: 2; + left: 0; + right: 0; + width: 100%; + max-width: none; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25em; + margin: 0; + padding: 1.35em 0.85em; + box-sizing: border-box; + pointer-events: none; + color: var(--kefin-card-title-color, #fff); +} + +.verticalSection[data-card-title-position^="overlay-"] .cardScalable::after, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardScalable::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 0) 75%); +} + +.verticalSection[data-card-title-position^="overlay-top"] .cardScalable::after, +.emby-scroller-container[data-card-title-position^="overlay-top"] .cardScalable::after { + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 50%); +} + +.verticalSection[data-card-title-position^="overlay-bottom"] .cardScalable::after, +.emby-scroller-container[data-card-title-position^="overlay-bottom"] .cardScalable::after { + background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 50%); +} + +.verticalSection[data-card-title-position^="overlay-"] .cardTextStack .cardText, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack .cardText { + position: static; + margin: 0; + padding: 0; + max-width: 100%; + box-sizing: border-box; + color: inherit; + overflow: visible; + text-overflow: ellipsis; + white-space: nowrap; + background: none; + text-shadow: + 0 1px 2px rgba(0, 0, 0, 0.75), + 0 0 10px rgba(0, 0, 0, 0.45); +} + +.verticalSection[data-card-title-position^="overlay-"] .cardTextStack > .cardFooter, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack > .cardFooter { + display: flex; + flex-direction: column; + align-items: inherit; + gap: 0.1em; + width: 100%; + margin: 0; + padding: 0; + background: none; +} + +.verticalSection[data-card-title-position^="overlay-"] .cardTextStack .cardText-first, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack .cardText-first { + font-size: 1.5rem; + line-height: 1.5rem; + white-space: pre-line; + border-radius: 0 !important; +} + +.verticalSection[data-card-title-position^="overlay-"] .cardTextStack .cardText-secondary, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack .cardText-secondary { + font-size: 0.95rem; + opacity: 0.92; + border-radius: 0 !important; +} + +.verticalSection[data-card-title-position^="overlay-"][data-font-size="small"] .cardTextStack .cardText-first, +.emby-scroller-container[data-card-title-position^="overlay-"][data-font-size="small"] .cardTextStack .cardText-first { + font-size: 1.2rem; +} + +.verticalSection[data-card-title-position^="overlay-"][data-font-size="large"] .cardTextStack .cardText-first, +.emby-scroller-container[data-card-title-position^="overlay-"][data-font-size="large"] .cardTextStack .cardText-first { + font-size: 1.875rem; +} + +.verticalSection[data-card-title-position="overlay-top-left"] .cardTextStack, +.verticalSection[data-card-title-position="overlay-top-center"] .cardTextStack, +.verticalSection[data-card-title-position="overlay-top-right"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-top-left"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-top-center"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-top-right"] .cardTextStack { + /* Solid at top → fade out toward bottom */ + padding: 0.9em 1.1em; + border-top-left-radius: var(--kefin-card-radius, 0); + border-top-right-radius: var(--kefin-card-radius, 0); + overflow: hidden; +} + +.verticalSection[data-card-title-position="overlay-bottom-left"] .cardTextStack, +.verticalSection[data-card-title-position="overlay-bottom-center"] .cardTextStack, +.verticalSection[data-card-title-position="overlay-bottom-right"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-bottom-left"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-bottom-center"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-bottom-right"] .cardTextStack { + /* Solid at bottom → fade out toward top */ + padding: 0.9em 1.1em; + border-bottom-left-radius: var(--kefin-card-radius, 0); + border-bottom-right-radius: var(--kefin-card-radius, 0); + overflow: hidden; +} + +.verticalSection[data-card-title-position="overlay-center-left"] .cardTextStack, +.verticalSection[data-card-title-position="overlay-center-center"] .cardTextStack, +.verticalSection[data-card-title-position="overlay-center-right"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-center-left"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-center-center"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-center-right"] .cardTextStack { + /* Solid at bottom → fade out toward top */ + padding: 0 1.1em; + overflow: hidden; +} + +.verticalSection[data-card-title-position="overlay-top-left"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-top-left"] .cardTextStack { + top: 0; +} + +.verticalSection[data-card-title-position="overlay-top-center"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-top-center"] .cardTextStack { + top: 0; + align-items: center; + text-align: center; +} + +.verticalSection[data-card-title-position="overlay-top-right"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-top-right"] .cardTextStack { + top: 0; + align-items: flex-end; + text-align: right; +} + +.verticalSection[data-card-title-position="overlay-center-left"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-center-left"] .cardTextStack { + top: 50%; + transform: translateY(-50%); +} + +.verticalSection[data-card-title-position="overlay-center"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-center"] .cardTextStack { + top: 50%; + transform: translateY(-50%); + align-items: center; + text-align: center; +} + +.verticalSection[data-card-title-position="overlay-center-right"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-center-right"] .cardTextStack { + top: 50%; + transform: translateY(-50%); + align-items: flex-end; + text-align: right; +} + +.verticalSection[data-card-title-position="overlay-bottom-left"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-bottom-left"] .cardTextStack { + bottom: 0; +} + +.verticalSection[data-card-title-position="overlay-bottom-center"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-bottom-center"] .cardTextStack { + bottom: 0; + align-items: center; + text-align: center; +} + +.verticalSection[data-card-title-position="overlay-bottom-right"] .cardTextStack, +.emby-scroller-container[data-card-title-position="overlay-bottom-right"] .cardTextStack { + bottom: 0; + align-items: flex-end; + text-align: right; +} + +/* ========== Card border overlay host (.cardBorder) ========== */ +.card .cardScalable > .cardBorder { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + box-sizing: border-box; + /* Section may override via --kefin-card-border-color */ + --card-border-cream: var(--kefin-card-border-color, #e8dab2); + --card-border-fancy: var(--kefin-card-border-color, rgb(231 255 123 / 35%)); +} + +.card .cardScalable > .cardBorder > * { + box-sizing: border-box; + pointer-events: none; +} + +[data-border="double"] .card .cardBorder[data-style="double"], +[data-border-style="double"] .card .cardBorder[data-style="double"], +.card .cardBorder[data-style="double"], +[data-border="slim-fancy"] .card .cardBorder[data-style="slim-fancy"], +[data-border-style="slim-fancy"] .card .cardBorder[data-style="slim-fancy"], +.card .cardBorder[data-style="slim-fancy"] { + border: 10px solid transparent; + outline: 1px solid var(--card-border-cream) !important; + outline-offset: -5px; + background: conic-gradient(from 90deg at 1px 1px, #0000 25%, var(--card-border-cream) 0) 0 0 / calc(100% - 2px) calc(100% - 2px) + padding-box border-box !important; +} + +/* + * Decorative borders that are always square — override skin radius on the card + * hosts and --kefin-card-radius so overlay text stacks stay square too. + */ +[data-border="retro-poster"], +[data-border-style="retro-poster"], +[data-border="modern-poster"], +[data-border-style="modern-poster"], +[data-border="picture-frame"], +[data-border-style="picture-frame"], +[data-border="art-gallery"], +[data-border-style="art-gallery"], +[data-border="wood"], +[data-border-style="wood"], +[data-border="profile"], +[data-border-style="profile"], +[data-border="film-reel"], +[data-border-style="film-reel"] { + --kefin-card-radius: 0px; +} + +[data-border="retro-poster"] .card .cardBox, +[data-border="retro-poster"] .card .cardScalable, +[data-border-style="retro-poster"] .card .cardBox, +[data-border-style="retro-poster"] .card .cardScalable, +[data-border="modern-poster"] .card .cardBox, +[data-border="modern-poster"] .card .cardScalable, +[data-border-style="modern-poster"] .card .cardBox, +[data-border-style="modern-poster"] .card .cardScalable, +[data-border="picture-frame"] .card .cardBox, +[data-border="picture-frame"] .card .cardScalable, +[data-border-style="picture-frame"] .card .cardBox, +[data-border-style="picture-frame"] .card .cardScalable, +[data-border="art-gallery"] .card .cardBox, +[data-border="art-gallery"] .card .cardScalable, +[data-border-style="art-gallery"] .card .cardBox, +[data-border-style="art-gallery"] .card .cardScalable, +[data-border="wood"] .card .cardBox, +[data-border="wood"] .card .cardScalable, +[data-border-style="wood"] .card .cardBox, +[data-border-style="wood"] .card .cardScalable, +[data-border="profile"] .card .cardBox, +[data-border="profile"] .card .cardScalable, +[data-border-style="profile"] .card .cardBox, +[data-border-style="profile"] .card .cardScalable, +[data-border="film-reel"] .card .cardBox, +[data-border="film-reel"] .card .cardScalable, +[data-border-style="film-reel"] .card .cardBox, +[data-border-style="film-reel"] .card .cardScalable { + border-radius: 0 !important; +} + +/* + * Retro Poster — bulb frame around marquee strip + poster. + * Solid red mid-band; top/bottom bulbs own corners; theater-track title above image. + */ +[data-border="retro-poster"], +[data-border-style="retro-poster"] { + --rp-thin: 2px; + --rp-band: 16px; + --rp-inset: calc(var(--rp-thin) + var(--rp-band) + var(--rp-thin)); + --rp-marquee-h: 2.15em; + /* Gold frames stay fixed; only the inner band follows border color */ + --rp-gold-hi: #ffe9a0; + --rp-gold-lo: #c9a227; + --rp-red: var(--kefin-card-border-color, #c41e26); +} + +/* Poster clears the placard + bulb frame; padder padding-top restores full poster height */ +[data-border="retro-poster"] .card .cardScalable > .cardImageContainer, +[data-border-style="retro-poster"] .card .cardScalable > .cardImageContainer, +[data-border="retro-poster"] .card .cardScalable > .cardOverlayContainer, +[data-border-style="retro-poster"] .card .cardScalable > .cardOverlayContainer, +[data-border="retro-poster"] .card .cardScalable > .blurhash-canvas, +[data-border-style="retro-poster"] .card .cardScalable > .blurhash-canvas { + top: calc(var(--rp-inset) + var(--rp-marquee-h)) !important; + right: var(--rp-inset) !important; + bottom: var(--rp-inset) !important; + left: var(--rp-inset) !important; + width: calc(100% - var(--rp-inset) * 2) !important; + height: calc(100% - var(--rp-inset) * 2 - var(--rp-marquee-h)) !important; + border-radius: 0 !important; +} + +[data-border="retro-poster"] .card, +[data-border-style="retro-poster"] .card, +[data-border="retro-poster"] .card .cardScalable, +[data-border-style="retro-poster"] .card .cardScalable, +[data-border="retro-poster"] .card .cardPadder, +[data-border-style="retro-poster"] .card .cardPadder { + border-radius: 0 !important; +} + +/* Grow the aspect box so the placard sits above the poster instead of shrinking it */ +[data-border="retro-poster"] .card .cardPadder, +[data-border-style="retro-poster"] .card .cardPadder { + padding-top: var(--rp-marquee-h); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, #1e2029 0%, #0f1015 100%) !important; + backdrop-filter: blur(12px); +} +[data-card-format="Thumb"][data-border="retro-poster"] .card .cardPadder, +[data-card-format="Thumb"][data-border-style="retro-poster"] .card .cardPadder, +[data-card-format="Backdrop"][data-border="retro-poster"] .card .cardPadder, +[data-card-format="Backdrop"][data-border-style="retro-poster"] .card .cardPadder, +[data-card-format="Logo"][data-border="retro-poster"] .card .cardPadder, +[data-card-format="Logo"][data-border-style="retro-poster"] .card .cardPadder, +[data-card-format="Clear Art"][data-border="retro-poster"] .card .cardPadder, +[data-card-format="Clear Art"][data-border-style="retro-poster"] .card .cardPadder { + padding-top: calc(var(--rp-marquee-h) + var(--rp-inset)); +} +[data-card-format="Poster"][data-border="retro-poster"] .card .cardPadder, +[data-card-format="Poster"][data-border-style="retro-poster"] .card .cardPadder { + padding-top: initial; +} +[data-card-format="Banner"][data-border="retro-poster"] .card .cardPadder, +[data-card-format="Banner"][data-border-style="retro-poster"] .card .cardPadder { + padding-top: calc(var(--rp-marquee-h) + calc(var(--rp-inset) * 2)); +} + +/* Theater letter-board strip above the poster, inside the frame */ +[data-border="retro-poster"] .card .cardScalable > .retroPosterMarquee, +[data-border-style="retro-poster"] .card .cardScalable > .retroPosterMarquee { + position: absolute; + top: var(--rp-inset); + left: var(--rp-inset); + right: var(--rp-inset); + height: var(--rp-marquee-h); + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-sizing: border-box; + padding: 0 0.35em; + container-type: inline-size; + container-name: retro-marquee; + /* Off-white board + vertical posts + horizontal letter tracks */ + background-color: #e6e2d8; + background-image: + repeating-linear-gradient(90deg, transparent 0, transparent 22px, rgba(60, 45, 20, 0.12) 22px, rgba(60, 45, 20, 0.12) 24px), + repeating-linear-gradient(180deg, transparent 0, transparent 3px, #1c1c1c78 3px, #1c1c1c17 4px); + border-bottom: 1px solid rgba(0, 0, 0, 0.2); +} + +[data-border="retro-poster"] .card .retroPosterMarquee-label, +[data-border-style="retro-poster"] .card .retroPosterMarquee-label { + position: relative; + z-index: 1; + margin: 0; + font-family: Oswald, "Arial Narrow", sans-serif; + font-weight: 700; + font-size: 16px; + letter-spacing: 0.12em; + line-height: 1; + text-transform: uppercase; + color: #1e3a8a; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.card .cardBorder[data-style="retro-poster"] { + box-shadow: + inset 0 0 0 1px var(--rp-gold-hi), + inset 0 0 0 var(--rp-thin) var(--rp-gold-lo); +} + +.card .cardBorder[data-style="retro-poster"]::before { + content: ""; + position: absolute; + inset: var(--rp-thin); + padding: var(--rp-band); + box-sizing: border-box; + pointer-events: none; + background: var(--rp-red); + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; +} + +.card .cardBorder[data-style="retro-poster"]::after { + content: ""; + position: absolute; + inset: calc(var(--rp-thin) + var(--rp-band)); + pointer-events: none; + box-shadow: + inset 0 0 0 1px var(--rp-gold-hi), + inset 0 0 0 var(--rp-thin) var(--rp-gold-lo); +} + +/* Corner + edge circles: bulb on each corner, even spacing between on each side */ +.card .cardBorder[data-style="retro-poster"] .cardBorder-rp-bulbs { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; + z-index: 1; +} + +.card .cardBorder[data-style="retro-poster"] .cardBorder-rp-bulb-glow { + fill: rgba(255, 120, 20, 0.45); +} + +.card .cardBorder[data-style="retro-poster"] .cardBorder-rp-bulb-mid { + fill: rgba(255, 236, 160, 0.95); +} + +.card .cardBorder[data-style="retro-poster"] .cardBorder-rp-bulb-core { + fill: #ffffff; +} + +/* Stairstep — triple-layer stepped corners (CodePen stairstep geometry) */ +.card .cardBorder[data-style="stairstep"] { + display: grid; + grid-template-columns: + [outer-start] 5px + [mid-start] 5px + [inner-start] 1fr + [inner-end] 5px + [mid-end] 5px + [outer-end]; + grid-template-rows: + [inner-start] 5px + [mid-start] 5px + [outer-start] 1fr + [outer-end] 5px + [mid-end] 5px + [inner-end]; +} + +.card .cardBorder[data-style="stairstep"] .cardBorder-outer-left { + grid-column: outer-start / mid-start; + grid-row: outer-start / outer-end; + border-left: 1px solid var(--card-border-cream); + border-top: 1px solid var(--card-border-cream); + border-bottom: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="stairstep"] .cardBorder-outer-right { + grid-column: mid-end / outer-end; + grid-row: outer-start / outer-end; + border-right: 1px solid var(--card-border-cream); + border-top: 1px solid var(--card-border-cream); + border-bottom: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="stairstep"] .cardBorder-mid-left { + grid-column: mid-start / inner-start; + grid-row: mid-start / mid-end; + border-top: 1px solid var(--card-border-cream); + border-bottom: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="stairstep"] .cardBorder-mid-right { + grid-column: inner-end / mid-end; + grid-row: mid-start / mid-end; + border-top: 1px solid var(--card-border-cream); + border-bottom: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="stairstep"] .cardBorder-mid-top { + grid-column: mid-start / mid-end; + grid-row: outer-start / mid-start; + border-left: 1px solid var(--card-border-cream); + border-right: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="stairstep"] .cardBorder-mid-bottom { + grid-column: mid-start / mid-end; + grid-row: mid-end / outer-end; + border-left: 1px solid var(--card-border-cream); + border-right: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="stairstep"] .cardBorder-inner-top { + grid-column: inner-start / inner-end; + grid-row: inner-start / mid-start; + border-left: 1px solid var(--card-border-cream); + border-right: 1px solid var(--card-border-cream); + border-top: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="stairstep"] .cardBorder-inner-bottom { + grid-column: inner-start / inner-end; + grid-row: mid-end / inner-end; + border-left: 1px solid var(--card-border-cream); + border-right: 1px solid var(--card-border-cream); + border-bottom: 1px solid var(--card-border-cream); +} + +/* Square — dual rails + nested corner squares (CodePen square geometry) */ +.card .cardBorder[data-style="square"] { + display: grid; + grid-template-columns: + [outer-start] 5px + [mid-start] 5px + [inner-start] 2px + [gap-start] 1fr + [gap-end] 2px + [inner-end] 5px + [mid-end] 5px + [outer-end]; + grid-template-rows: + [outer-start] 5px + [mid-start] 5px + [inner-start] 2px + [gap-start] 1fr + [gap-end] 2px + [inner-end] 5px + [mid-end] 5px + [outer-end]; +} + +.card .cardBorder[data-style="square"] .cardBorder-inner { + grid-area: mid; + border: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="square"] .cardBorder-square-left-top { + grid-column: outer-start / inner-start; + grid-row: outer-start / inner-start; + border: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="square"] .cardBorder-square-left-bottom { + grid-column: outer-start / inner-start; + grid-row: inner-end / outer-end; + border: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="square"] .cardBorder-square-right-top { + grid-column: inner-end / outer-end; + grid-row: outer-start / inner-start; + border: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="square"] .cardBorder-square-right-bottom { + grid-column: inner-end / outer-end; + grid-row: inner-end / outer-end; + border: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="square"] .cardBorder-tall { + grid-column: gap-start / gap-end; + grid-row: outer-start / outer-end; + border-top: 1px solid var(--card-border-cream); + border-bottom: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="square"] .cardBorder-wide { + grid-column: outer-start / outer-end; + grid-row: gap-start / gap-end; + border-left: 1px solid var(--card-border-cream); + border-right: 1px solid var(--card-border-cream); +} + +/* Triple — concentric tall/mid/wide frames */ +.card .cardBorder[data-style="triple"] { + display: grid; + grid-template-columns: + [wide-start] 5px + [mid-start] 5px + [tall-start] 1fr + [tall-end] 5px + [mid-end] 5px + [wide-end]; + grid-template-rows: + [tall-start] 5px + [mid-start] 5px + [wide-start] 1fr + [wide-end] 5px + [mid-end] 5px + [tall-end]; +} + +.card .cardBorder[data-style="triple"] .cardBorder-tall { + grid-area: tall; + border: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="triple"] .cardBorder-mid { + grid-area: mid; + border: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="triple"] .cardBorder-wide { + grid-area: wide; + border: 1px solid var(--card-border-cream); +} + +/* Diamond — corner diamonds + dual tall/wide rails */ +.card .cardBorder[data-style="diamond"] { + display: grid; + grid-template-columns: + [outer-start] 5px + [mid-start] 5px + [inner-start] 3px + [gap-start] 1fr + [gap-end] 3px + [inner-end] 5px + [mid-end] 5px + [outer-end]; + grid-template-rows: + [outer-start] 5px + [mid-start] 5px + [inner-start] 3px + [gap-start] 1fr + [gap-end] 3px + [inner-end] 5px + [mid-end] 5px + [outer-end]; +} + +.card .cardBorder[data-style="diamond"] .cardBorder-square-left-top { + grid-column: outer-start / inner-start; + grid-row: outer-start / inner-start; + transform: rotate(45deg); + border: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="diamond"] .cardBorder-square-left-bottom { + grid-column: outer-start / inner-start; + grid-row: inner-end / outer-end; + transform: rotate(45deg); + border: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="diamond"] .cardBorder-square-right-top { + grid-column: inner-end / outer-end; + grid-row: outer-start / inner-start; + transform: rotate(45deg); + border: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="diamond"] .cardBorder-square-right-bottom { + grid-column: inner-end / outer-end; + grid-row: inner-end / outer-end; + transform: rotate(45deg); + border: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="diamond"] .cardBorder-tall-outer { + grid-column: gap-start / gap-end; + grid-row: outer-start / outer-end; + border-top: 1px solid var(--card-border-cream); + border-bottom: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="diamond"] .cardBorder-tall-inner { + grid-column: gap-start / gap-end; + grid-row: inner-start / inner-end; + border-top: 1px solid var(--card-border-cream); + border-bottom: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="diamond"] .cardBorder-wide-outer { + grid-column: outer-start / outer-end; + grid-row: gap-start / gap-end; + border-left: 1px solid var(--card-border-cream); + border-right: 1px solid var(--card-border-cream); +} + +.card .cardBorder[data-style="diamond"] .cardBorder-wide-inner { + grid-column: inner-start / inner-end; + grid-row: gap-start / gap-end; + border-left: 1px solid var(--card-border-cream); + border-right: 1px solid var(--card-border-cream); +} + +/* ========== Corner ========== + Inset overlay frame + mirrored corner flourishes (stays inside the card). */ +.card .cardBorder[data-style="corner"] { + --corner-inset: 0.5rem; + --corner-sq: 0.4rem; + --corner-arm: 1.25rem; + --corner-gap: 0.7rem; + --corner-line: var(--card-border-cream); + /* Shrink host so the frame sits inside the poster as an overlay */ + inset: var(--corner-inset); + border: 1px solid var(--corner-line); + overflow: visible; + background: transparent; +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner { + position: absolute; + width: var(--corner-sq); + height: var(--corner-sq); + /* content-box keeps abspos math identical on left vs right */ + box-sizing: content-box; + border: 1px solid var(--corner-line); +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner::after, +.card .cardBorder[data-style="corner"] .cardBorder-corner::before { + content: ""; + position: absolute; + box-sizing: border-box; +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner::after { + width: var(--corner-arm); + height: var(--corner-gap); +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner::before { + width: var(--corner-gap); + height: var(--corner-arm); +} + +/* Center each square on the inset frame corner (symmetric L/R + T/B) */ +.card .cardBorder[data-style="corner"] .cardBorder-corner-tl { + top: calc(var(--corner-sq) / -2 - 1px); + left: calc(var(--corner-sq) / -2 - 1px); +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner-tr { + top: calc(var(--corner-sq) / -2 - 1px); + right: calc(var(--corner-sq) / -2 - 1px); +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner-bl { + bottom: calc(var(--corner-sq) / -2 - 1px); + left: calc(var(--corner-sq) / -2 - 1px); +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner-br { + bottom: calc(var(--corner-sq) / -2 - 1px); + right: calc(var(--corner-sq) / -2 - 1px); +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner-tl::after, +.card .cardBorder[data-style="corner"] .cardBorder-corner-bl::after { + left: calc(var(--corner-gap) - 1px); + border-left: 1px solid var(--corner-line); +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner-tl::before, +.card .cardBorder[data-style="corner"] .cardBorder-corner-bl::before { + left: -1px; + border-left: 1px solid var(--corner-line); +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner-tr::after, +.card .cardBorder[data-style="corner"] .cardBorder-corner-br::after { + right: calc(var(--corner-gap) - 1px); + border-right: 1px solid var(--corner-line); +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner-tr::before, +.card .cardBorder[data-style="corner"] .cardBorder-corner-br::before { + right: -1px; + border-right: 1px solid var(--corner-line); +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner-tl::after, +.card .cardBorder[data-style="corner"] .cardBorder-corner-tr::after { + top: -1px; + border-top: 1px solid var(--corner-line); +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner-tl::before, +.card .cardBorder[data-style="corner"] .cardBorder-corner-tr::before { + top: calc(var(--corner-gap) - 1px); + border-top: 1px solid var(--corner-line); +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner-bl::after, +.card .cardBorder[data-style="corner"] .cardBorder-corner-br::after { + bottom: -1px; + border-bottom: 1px solid var(--corner-line); +} + +.card .cardBorder[data-style="corner"] .cardBorder-corner-bl::before, +.card .cardBorder[data-style="corner"] .cardBorder-corner-br::before { + bottom: calc(var(--corner-gap) - 1px); + border-bottom: 1px solid var(--corner-line); +} + +/* ========== Picture Frame ========== + Frame stays inside the card; poster shrinks into the mat opening. */ +[data-border="picture-frame"], +[data-border-style="picture-frame"] { + --pf-frame: 0.85rem; + --pf-mat: 0.55rem; + --pf-lip: 0.35rem; + /* Extra gap so the poster sits deeper in the mat opening */ + --pf-gap: 0.45rem; + --pf-inset: calc(var(--pf-lip) + var(--pf-frame) + var(--pf-mat) + var(--pf-gap)); + /* Raised lip/mat use border color; recessed rings use a slightly darker mix */ + --pf-raised: var(--kefin-card-border-color, #f8f8f8); + --pf-recessed: color-mix(in srgb, var(--pf-raised) 78%, #000); +} + +[data-border="picture-frame"] .card .cardScalable > .cardImageContainer, +[data-border-style="picture-frame"] .card .cardScalable > .cardImageContainer, +[data-border="picture-frame"] .card .cardScalable > .cardOverlayContainer, +[data-border-style="picture-frame"] .card .cardScalable > .cardOverlayContainer, +[data-border="picture-frame"] .card .cardScalable > .blurhash-canvas, +[data-border-style="picture-frame"] .card .cardScalable > .blurhash-canvas { + top: calc(var(--pf-inset) + 0.25rem) !important; + right: calc(var(--pf-inset) + 0.25rem) !important; + bottom: calc(var(--pf-inset) + 0.25rem) !important; + left: calc(var(--pf-inset) + 0.25rem) !important; + width: calc(100% - calc(calc(var(--pf-inset) + 0.25rem) * 2)) !important; + height: calc(100% - calc(calc(var(--pf-inset) + 0.25rem) * 2)) !important; + border-radius: 0 !important; + z-index: 3 !important; + background-size: cover; +} + +[data-border="picture-frame"] .card, +[data-border-style="picture-frame"] .card, +[data-border="picture-frame"] .card .cardScalable, +[data-border-style="picture-frame"] .card .cardScalable, +[data-border="picture-frame"] .card .cardPadder, +[data-border-style="picture-frame"] .card .cardPadder { + border-radius: 0 !important; +} + +[data-border="picture-frame"] .card .cardScalable { + padding: var(--pf-inset) !important; +} + +.card .cardBorder[data-style="picture-frame"] { + --pf-frame: 1.25rem; + --pf-mat: 0.55rem; + --pf-lip: 0.5rem; + --pf-raised: var(--kefin-card-border-color, #f8f8f8); + --pf-recessed: color-mix(in srgb, var(--pf-raised) 95%, #000); + overflow: hidden; + /* Above padder so the frame is visible in the inset margins */ + z-index: 2; +} + +/* Mid frame / mat bed (1st inner) — recessed, slightly darker than border color */ +.card .cardBorder[data-style="picture-frame"]::before { + content: ""; + position: absolute; + inset: var(--pf-lip); + z-index: 0; + background-color: var(--pf-recessed); + background-image: radial-gradient( + circle farthest-corner at 50% 50%, + color-mix(in srgb, var(--pf-recessed) 88%, #fff), + var(--pf-recessed) 100% + ); + box-shadow: + inset 0 10px 0.5em rgba(0, 0, 0, 0.25), + inset 0.1em 0 0.1em rgba(0, 0, 0, 0.1), + inset -0.1em 0 0.1em rgba(0, 0, 0, 0.05), + 0 0.3em 0.2em color-mix(in srgb, var(--pf-raised) 55%, transparent); +} + +/* Outer lip — fills the cardBorder host (no outside overflow). + Border color tints this raised outer band. */ +.card .cardBorder[data-style="picture-frame"]::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: var(--pf-raised); + box-shadow: + 0 1em 2em -1em rgba(0, 0, 0, 0.4), + 0 2em 2em -1em rgba(0, 0, 0, 0.3), + 0 3em 2em -1em rgba(0, 0, 0, 0.2), + 0 4em 1.5em -1em rgba(0, 0, 0, 0.15), + 0 2em 4em 0.5em rgba(0, 0, 0, 0.1), + inset 0 0.2em 0.1em color-mix(in srgb, var(--pf-raised) 70%, #fff); +} + +/* Inner mat (2nd inner raised) + opening bed (3rd inner recessed) */ +.card .cardBorder[data-style="picture-frame"] .cardBorder-pf-inner { + position: absolute; + inset: calc(var(--pf-lip) + var(--pf-frame)); + box-shadow: inset 0 0.3em 0.1em rgba(0, 0, 0, 0.2); + background: var(--pf-recessed); + box-sizing: border-box; +} + +/* ========== Art Gallery ========== + Demo .Frame look: dark beveled rail, light mat, diagonal gloss, thin photo lip. */ +[data-border="art-gallery"], +[data-border-style="art-gallery"] { + --ag-rail: 1.1rem; + --ag-mat: 0.5rem; + --ag-inset: calc(var(--ag-rail) + var(--ag-mat)); + --ag-base: var(--kefin-card-border-color, #434040); + --ag-rail-top: color-mix(in srgb, var(--ag-base) 72%, #000); + --ag-rail-side: var(--ag-base); + --ag-rail-bottom: color-mix(in srgb, var(--ag-base) 88%, #fff); +} + +[data-border="art-gallery"] .card .cardScalable > .cardImageContainer, +[data-border-style="art-gallery"] .card .cardScalable > .cardImageContainer, +[data-border="art-gallery"] .card .cardScalable > .cardOverlayContainer, +[data-border-style="art-gallery"] .card .cardScalable > .cardOverlayContainer, +[data-border="art-gallery"] .card .cardScalable > .blurhash-canvas, +[data-border-style="art-gallery"] .card .cardScalable > .blurhash-canvas { + top: var(--ag-inset) !important; + right: var(--ag-inset) !important; + bottom: var(--ag-inset) !important; + left: var(--ag-inset) !important; + width: calc(100% - var(--ag-inset) * 2) !important; + height: calc(100% - var(--ag-inset) * 2) !important; + border-radius: 0 !important; + z-index: 3 !important; + background-size: cover; +} + +[data-border="art-gallery"] .card .cardScalable > .cardImageContainer, +[data-border-style="art-gallery"] .card .cardScalable > .cardImageContainer { + border-width: 2px !important; + border-style: solid !important; + border-color: #bbbab4 #c7c7bf #e5e4df #c7c7bf !important; + box-shadow: + 0 -1px 1px rgba(0, 0, 0, 0.1), + 0 1px 1px 1px rgba(255, 255, 255, 0.7) !important; + box-sizing: border-box !important; +} + +[data-border="art-gallery"] .card, +[data-border-style="art-gallery"] .card, +[data-border="art-gallery"] .card .cardScalable, +[data-border-style="art-gallery"] .card .cardScalable, +[data-border="art-gallery"] .card .cardPadder, +[data-border-style="art-gallery"] .card .cardPadder { + border-radius: 0 !important; +} + +[data-border="art-gallery"] .card .cardScalable { + padding: calc(var(--ag-inset) * 2) !important; +} + +.emby-scroller-container[data-border-style="art-gallery"] .card .cardBorder { + --ag-rail: 0.5rem; + --ag-base: var(--kefin-card-border-color, #434040); + --ag-rail-top: color-mix(in srgb, var(--ag-base) 72%, #000); + --ag-rail-side: var(--ag-base); + --ag-rail-bottom: color-mix(in srgb, var(--ag-base) 88%, #fff); + z-index: 2; + overflow: hidden; + box-sizing: border-box; + border-width: var(--ag-rail); + border-style: solid; + border-color: var(--ag-rail-top) var(--ag-rail-side) var(--ag-rail-bottom) var(--ag-rail-side); + background: #f5f5f5; + background-image: linear-gradient(#e5e4df, #cdcdc6); + box-shadow: + inset 0 2px 5px rgba(0, 0, 0, 0.45), + 0 3px 2px rgba(0, 0, 0, 0.08), + 0 8px 16px rgba(0, 0, 0, 0.45); +} + +/* Diagonal gloss highlight over the mat */ +.emby-scroller-container[data-border-style="art-gallery"] .card .cardImageContainer::before, +.emby-scroller-container[data-border-style="art-gallery"] .card .cardBorder::before { + content: ""; + position: absolute; + top: -175px; + right: -20%; + height: 400px; + z-index: 27; + pointer-events: none; + transform: rotate(-40deg); + background-image: linear-gradient(rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0)); +} + +.emby-scroller-container[data-border-style="art-gallery"] .card .cardBorder::before { + left: calc(50% - 14px); +} + +.emby-scroller-container[data-border-style="art-gallery"] .cardImageContainer::before { + left: 50%; +} + +/* ========== Modern Poster ========== + Bevel colors match demo: lighten(#000, 20%) / #000 */ +[data-border="modern-poster"], +[data-border-style="modern-poster"] { + --mp-inset: 10px; +} + +/* Shrink poster so the 10px bevel does not clip the image */ +[data-border="modern-poster"] .card .cardScalable > .cardImageContainer, +[data-border-style="modern-poster"] .card .cardScalable > .cardImageContainer, +[data-border="modern-poster"] .card .cardScalable > .cardOverlayContainer, +[data-border-style="modern-poster"] .card .cardScalable > .cardOverlayContainer, +[data-border="modern-poster"] .card .cardScalable > .blurhash-canvas, +[data-border-style="modern-poster"] .card .cardScalable > .blurhash-canvas { + width: calc(100% - var(--mp-inset) * 4) !important; + height: calc(100% - var(--mp-inset) * 4) !important; + top: calc(var(--mp-inset) * 2) !important; + left: calc(var(--mp-inset) * 2) !important; + border-radius: 0 !important; + background-size: cover; +} + +[data-border="modern-poster"] .cardPadder { + background: white !important; +} + +/* Square corners so the poster meets the bevel flush */ +[data-border="modern-poster"] .card, +[data-border-style="modern-poster"] .card, +[data-border="modern-poster"] .card .cardScalable, +[data-border-style="modern-poster"] .card .cardScalable, +[data-border="modern-poster"] .card .cardPadder, +[data-border-style="modern-poster"] .card .cardPadder { + border-radius: 0 !important; +} + +[data-border="modern-poster"] .card .cardScalable { + padding: calc(var(--mp-inset) * 2) !important; + background: white !important; +} + +.card .cardBorder[data-style="modern-poster"] { + border-style: solid; + border-width: var(--mp-inset, 15px); + border-top-color: #333; + border-right-color: #000; + border-bottom-color: #333; + border-left-color: #000; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.6); + background: transparent; +} + +/* ========== Vintage ========== */ +.card .cardBorder[data-style="vintage"] { + overflow: visible; +} + +.card .cardBorder[data-style="vintage"] .cardBorder-v-outer { + position: absolute; + inset: 0; + border: 2px solid var(--card-border-cream); + padding: 4px; + box-sizing: border-box; +} + +.card .cardBorder[data-style="vintage"] .cardBorder-v-mid { + position: relative; + width: 100%; + height: 100%; + border: 4px solid var(--card-border-cream); + padding: 4px; + box-sizing: border-box; +} + +.card .cardBorder[data-style="vintage"] .cardBorder-v-inner { + position: relative; + width: 100%; + height: 100%; + border: 2px solid var(--card-border-cream); + box-sizing: border-box; +} + +.card .cardBorder[data-style="vintage"] .cardBorder-v-corner { + position: absolute; + width: 3em; + height: 3em; + margin: -3px; + background-color: var(--card-border-cream); + background-image: none; + -webkit-mask-image: var(--v-corner-deco); + mask-image: var(--v-corner-deco); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; +} + +.card .cardBorder[data-style="vintage"] .cardBorder-v-corner-tl { + left: 0; + top: 0; +} + +.card .cardBorder[data-style="vintage"] .cardBorder-v-corner-tr { + top: 0; + right: 0; + transform: scaleX(-1); +} + +.card .cardBorder[data-style="vintage"] .cardBorder-v-corner-br { + right: 0; + bottom: 0; + transform: scale(-1); +} + +.card .cardBorder[data-style="vintage"] .cardBorder-v-corner-bl { + left: 0; + bottom: 0; + transform: scaleY(-1); +} + +/* ========== Film Reel ========== + Black film frame by default; poster inset into the center “hole” + so top/bottom sprocket bands stay fully visible. */ +[data-border="film-reel"], +[data-border-style="film-reel"] { + --fr-film: var(--kefin-card-border-color, #000000); + --fr-band: 22px; + --fr-side: 10px; + /* Sprocket perforations: taller than wide; spaced + inset so none clip */ + --fr-hole-w: 8px; + --fr-hole-h: 12px; + --fr-hole-gap: 18px; + --fr-edge-inset: -8px; + --fr-hole-fill: color-mix(in srgb, var(--fr-film) 12%, #f2ead8); +} + +/* Shrink poster into the center frame hole */ +[data-border="film-reel"] .card .cardScalable > .cardImageContainer, +[data-border-style="film-reel"] .card .cardScalable > .cardImageContainer, +[data-border="film-reel"] .card .cardScalable > .cardOverlayContainer, +[data-border-style="film-reel"] .card .cardScalable > .cardOverlayContainer { + top: var(--fr-band) !important; + right: var(--fr-side) !important; + bottom: var(--fr-band) !important; + left: var(--fr-side) !important; + width: auto !important; + height: auto !important; + border-radius: 0 !important; +} + +[data-border="film-reel"] .card, +[data-border-style="film-reel"] .card, +[data-border="film-reel"] .card .cardScalable, +[data-border-style="film-reel"] .card .cardScalable, +[data-border="film-reel"] .card .cardPadder, +[data-border-style="film-reel"] .card .cardPadder { + border-radius: 0 !important; + border: none !important; +} + +.card .cardBorder[data-style="film-reel"] { + /* Transparent center so the inset poster shows through; sides are solid rails */ + background: transparent; + border-top: none; + border-bottom: none; + border-left: var(--fr-side) solid var(--fr-film); + border-right: var(--fr-side) solid var(--fr-film); + overflow: hidden; + box-sizing: border-box; +} + +/* Top / bottom sprocket bands — corner holes inset, evenly spaced, never clipped */ +.card .cardBorder[data-style="film-reel"]::before, +.card .cardBorder[data-style="film-reel"]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + height: var(--fr-band); + box-sizing: border-box; + pointer-events: none; + z-index: 3; + margin: 0 var(--fr-edge-inset); + /* Hole centered in each tile so first/last stay inset; `space` evens the rest */ + background-image: + linear-gradient( + 90deg, + transparent 0, + transparent calc(var(--fr-hole-gap) / 2), + var(--fr-hole-fill) calc(var(--fr-hole-gap) / 2) calc(var(--fr-hole-gap) / 2 + var(--fr-hole-w)), + transparent calc(var(--fr-hole-gap) / 2 + var(--fr-hole-w)) + ), + linear-gradient(var(--fr-film), var(--fr-film)); + background-size: + calc(var(--fr-hole-w) + var(--fr-hole-gap)) var(--fr-hole-h), + auto; + background-repeat: + space no-repeat, + no-repeat; + background-position: + left center, + center; + background-origin: content-box, border-box; + background-clip: content-box, border-box; +} + +.card .cardBorder[data-style="film-reel"]::before { + top: 0; +} + +.card .cardBorder[data-style="film-reel"]::after { + bottom: 0; +} + +/* ========== Wood (bookshelf) ========== + wood.png under the poster; top/bottom shelf bars only (no side rails). + Tiles: half shelf height + container half padding so outer edges sum to full. */ +[data-border="wood"], +[data-border-style="wood"] { + --wood-shelf-h-full: 14px; + --wood-shelf-h: var(--wood-shelf-h-full); + --wood-vert-img: url("../pages/images/wood.png"); + --wood-shelf: color-mix(in srgb, var(--kefin-card-border-color, #6b492b) 40%, #6b492b); + /* Top-of-top and bottom-of-bottom share --wood-shelf-edge for seamless tiling */ + --wood-shelf-edge: color-mix(in srgb, var(--wood-shelf) 82%, #8a6340); + --wood-shelf-mid: color-mix(in srgb, var(--wood-shelf) 70%, #3d2818); + --wood-shelf-top: linear-gradient(to bottom, var(--wood-shelf-edge), var(--wood-shelf-mid)); + --wood-shelf-bottom: linear-gradient(to bottom, var(--wood-shelf-mid), var(--wood-shelf-edge)); + --wood-bevel-hi: color-mix(in srgb, var(--wood-shelf) 40%, #e8d4b0); + --wood-bevel-lo: color-mix(in srgb, var(--wood-shelf) 40%, #1a0f08); +} + +[data-border="wood"] .emby-scroller, +[data-border-style="wood"] .emby-scroller { + margin-top: 0.5rem; +} + +/* Row/grid: grain on each card. Tiles: container owns continuous grain (below). */ +[data-border="wood"] .card .cardScalable, +[data-border-style="wood"] .card .cardScalable { + background-image: var(--wood-vert-img); + background-size: cover; + background-repeat: repeat; + background-position: center top; + border-radius: 0 !important; +} + +[data-border="wood"] .card, +[data-border-style="wood"] .card { + padding: 0; +} + +[data-border="wood"] .itemsContainer[data-gapless="true"] .card .cardScalable, +[data-border-style="wood"] .itemsContainer[data-gapless="true"] .card .cardScalable { + background-image: none; + background-color: transparent; +} + +/* Gapless: half shelves + outer half-height shelf ring on the container */ +[data-border="wood"] .itemsContainer[data-gapless="true"], +[data-border-style="wood"] .itemsContainer[data-gapless="true"] { + box-sizing: border-box; + background-color: transparent; + background-image: var(--wood-shelf-bottom), var(--wood-shelf-top), transparent; + background-size: + 100% var(--wood-shelf-h), + 100% var(--wood-shelf-h), + cover; + background-position: + top, + bottom, + center top; + background-repeat: no-repeat, no-repeat, repeat; +} + +[data-card-title-visibility="hidden"][data-border-style="wood"] .itemsContainer[data-gapless="true"], +[data-card-title-visibility="hidden"][data-border-style="wood"] .itemsContainer[data-layout="grid"] { + --wood-shelf-h: calc(var(--wood-shelf-h-full) / 2); + padding: var(--wood-shelf-h) 0; + background: var(--wood-shelf-edge); +} + +/* Poster inset — top/bottom shelves only */ +[data-border="wood"] .card .cardScalable > .cardImageContainer, +[data-border-style="wood"] .card .cardScalable > .cardImageContainer, +[data-border="wood"] .card .cardScalable > .cardOverlayContainer, +[data-border-style="wood"] .card .cardScalable > .cardOverlayContainer, +[data-border="wood"] .card .cardScalable > .blurhash-canvas, +[data-border-style="wood"] .card .cardScalable > .blurhash-canvas { + top: calc(var(--wood-shelf-h-full) * 2) !important; + right: var(--wood-shelf-h-full) !important; + bottom: var(--wood-shelf-h) !important; + left: var(--wood-shelf-h-full) !important; + width: calc(100% - var(--wood-shelf-h-full) * 2) !important; + height: calc(100% - calc(calc(var(--wood-shelf-h-full) * 2) + var(--wood-shelf-h))) !important; + border-radius: 0 !important; + z-index: 1 !important; + box-shadow: 0px -4px 10px 0px #000c; +} + +[data-border-style="wood"] .card .cardScalable { + border: none !important; + box-shadow: none !important; +} + +[data-border-style="wood"][data-card-format="Disc"] .card .cardScalable > .cardImageContainer, +[data-border-style="wood"][data-card-format="Clear Art"] .card .cardScalable > .cardImageContainer, +[data-border-style="wood"][data-card-format="Logo"] .card .cardScalable > .cardImageContainer { + box-shadow: none !important; + background-size: contain; + background-position: bottom center; +} + +[data-border="wood"] .card, +[data-border-style="wood"] .card, +[data-border="wood"] .card .cardPadder, +[data-border-style="wood"] .card .cardPadder { + border-radius: 0 !important; +} + +/* Shelves only — full width, no side rails */ +.card .cardBorder[data-style="wood"] { + overflow: hidden; + z-index: 0; + box-sizing: border-box; + border: none; + background-image: var(--wood-vert-img); + background-size: cover; +} + +.card .cardBorder[data-style="wood"]::before, +.card .cardBorder[data-style="wood"]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + width: auto; + height: var(--wood-shelf-h); + z-index: 3; + pointer-events: none; + box-sizing: border-box; +} + +.card .cardBorder[data-style="wood"]::before { + top: 0; + background: var(--wood-shelf-top); + /* Bevel shadow along the inner (bottom) edge */ + box-shadow: inset 0 -2px 0 var(--wood-bevel-lo); +} + +.card .cardBorder[data-style="wood"]::after { + bottom: 0; + background: var(--wood-shelf-bottom); + /* Bevel highlight along the inner (top) edge */ + box-shadow: inset 0 1px 0 var(--wood-bevel-hi); +} + +/* ========== Profile ========== + Broken outer frame around the whole card; poster inset with its own thin + border; title sits in the side gaps; secondary sits in the bottom gap. + Card chrome is transparent (no white/cream fill). */ +[data-border="profile"], +[data-border-style="profile"] { + --pr-c: var(--kefin-card-border-color, #c45c4a); + --pr-w: 2px; + --pr-pad: 1.85rem; + /* Vertical gap in the side rails where the primary title sits */ + --pr-side-gap: 2.35em; + /* Height of the short side stubs that meet the bottom corners */ + --pr-side-stub: 1.55em; + /* Half-width gap in the bottom rail for secondary text */ + --pr-bottom-gap: 3.25em; +} + +[data-border="profile"] .itemsContainer, +[data-border-style="profile"] .itemsContainer, +[data-border-style="profile"] .itemsContainer { + gap: 1.5rem; +} + +[data-border="profile"] .card, +[data-border-style="profile"] .card { + box-shadow: none; + background: transparent !important; + contain: none; + overflow: hidden; +} + +[data-border="profile"] .card .cardBox, +[data-border-style="profile"] .card .cardBox { + position: relative; + background-color: transparent !important; + /* Broken rectangle: top + upper sides, bottom stubs + bottom rail with center gap */ + background-image: + linear-gradient(var(--pr-c), var(--pr-c)), linear-gradient(var(--pr-c), var(--pr-c)), linear-gradient(var(--pr-c), var(--pr-c)), + linear-gradient(var(--pr-c), var(--pr-c)), linear-gradient(var(--pr-c), var(--pr-c)), linear-gradient(var(--pr-c), var(--pr-c)), + linear-gradient(var(--pr-c), var(--pr-c)); + background-size: + 100% var(--pr-w), + var(--pr-w) calc(100% - var(--pr-side-stub) - var(--pr-side-gap)), + var(--pr-w) calc(100% - var(--pr-side-stub) - var(--pr-side-gap)), + var(--pr-w) var(--pr-side-stub), + var(--pr-w) var(--pr-side-stub), + calc(50% - var(--pr-bottom-gap)) var(--pr-w), + calc(50% - var(--pr-bottom-gap)) var(--pr-w); + background-position: + top left, + top left, + top right, + bottom left, + bottom right, + bottom left, + bottom right; + background-repeat: no-repeat; + padding: var(--pr-pad) var(--pr-pad) 0; + box-sizing: border-box; + display: grid; + grid-template-rows: auto 1fr; +} + +[data-border="profile"] .card .cardScalable, +[data-border-style="profile"] .card .cardScalable { + padding: var(--pr-pad) !important; + background: transparent !important; + border-radius: 0 !important; + box-shadow: none !important; + border: none !important; +} + +[data-border="profile"] .card .cardPadder, +[data-border-style="profile"] .card .cardPadder { + background: transparent; +} + +[data-border="profile"] .card .cardBox, +[data-border-style="profile"] .cardBox { + height: calc(100% - var(--pr-pad)); +} + +[data-border="profile"] .card .cardPadder, +[data-border-style="profile"] .card .cardPadder, +[data-border="profile"] .card .cardScalable > .cardImageContainer, +[data-border-style="profile"] .card .cardScalable > .cardImageContainer, +[data-border="profile"] .card .cardScalable > .cardOverlayContainer, +[data-border-style="profile"] .card .cardScalable > .cardOverlayContainer, +[data-border="profile"] .card .cardScalable > .blurhash-canvas, +[data-border-style="profile"] .card .cardScalable > .blurhash-canvas { + border-radius: 0 !important; +} + +[data-border="profile"] .card .cardScalable > .blurhash-canvas, +[data-border-style="profile"] .card .cardScalable > .blurhash-canvas { + top: 1rem; + left: 1rem; + width: calc(100% - 2rem); + height: calc(100% - 2rem); +} + +[data-border="profile"] .card .cardScalable > .cardImageContainer, +[data-border-style="profile"] .card .cardScalable > .cardImageContainer { + box-sizing: border-box !important; + clip-path: none !important; + box-shadow: none !important; + width: calc(100% - 2rem); + height: calc(100% - 2rem); + background-size: cover; + top: 1rem; + left: 1rem; +} + +/* Frame is painted on .cardBox; hide the scalable overlay host */ +[data-border="profile"] .card .cardBorder, +[data-border-style="profile"] .card .cardBorder { + display: none; +} + +[data-border="profile"] .card .cardTextStack, +[data-border-style="profile"] .card .cardTextStack { + display: grid; +} + +[data-border="profile"] .card .cardText-first, +[data-border-style="profile"] .card .cardText-first { + position: relative; + order: 2; + z-index: 1; + background: transparent !important; + color: var(--pr-c); + font-size: 1.05em; + width: calc(100% - 2 * var(--pr-pad)); + margin: -0.55em auto 0; + padding: 0.35em 0; + text-align: center; + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.02em; + box-sizing: border-box; + /* Sit in the side-rail gap */ + line-height: 1.2; + text-align: center !important; + white-space: normal; +} + +[data-border="profile"] .card .cardText-first a, +[data-border-style="profile"] .card .cardText-first a { + color: inherit !important; + font-weight: inherit; +} + +[data-border="profile"] .card .cardText-secondary, +[data-border-style="profile"] .card .cardText-secondary { + position: relative; + z-index: 1; + margin: 0.35em calc(-1 * var(--pr-pad)) 0; + width: calc(100% + 2 * var(--pr-pad)); + padding: 0.15em var(--pr-pad) 0.65em; + text-align: center; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.78em; + font-weight: 500; + color: var(--pr-c); + opacity: 0.72; + background: transparent !important; + box-sizing: border-box; + /* Centered in the bottom-rail gap */ + line-height: 1; + text-align: center !important; +} + +[data-border="profile"] .card .cardText-secondary a, +[data-border-style="profile"] .card .cardText-secondary a { + color: inherit !important; +} + +[data-border-style="profile"] .cardTextStack .cardText-secondary:nth-child(2) { + order: 3; + margin-top: auto; +} + +[data-border-style="profile"] .cardTextStack { + display: flex; + flex-direction: column; +} + +[data-border="profile"] .card .cardFooter, +[data-border-style="profile"] .card .cardFooter { + visibility: visible !important; + background: transparent !important; +} + +.cardbuilder-items-fade { + transition: opacity 0.28s ease; +} + +.cardbuilder-items-fade.is-hidden { + opacity: 0; +} + +.cardbuilder-empty-message { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 4.5em; + padding: 1em 1.25em; + box-sizing: border-box; + opacity: 0.55; + font-size: 1.05em; + text-align: center; +} + +.cardbuilder-card-collapse, +.cardbuilder-card-insert { + overflow: hidden !important; + pointer-events: none; +} + +.itemsContainer.cardbuilder-row-animating .cardbuilder-card-collapse, +.itemsContainer.cardbuilder-row-animating .cardbuilder-card-insert { + transition: + opacity 0.32s ease, + width 0.32s ease, + min-width 0.32s ease, + max-width 0.32s ease, + margin 0.32s ease; +} + +.itemProgressBarForeground { + height: 100%; +} + +.custom-scroller-container .sectionTitleContainer h2, +.sectionTitleContainer span { + margin: 0 !important; +} + +.custom-scroller .homeLibraryButton { + min-width: unset; +} + +.missingIndicator, +.unairedIndicator { + background: #c33; + border-radius: 100em; + color: #fff; + font-size: 84%; + font-weight: 500; + margin: 0 0.25em; + padding: 0.25em 0.5em; +} + +.custom-scroller-container .sectionCaption.sectionTitleTextButton:not(.padded-left) { + padding-top: 0 !important; + padding-bottom: 0 !important; + margin-top: 0 !important; + margin-bottom: 0 !important; +} + +.emby-scroller-container[data-layout="grid"] .emby-scrollbuttons { + display: none !important; +} + +.spotlight-section[data-tile-count="3"] .spotlight-background-layer { + width: 37.5%; +} + +.emby-scroller-container[data-no-scroll="true"] .emby-scrollbuttons { + display: none; +} + +.emby-scroller-container[data-layout="grid"] .emby-scrollbuttons { + display: none !important; +} + +/* +.spotlight-truncated-list:has(.spotlight-truncated-list-tooltip) { + overflow: visible; +} + +.spotlight-section[data-size="full"] .spotlight-truncated-list.itemDetailsGroup:has(> .detailsGroupItem), +.spotlight-section[data-size="full"] .spotlight-truncated-list.sectionTabs:has(> .emby-tab-button) { + font-size: large; + display: flex; + align-items: center; + align-self: start; +} + +.spotlight-section .itemDetailsGroup:has(.detailsGroupItem:not(.hide)), +.spotlight-section .sectionTabs:not(.spotlight-section-title):has(.emby-tab-button:not(.hide)) { + margin: 0px !important; + padding: 0px !important; + width: initial !important; + min-width: initial !important; +} + +.spotlight-section:not(.hide) span.emby-tab-button.emby-tab-button-active:not(:has(a)) { + cursor: default; +} + +.layout-mobile .spotlight-section .spotlight-section-title.sectionTabs:has(.emby-tab-button:not(.hide)) { + justify-self: center; + display: flex !important; + justify-content: center; +} + +.layout-mobile .spotlight-section .spotlight-section-title.sectionTabs:has(.emby-tab-button:not(.hide)) > * { + display: block !important; + padding: 0.25em 0.5em !important; +} + +.emby-scroller-container[data-border-style="profile"] .card .cardTextStack:has(.cardText:nth-child(2):last-child) .cardText-first { + margin-top: auto; +} */ + +.layout-mobile .spotlight-section[data-hide-section-name="true"] .spotlight-section-title, +.layout-mobile .spotlight-section[data-hide-section-name="true"] .spotlight-section-title.sectionTabs, +.spotlight-section[data-hide-section-name="true"] .spotlight-section-title.sectionTabs { + display: none !important; +} + +.spotlight-section .spotlight-header-container > *, +.spotlight-section .spotlight-section-title-container > *, +.spotlight-section .spotlight-section-title-container .section-controls, +.spotlight-section .spotlight-section-title-container a, +.spotlight-section .spotlight-section-title-container button, +.spotlight-section .spotlight-nav-container { + pointer-events: auto; +} + +.sectionTitleContainer { + display: flex !important; +} +.cardOverlayContainer > .button-link { + width: 100%; + height: 100%; +} + +.card[data-type="Program"] .cardTextStack .cardText-secondary:nth-child(3) { + display: none; +} +[data-section-id="liveTv-on-now"] .card[data-type="Program"] .cardTextStack .cardText-secondary:nth-child(2) { + display: none; +} +[data-section-id="liveTv-on-now"] .card[data-type="Program"] .cardTextStack .cardText-secondary:nth-child(3) { + display: block; +} + +/* TV layout: native emby-scroller + button cards (match Jellyfin layout-tv rows) */ +.layout-tv .emby-scroller-container > .emby-scroller.no-padding { + overflow: hidden; +} + +.layout-tv .itemsContainer-tv > button.card { + -webkit-appearance: none; + appearance: none; + display: inline-block; + border: 0; + background: transparent; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + text-align: inherit; + vertical-align: top; + cursor: pointer; +} + +/* Section controls are hover-only on desktop; TV has no hover */ +.layout-tv .emby-scroller-container .show-all-button, +.layout-tv .emby-scroller-container .section-refresh-button, +.layout-tv .emby-scroller-container .section-configure-button, +.layout-tv .emby-scroller-container .section-controls-more, +.layout-tv .spotlight-section .show-all-button, +.layout-tv .spotlight-section .section-refresh-button, +.layout-tv .spotlight-section .section-configure-button { + opacity: 0.65; + visibility: visible; + pointer-events: auto; +} + +/* TV: title + controls share focuscontainer-x on .sectionTitleContainer / .sectionTitle-wrapper */ +.layout-tv .section-controls-inline { + display: inline-flex; + align-items: center; +} + +.verticalSection[data-card-title-position^="overlay-"] .cardScalable::after, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardScalable::after { + pointer-events: none !important; +} + +.verticalSection[data-card-title-position^="overlay-"] .cardTextStack a, +.verticalSection[data-card-title-position^="overlay-"] .cardTextStack button, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack a, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack button { + pointer-events: auto !important; + position: relative; + z-index: 1; +} + +.emby-scroller-container[data-section-id] { + z-index: 1; +} + +/* TV metadata stop: look like the pill group, not a raised Jellyfin button */ +.layout-tv button.metadata-container.emby-button { + display: flex; + flex-direction: column; + align-items: start; + gap: 8px; + overflow: visible; + min-width: 0; + width: auto; + background: transparent; + border: 0; + padding: 0; + margin: 0; + box-shadow: none; + font: inherit; + color: inherit; + text-align: inherit; + cursor: default; + -webkit-appearance: none; + appearance: none; + min-height: 0; + transform: none; +} + +.section-controls span.material-icons { + font-size: 18px !important; +} + +/* --- TV spotlight focus: solid accent pills --- */ +.layout-tv .spotlight-section-title .emby-button.show-focus:focus, +.layout-tv .spotlight-section-title .emby-tab-button.show-focus:focus, +.layout-tv .spotlight-section-title-container .show-all-button.show-focus:focus, +.layout-tv .spotlight-section-title-container .section-refresh-button.show-focus:focus, +.layout-tv .spotlight-section-title-container .section-configure-button.show-focus:focus, +.layout-tv .sectionTitleContainer .show-all-button.show-focus:focus, +.layout-tv .sectionTitleContainer .section-refresh-button.show-focus:focus, +.layout-tv .sectionTitleContainer .section-configure-button.show-focus:focus, +.layout-tv .spotlight-nav-container .spotlight-nav-button.show-focus:focus, +.layout-tv .metadata-container a.spotlight-genre-link.show-focus:focus, +.layout-tv .metadata-container a.spotlight-person-link.show-focus:focus, +.layout-tv .metadata-container a.spotlight-studio-link.show-focus:focus { + background-color: var(--theme-primary-color, #00a4dc) !important; + background: var(--theme-primary-color, #00a4dc) !important; + color: #fff !important; + opacity: 1; + outline: none; + box-shadow: none; +} + +.layout-tv .spotlight-section-title-container .show-all-button.show-focus:focus .material-icons, +.layout-tv .spotlight-section-title-container .section-refresh-button.show-focus:focus .material-icons, +.layout-tv .spotlight-section-title-container .section-configure-button.show-focus:focus .material-icons, +.layout-tv .sectionTitleContainer .show-all-button.show-focus:focus .material-icons, +.layout-tv .sectionTitleContainer .section-refresh-button.show-focus:focus .material-icons, +.layout-tv .sectionTitleContainer .section-configure-button.show-focus:focus .material-icons, +.layout-tv .sectionTitle-wrapper .show-all-button.show-focus:focus .material-icons, +.layout-tv .sectionTitle-wrapper .section-refresh-button.show-focus:focus .material-icons, +.layout-tv .sectionTitle-wrapper .section-configure-button.show-focus:focus .material-icons { + color: #fff !important; +} + +.layout-tv .metadata-container.show-focus:focus, +.layout-tv button.metadata-container.emby-button.show-focus:focus, +.layout-tv button.metadata-container.emby-button.show-focus:focus:not(.skip), +.layout-tv button.metadata-container.emby-button:focus { + border-radius: 0.2em; + outline: 2px solid var(--theme-primary-color, #00a4dc) !important; + outline-offset: 2px; + background: transparent !important; + box-shadow: none !important; + color: inherit !important; + /* Override Jellyfin emby-button TV focus scale (block is too large to grow) */ + transform: none !important; + -webkit-transform: none !important; +} + +.layout-tv .metadata-container[data-metadata-drilled="true"] a.spotlight-genre-link.show-focus:focus, +.layout-tv .metadata-container[data-metadata-drilled="true"] a.spotlight-person-link.show-focus:focus, +.layout-tv .metadata-container[data-metadata-drilled="true"] a.spotlight-studio-link.show-focus:focus { + border-radius: 0.15em; + padding: 0.1em 0.25em; +} + +/* Transparent card formats: visible TV focus ring (cardBox chrome is forced transparent) */ +.layout-tv .emby-scroller-container[data-card-format="Logo"] .card.show-focus:focus .cardScalable, +.layout-tv .emby-scroller-container[data-card-format="Clear Art"] .card.show-focus:focus .cardScalable, +.layout-tv .emby-scroller-container[data-card-format="Disc"] .card.show-focus:focus .cardScalable, +.layout-tv .emby-scroller-container[data-section-id="liveTv-channels"] .card.show-focus:focus .cardScalable, +.layout-tv .emby-scroller-container[data-card-format="Logo"] .cardButton-tv.show-focus:focus .cardScalable, +.layout-tv .emby-scroller-container[data-card-format="Clear Art"] .cardButton-tv.show-focus:focus .cardScalable, +.layout-tv .emby-scroller-container[data-card-format="Disc"] .cardButton-tv.show-focus:focus .cardScalable { + outline: 3px solid #fff; + outline-offset: 2px; + border-radius: 2px; +} + +/* --- JMP / no flex-gap: scoped margin fallbacks --- */ +html.no-flex-gap .spotlight-section:not(.hide) .spotlight-metadata-row { + gap: 0; +} +html.no-flex-gap .spotlight-section:not(.hide) .spotlight-metadata-row > * + * { + margin-left: 12px !important; +} +html.no-flex-gap .spotlight-section:not(.hide) .metadata-container { + gap: 0; +} +html.no-flex-gap .spotlight-section:not(.hide) .metadata-container > * + * { + margin-top: 8px !important; +} +html.no-flex-gap .spotlight-section:not(.hide) .spotlight-overlay { + gap: 0; +} +html.no-flex-gap .spotlight-section:not(.hide) .spotlight-overlay > * + * { + margin-top: 1em !important; +} +html.no-flex-gap .spotlight-section:not(.hide) .spotlight-buttons-container, +html.no-flex-gap .spotlight-section:not(.hide) .mainDetailButtons.spotlight-buttons-container { + gap: 0; +} +html.no-flex-gap .spotlight-section:not(.hide) .spotlight-buttons-container > * + *, +html.no-flex-gap .spotlight-section:not(.hide) .mainDetailButtons.spotlight-buttons-container > * + * { + margin-left: 2em !important; +} +html.no-flex-gap .spotlight-section:not(.hide) .section-controls, +html.no-flex-gap .spotlight-section:not(.hide) .section-controls-inline { + gap: 0; +} +html.no-flex-gap .spotlight-section:not(.hide) .section-controls-inline > * + * { + margin-left: 0.15em !important; +} +html.no-flex-gap .spotlight-section:not(.hide) .custom-scroller-container .sectionTitleContainer, +html.no-flex-gap .spotlight-section:not(.hide) .emby-scroller-container .sectionTitleContainer { + gap: 0; +} +html.no-flex-gap .spotlight-section:not(.hide) .sectionTitleContainer > * + * { + margin-left: 0.35em !important; +} +html.no-flex-gap .spotlight-section:not(.hide) .spotlight-nav-buttons-container { + gap: 0; +} +html.no-flex-gap .spotlight-section:not(.hide) .spotlight-nav-buttons-container > * + * { + margin-left: 8px !important; +} +html.no-flex-gap .itemsContainer:not([data-gapless="true"]) > * + * { + margin-left: 1em !important; +} + +html.no-flex-gap .emby-scroller-container[data-card-format="Logo"]:not([data-border-style]) .itemsContainer:not([data-gapless="true"]) > * + *, +html.no-flex-gap .emby-scroller-container[data-card-format="Clear Art"]:not([data-border-style]) .itemsContainer:not([data-gapless="true"]) > * + *, +html.no-flex-gap .emby-scroller-container[data-card-format="Disc"]:not([data-border-style]) .itemsContainer:not([data-gapless="true"]) > * + * { + margin-left: 4em !important; +} + +html.layout-tv .spotlight-section:not([data-layout="Borderless"]) .spotlight-overlay { + gap: 20px; + padding: 130px 40px 45px 40px !important; +} +html.layout-tv .spotlight-section[data-layout="Borderless"] .spotlight-overlay { + padding-top: 130px !important; +} + +html.layout-tv .spotlight-section .spotlight-overlay span, +html.layout-tv .spotlight-section .spotlight-overlay p { + font-size: 1em; +} + +html.layout-tv .spotlight-section .spotlight-overlay .spotlight-truncated-list, +html.layout-tv .spotlight-section .spotlight-overlay .spotlight-metadata-row { + font-size: revert; +} + +html.layout-tv .spotlight-section .spotlight-overview { + display: block !important; +} + +html.layout-tv .spotlight-section .spotlight-metadata-section { + display: none !important; +} + +html.layout-tv .spotlight-section .spotlight-overlay .spotlight-overview { + max-width: unset; +} + +html.layout-tv .spotlight-section:not([data-size="full"]) .spotlight-overlay .spotlight-overview { + -webkit-line-clamp: 2; +} + +html.layout-tv .spotlight-section .spotlight-overlay { + grid-template-rows: 1fr auto auto; + grid-template-areas: + "logo" + "overview" + "buttons"; +} + +html.layout-tv .spotlight-section .spotlight-header-container { + top: 45px !important; +} + +html.layout-tv .spotlight-section .spotlight-overview { + display: block !important; +} + +html.layout-tv .spotlight-section .spotlight-metadata-section { + display: none !important; +} + +html.layout-tv .spotlight-section .spotlight-tagline { + display: none !important; +} + +/* --- Variant Row Layouts --- */ + +.rows-2.focus-1 { + --r2-gap: 1.2em; + --r2-small: 16.5vw; + --r2-focus: calc(var(--r2-small) * 2 + var(--r2-gap) * 2); +} + +.rows-2.focus-1.emby-scroller-container[data-card-format="Logo"], +.rows-2.focus-1.emby-scroller-container[data-card-format="Clear Art"] { + --r2-gap: 5em; +} + +.rows-2.focus-1.emby-scroller-container[data-card-format="Poster"], +.rows-2.focus-1.emby-scroller-container[data-card-format="Series Poster"], +.rows-2.focus-1.emby-scroller-container[data-card-format="Default"] { + --r2-small: 6.2vw; +} + +.rows-2.focus-1.emby-scroller-container[data-card-format="Square"], +.rows-2.focus-1.emby-scroller-container[data-card-format="Disc"] { + --r2-small: 9.3vw; +} + +.rows-2.focus-1.emby-scroller-container[data-card-format="Banner"] { + --r2-small: 28vw; +} + +@media (max-width: 900px) { + .rows-2.focus-1 { + --r2-gap: 0.4em; + --r2-small: 38.5vw; + --r2-focus: calc(var(--r2-small) * 2 + var(--r2-gap) * 2); + } + + .rows-2.focus-1.emby-scroller-container[data-card-format="Poster"], + .rows-2.focus-1.emby-scroller-container[data-card-format="Series Poster"], + .rows-2.focus-1.emby-scroller-container[data-card-format="Default"] { + --r2-small: 25vw; + } + + .rows-2.focus-1.emby-scroller-container[data-card-format="Square"], + .rows-2.focus-1.emby-scroller-container[data-card-format="Disc"] { + --r2-small: 32vw; + } + + .rows-2.focus-1.emby-scroller-container[data-card-format="Banner"] { + --r2-small: 55vw; + } +} + +.rows-2.focus-1.emby-scroller-container[data-card-format] .itemsContainer { + display: grid !important; + grid-template-rows: repeat(2, auto); + grid-auto-flow: column; + grid-auto-columns: var(--r2-small); + grid-template-columns: var(--r2-focus); + gap: var(--r2-gap) !important; + align-items: end; + white-space: normal; +} + +.rows-2.focus-1 .sectionTitleContainer { + margin-bottom: 0.6em; +} + +.rows-2.focus-1 .itemsContainer > .card { + width: var(--r2-small) !important; + min-width: 0; + max-width: none; + margin: 0; + padding: 0; + contain: none; +} + +.rows-2.focus-1 .itemsContainer > .card:first-child { + grid-row: 1 / span 2; + width: auto !important; /* outer track; box grows via --r2-focus */ + overflow: visible; + height: 100%; +} + +.rows-2.focus-1 .itemsContainer > .card > .cardBox { + position: relative; + margin: 0 !important; +} + +.rows-2.focus-1 .itemsContainer > .card:first-child > .cardBox { + width: var(--r2-focus); + contain: none !important; + overflow: visible !important; + height: 100%; + display: grid; + grid-template-rows: 1fr auto; +} + +.rows-2.focus-1 .itemsContainer > .card:first-child .cardScalable { + height: 100%; + min-height: 0; +} +.rows-2.focus-1 .itemsContainer > .card:first-child .cardPadder { + background-color: transparent !important; + height: 100%; +} +.rows-2.focus-1 .itemsContainer > .card:first-child .cardImageContainer, +.rows-2.focus-1 .itemsContainer > .card:first-child .coveredImage { + position: absolute; + inset: 0; + height: 100% !important; + width: 100% !important; +} + +.rows-2.focus-1.divider-1 .itemsContainer > .card:first-child .cardBox::after { + content: ""; + position: absolute; + top: 0; + right: -0.18em; + border: 1px solid #e1e1e16b; + height: 100%; + border-radius: 5px; +} + +.rows-2.focus-1.divider-1 .itemsContainer > .card:first-child .cardScalable { + width: calc(100% - var(--r2-gap)); +} + +.rows-2.focus-1.text-overlay-1 .itemsContainer > .card:first-child .cardText { + position: static; + margin: 0; + padding: 0; + max-width: 100%; + box-sizing: border-box; + color: inherit; + overflow: visible; + text-overflow: ellipsis; + white-space: nowrap; + background: none; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.75), 0 0 10px rgba(0, 0, 0, 0.45); + font-size: 1.5rem; + line-height: 1.5rem; + white-space: pre-line; + border-radius: 0 !important; +} + +.rows-2.focus-1.text-overlay-1 .itemsContainer > .card:first-child .cardTextStack { + position: absolute; + z-index: 2; + left: 0; + right: 0; + width: 100%; + max-width: none; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25em; + margin: 0; + padding: 1.35em 0.85em; + box-sizing: border-box; + pointer-events: none; + color: var(--kefin-card-title-color, #fff); + overflow: hidden; + bottom: 0; +} + + /* --- End Variant Row Layouts --- */ + +[data-active-page="home"] #homeTab { + margin-top: 1em; +} \ No newline at end of file diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index d5e26c3..3414400 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -2,9 +2,69 @@ // This module provides a main entry point function to build Jellyfin cards // Usage: window.cardBuilder.buildCard(jellyfinItem) -(function() { +(function () { 'use strict'; - + + // Global toggle for progressive section fade-in. + // Developers can set this to false to disable the behavior. + const FADE_IN_SECTIONS = true; + + // Detect flex gap after DOM is ready (JMP/Qt often lacks flex gap). + // ensureNoFlexGapClass is a function declaration (hoisted in this IIFE). + function scheduleFlexGapDetect() { + if (!document.body) { + document.addEventListener('DOMContentLoaded', () => ensureNoFlexGapClass(), { once: true }); + return; + } + ensureNoFlexGapClass(); + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => ensureNoFlexGapClass(), { once: true }); + } else { + scheduleFlexGapDetect(); + } + + // Sliding window: render current slide + 4 prev + 4 next (9 total). As user navigates, remove/add slides. + const SPOTLIGHT_WINDOW_PREV = 4; + const SPOTLIGHT_WINDOW_NEXT = 4; + + const state = { + defaultCardBackgroundNumber: 1, + useBlurhash: true, + watchlist: false, + }; + + /** In-memory scroller metrics (avoid CSS var style writes for JS-only caches). */ + const scrollerMetricsByEl = new WeakMap(); + const cardPaddingByEl = new WeakMap(); + + function getScrollerMetrics(scroller) { + if (!scroller) return null; + let m = scrollerMetricsByEl.get(scroller); + if (!m) { + m = { scrollX: 0, pad: null, cardPad: null, maxScroll: null }; + scrollerMetricsByEl.set(scroller, m); + } + return m; + } + + function invalidateScrollerMetrics(scroller) { + if (!scroller) return; + scrollerMetricsByEl.delete(scroller); + } + + function getSpotlightWindowIndices(currentIndex, totalLength) { + if (totalLength <= 9) { + return Array.from({ length: totalLength }, (_, i) => i); + } + const indices = new Set(); + for (let i = -SPOTLIGHT_WINDOW_PREV; i <= SPOTLIGHT_WINDOW_NEXT; i++) { + const idx = (currentIndex + i + totalLength) % totalLength; + indices.add(idx); + } + return Array.from(indices); + } + /** * Helper function to shuffle array (Fisher-Yates) */ @@ -16,7 +76,652 @@ } return arr; } - + + /** Element.replaceChildren polyfill (missing in JMP Qt5 WebEngine / Chromium ~83). */ + function replaceChildren(node, ...nodes) { + if (!node) return; + if (typeof node.replaceChildren === 'function') { + node.replaceChildren(...nodes); + return; + } + while (node.firstChild) node.removeChild(node.firstChild); + for (const child of nodes) { + if (child == null) continue; + node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child); + } + } + + /** Cream deco border styles that need injected child pieces on `.cardBorder`. */ + const CARD_BORDER_DECO_CHILDREN = { + triple: ['cardBorder-tall', 'cardBorder-mid', 'cardBorder-wide'], + stairstep: [ + 'cardBorder-outer-left', + 'cardBorder-outer-right', + 'cardBorder-mid-left', + 'cardBorder-mid-right', + 'cardBorder-mid-top', + 'cardBorder-mid-bottom', + 'cardBorder-inner-top', + 'cardBorder-inner-bottom', + ], + square: [ + 'cardBorder-inner', + 'cardBorder-square-left-top', + 'cardBorder-square-left-bottom', + 'cardBorder-square-right-top', + 'cardBorder-square-right-bottom', + 'cardBorder-tall', + 'cardBorder-wide', + ], + diamond: [ + 'cardBorder-square-left-top', + 'cardBorder-square-left-bottom', + 'cardBorder-square-right-top', + 'cardBorder-square-right-bottom', + 'cardBorder-tall-outer', + 'cardBorder-tall-inner', + 'cardBorder-wide-outer', + 'cardBorder-wide-inner', + ], + corner: [ + 'cardBorder-corner cardBorder-corner-tl', + 'cardBorder-corner cardBorder-corner-tr', + 'cardBorder-corner cardBorder-corner-bl', + 'cardBorder-corner cardBorder-corner-br', + ], + 'picture-frame': ['cardBorder-pf-inner'], + }; + + /** Legacy art-deco / slim-fancy / vintage-wide prefs map onto current equivalents. */ + const CARD_BORDER_ALIASES = { + 'art-deco': 'stairstep', + 'art-deco-2': 'square', + 'slim-fancy': 'double', + 'vintage-wide': 'vintage', + }; + + const CARD_BORDER_CSS_ONLY = new Set(['fancy', 'slim', 'double', 'modern-poster', 'film-reel', 'profile', 'wood', 'art-gallery']); + + function normalizeCardBorderStyle(style) { + if (!style) return ''; + return CARD_BORDER_ALIASES[style] || style; + } + + function getCardBorderAssetUrl(filename) { + const root = String(window.KefinTweaksConfig?.kefinTweaksRoot || '').replace(/\/$/, ''); + return `${root}/pages/images/${filename}`; + } + + function getRetroPosterMarqueeLabel(item) { + if (item?.PremiereDate) { + const premiere = new Date(item.PremiereDate); + if (!Number.isNaN(premiere.getTime()) && premiere.getTime() > Date.now()) { + return 'COMING SOON'; + } + } + return 'NOW PLAYING'; + } + + function createCardBorderHost() { + const host = document.createElement('div'); + host.className = 'cardBorder'; + host.setAttribute('aria-hidden', 'true'); + return host; + } + + function ensureRetroPosterMarquee(scalable) { + if (!scalable) return; + const card = scalable.closest('.card'); + let marquee = scalable.querySelector(':scope > .retroPosterMarquee'); + if (!marquee) { + marquee = document.createElement('div'); + marquee.className = 'retroPosterMarquee'; + marquee.setAttribute('aria-hidden', 'true'); + const label = document.createElement('span'); + label.className = 'retroPosterMarquee-label'; + marquee.appendChild(label); + const border = scalable.querySelector(':scope > .cardBorder'); + if (border) scalable.insertBefore(marquee, border); + else scalable.appendChild(marquee); + } + const labelEl = marquee.querySelector('.retroPosterMarquee-label'); + if (labelEl) { + labelEl.textContent = card?.dataset.retroMarquee || 'NOW PLAYING'; + } + } + + function removeRetroPosterMarquee(scalable) { + scalable?.querySelector(':scope > .retroPosterMarquee')?.remove(); + } + + function fillVintageBorder(host) { + host.style.setProperty('--v-corner-deco', `url("${getCardBorderAssetUrl("corner-decoration.svg")}")`); + + const outer = document.createElement('div'); + outer.className = 'cardBorder-v-outer'; + const mid = document.createElement('div'); + mid.className = 'cardBorder-v-mid'; + const inner = document.createElement('div'); + inner.className = 'cardBorder-v-inner'; + + ['tl', 'tr', 'br', 'bl'].forEach((pos) => { + const corner = document.createElement('div'); + corner.className = `cardBorder-v-corner cardBorder-v-corner-${pos}`; + inner.appendChild(corner); + }); + + mid.appendChild(inner); + outer.appendChild(mid); + host.appendChild(outer); + } + + const SVG_NS = 'http://www.w3.org/2000/svg'; + /** Target px between bulb centers along each edge. */ + const RP_BULB_PITCH = 16; + const RP_BULB_LAYERS = [ + { cls: 'cardBorder-rp-bulb-glow', r: 5.5 }, + { cls: 'cardBorder-rp-bulb-mid', r: 3.5 }, + { cls: 'cardBorder-rp-bulb-core', r: 1.75 }, + ]; + const RP_DEFAULT_THIN = 2; + const RP_DEFAULT_BAND = 16; + /** Cards to sync bulbs for per animation frame (keeps style switches snappy). */ + const RP_SYNC_BATCH = 3; + + let rpBulbSyncQueue = []; + let rpBulbSyncRaf = 0; + + function disconnectRetroPosterBulbs(host) { + if (!host) return; + if (host._rpRoRaf) { + cancelAnimationFrame(host._rpRoRaf); + host._rpRoRaf = 0; + } + if (host._rpBulbRo) { + host._rpBulbRo.disconnect(); + host._rpBulbRo = null; + } + host._rpBulbPending = false; + host._rpBulbW = null; + host._rpBulbH = null; + if (rpBulbSyncQueue.length) { + rpBulbSyncQueue = rpBulbSyncQueue.filter((svg) => svg?.parentElement !== host); + } + } + + function getRetroPosterBandMetrics(sectionEl) { + if (sectionEl?._rpBandMetrics) return sectionEl._rpBandMetrics; + let thin = RP_DEFAULT_THIN; + let band = RP_DEFAULT_BAND; + try { + const cs = getComputedStyle(sectionEl || document.documentElement); + const t = parseFloat(cs.getPropertyValue('--rp-thin')); + const b = parseFloat(cs.getPropertyValue('--rp-band')); + if (Number.isFinite(t) && t > 0) thin = t; + if (Number.isFinite(b) && b > 0) band = b; + } catch { + /* keep defaults */ + } + const metrics = { thin, band }; + if (sectionEl) sectionEl._rpBandMetrics = metrics; + return metrics; + } + + function enqueueRetroPosterBulbSync(svg) { + if (!svg?.isConnected) return; + const host = svg.parentElement; + if (!host || host._rpBulbPending) return; + host._rpBulbPending = true; + rpBulbSyncQueue.push(svg); + if (!rpBulbSyncRaf) { + rpBulbSyncRaf = requestAnimationFrame(flushRetroPosterBulbSyncQueue); + } + } + + function flushRetroPosterBulbSyncQueue() { + rpBulbSyncRaf = 0; + let n = 0; + while (n < RP_SYNC_BATCH && rpBulbSyncQueue.length) { + const svg = rpBulbSyncQueue.shift(); + const host = svg?.parentElement; + if (host) host._rpBulbPending = false; + if (svg?.isConnected && host?.dataset?.style === 'retro-poster') { + syncRetroPosterBulbs(svg); + } + n += 1; + } + if (rpBulbSyncQueue.length) { + rpBulbSyncRaf = requestAnimationFrame(flushRetroPosterBulbSyncQueue); + } + } + + function syncRetroPosterBulbs(svg) { + const host = svg?.parentElement; + if (!host) return; + const w = host.clientWidth; + const h = host.clientHeight; + if (w < 8 || h < 8) return; + + // Skip rebuild when size unchanged and bulbs already exist + if (host._rpBulbW === w && host._rpBulbH === h && svg.querySelector('circle')) return; + host._rpBulbW = w; + host._rpBulbH = h; + + const sectionEl = host.closest('[data-border], [data-border-style]'); + const { thin, band } = getRetroPosterBandMetrics(sectionEl); + const mid = thin + band / 2; + const x1 = mid; + const y1 = mid; + const x2 = w - mid; + const y2 = h - mid; + const rw = Math.max(0, x2 - x1); + const rh = Math.max(0, y2 - y1); + + svg.setAttribute('viewBox', `0 0 ${w} ${h}`); + + /* Independent edge spacing so each side has a corner bulb + even steps between. */ + const kW = Math.max(1, Math.round(rw / RP_BULB_PITCH)); + const kH = Math.max(1, Math.round(rh / RP_BULB_PITCH)); + const sW = rw / kW; + const sH = rh / kH; + + const points = [ + [x1, y1], + [x2, y1], + [x2, y2], + [x1, y2], + ]; + for (let i = 1; i < kW; i++) { + points.push([x1 + i * sW, y1], [x1 + i * sW, y2]); + } + for (let i = 1; i < kH; i++) { + points.push([x1, y1 + i * sH], [x2, y1 + i * sH]); + } + + RP_BULB_LAYERS.forEach(({ cls, r }) => { + const g = svg.querySelector(`g.${cls}`); + if (!g) return; + const frag = document.createDocumentFragment(); + points.forEach(([cx, cy]) => { + const circle = document.createElementNS(SVG_NS, 'circle'); + circle.setAttribute('cx', String(cx)); + circle.setAttribute('cy', String(cy)); + circle.setAttribute('r', String(r)); + frag.appendChild(circle); + }); + replaceChildren(g, frag); + }); + } + + function fillRetroPosterBorder(host) { + disconnectRetroPosterBulbs(host); + + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('class', 'cardBorder-rp-bulbs'); + svg.setAttribute('aria-hidden', 'true'); + + RP_BULB_LAYERS.forEach(({ cls }) => { + const g = document.createElementNS(SVG_NS, 'g'); + g.setAttribute('class', cls); + svg.appendChild(g); + }); + + host.appendChild(svg); + + // Coalesce RO storms; do not sync synchronously on attach (causes border-switch hitch). + const ro = new ResizeObserver(() => { + if (host._rpRoRaf) return; + host._rpRoRaf = requestAnimationFrame(() => { + host._rpRoRaf = 0; + host._rpBulbW = null; + host._rpBulbH = null; + enqueueRetroPosterBulbSync(svg); + }); + }); + ro.observe(host); + host._rpBulbRo = ro; + enqueueRetroPosterBulbSync(svg); + } + + function fillCardBorder(host, style) { + if (!host) return; + const next = normalizeCardBorderStyle(style); + const isVintage = next === 'vintage'; + const isRetro = next === 'retro-poster'; + const needsChildren = next && !CARD_BORDER_CSS_ONLY.has(next) && (isVintage || isRetro || !!CARD_BORDER_DECO_CHILDREN[next]); + if (host.dataset.style === next) { + const vintageNeedsRefresh = isVintage && !host.style.getPropertyValue('--v-corner-deco'); + if (!vintageNeedsRefresh && (!needsChildren || host.childElementCount > 0)) return; + } + disconnectRetroPosterBulbs(host); + host.dataset.style = next; + replaceChildren(host); + if (!needsChildren) return; + + if (isVintage) { + fillVintageBorder(host); + return; + } + + if (isRetro) { + fillRetroPosterBorder(host); + return; + } + + for (const cls of CARD_BORDER_DECO_CHILDREN[next]) { + const piece = document.createElement('div'); + piece.className = cls; + host.appendChild(piece); + } + } + + /** + * Sync `.cardBorder` hosts under a section from its data-border / data-border-style. + * Creates hosts when missing; removes them when border is cleared. + */ + function ensureCardBorders(sectionEl) { + if (!sectionEl) return; + const raw = sectionEl.dataset.border || sectionEl.dataset.borderStyle || ''; + const style = normalizeCardBorderStyle(raw); + if (style && style !== raw) { + sectionEl.dataset.border = style; + sectionEl.dataset.borderStyle = style; + } + // Refresh cached band metrics when (re)applying so CSS vars stay current + delete sectionEl._rpBandMetrics; + + if (style === 'wood') { + sectionEl.style.setProperty('--wood-vert-img', `url("${getCardBorderAssetUrl("wood.png")}")`); + } else { + sectionEl.style.removeProperty('--wood-vert-img'); + } + const cards = sectionEl.querySelectorAll('.card .cardScalable'); + cards.forEach((scalable) => { + let host = scalable.querySelector(':scope > .cardBorder'); + if (!style) { + if (host) { + disconnectRetroPosterBulbs(host); + host.remove(); + } + removeRetroPosterMarquee(scalable); + return; + } + if (!host) { + host = createCardBorderHost(); + scalable.appendChild(host); + } + fillCardBorder(host, style); + if (style === 'retro-poster') ensureRetroPosterMarquee(scalable); + else removeRetroPosterMarquee(scalable); + }); + } + + /** Skin-wide card corner radius; measured once and exposed as --kefin-card-radius. */ + let cachedCardRadius = null; + + const SQUARE_CARD_BORDER_STYLES = new Set([ + 'retro-poster', + 'modern-poster', + 'picture-frame', + 'art-gallery', + 'wood', + 'profile', + 'film-reel', + ]); + + function sectionHasSquareCardBorder(el) { + const section = el?.closest?.('[data-border], [data-border-style]'); + if (!section) return false; + const style = section.dataset.borderStyle || section.dataset.border || ''; + return SQUARE_CARD_BORDER_STYLES.has(style); + } + + function getCardRadius() { + if (cachedCardRadius != null) return cachedCardRadius; + + const candidates = document.querySelectorAll('.card .cardScalable, .card .cardContent, .card'); + let sample = null; + for (const el of candidates) { + if (!sectionHasSquareCardBorder(el)) { + sample = el; + break; + } + } + + if (!sample) return '0px'; + + const style = getComputedStyle(sample); + let radius = (style.borderBottomLeftRadius || style.borderRadius || '').trim(); + if (!radius || radius === '0' || radius === '0px') { + const themeRound = ( + style.getPropertyValue('--theme-roundness') || + getComputedStyle(document.documentElement).getPropertyValue('--theme-roundness') || + '' + ).trim(); + radius = themeRound || radius || '0px'; + } + + cachedCardRadius = radius; + return cachedCardRadius; + } + + function ensureCardRadiusCssVar() { + document.documentElement.style.setProperty('--kefin-card-radius', getCardRadius()); + } + + function invalidateCardRadiusCache() { + cachedCardRadius = null; + document.documentElement.style.removeProperty('--kefin-card-radius'); + } + + function applySectionPresentationAttrs(element, sectionConfig, options = {}) { + if (!element || !sectionConfig) return; + const forceBorders = options.forceBorders === true; + + if (sectionConfig.sectionCssClass) { + sectionConfig.sectionCssClass + .split(/\s+/) + .filter(Boolean) + .forEach((cls) => element.classList.add(cls)); + } + + const desiredBorderRaw = sectionConfig.borderStyle || ''; + const desiredBorder = normalizeCardBorderStyle(desiredBorderRaw) || ''; + const currentBorder = normalizeCardBorderStyle(element.dataset.border || element.dataset.borderStyle || '') || ''; + const borderChanged = desiredBorder !== currentBorder; + + const desiredBorderColor = (sectionConfig.borderColor && String(sectionConfig.borderColor).trim()) || ''; + const currentBorderColor = (element.style.getPropertyValue('--kefin-card-border-color') || '').trim(); + const borderColorChanged = desiredBorderColor !== currentBorderColor; + + const desiredTitleColor = (sectionConfig.cardTitleColor && String(sectionConfig.cardTitleColor).trim()) || ''; + const currentTitleColor = (element.style.getPropertyValue('--kefin-card-title-color') || '').trim(); + const titleColorChanged = desiredTitleColor !== currentTitleColor; + + const desiredHideName = sectionConfig.hideName === true || sectionConfig.hideName === 'true'; + const currentHideName = element.dataset.hideSectionName === 'true'; + const hideNameChanged = desiredHideName !== currentHideName; + + const desiredTitleVisibility = sectionConfig.cardTitleVisibility || (sectionConfig.hideCardTitles === true ? 'hidden' : ''); + const currentTitleVisibility = element.dataset.cardTitleVisibility || ''; + const titleVisChanged = + (desiredTitleVisibility && desiredTitleVisibility !== 'visible' ? desiredTitleVisibility : '') !== currentTitleVisibility; + + const desiredTitlePosition = sectionConfig.cardTitlePosition || ''; + const titlePosChanged = (element.dataset.cardTitlePosition || '') !== desiredTitlePosition; + + const desiredCap = sectionConfig.cardTitleCapitalization && sectionConfig.cardTitleCapitalization !== 'normal' + ? sectionConfig.cardTitleCapitalization + : ''; + const capChanged = (element.dataset.cardTitleCapitalization || '') !== desiredCap; + + const desiredFontFamily = + sectionConfig.cardTitleFontFamily && sectionConfig.cardTitleFontFamily !== 'default' + ? sectionConfig.cardTitleFontFamily + : ''; + const fontFamilyChanged = (element.dataset.fontFamily || '') !== desiredFontFamily; + + const desiredFontSize = + sectionConfig.cardTitleFontSize && sectionConfig.cardTitleFontSize !== 'normal' ? sectionConfig.cardTitleFontSize : ''; + const fontSizeChanged = (element.dataset.fontSize || '') !== desiredFontSize; + + const desiredType = sectionConfig.type || ''; + const typeChanged = (element.getAttribute('data-section-type') || '') !== desiredType; + + const desiredCategory = resolveSectionCategory(sectionConfig) || ''; + const categoryChanged = (element.getAttribute('data-category') || '') !== desiredCategory; + + const sectionType = String(sectionConfig.type || '').toLowerCase(); + const isCustomSection = + sectionConfig.isCustom === true || + sectionType === 'custom' || + sectionType === 'custom-discovery' || + String(sectionConfig.id || '').startsWith('custom'); + const isDiscoverySection = + sectionConfig.discoveryEnabled === true || + sectionConfig.discoverySection === true || + sectionType === 'discovery' || + sectionType === 'custom-discovery' || + !!sectionConfig.discoveryType; + const customChanged = (element.dataset.customSection === 'true') !== isCustomSection; + const discoveryChanged = (element.dataset.discoverySection === 'true') !== isDiscoverySection; + + const itemsLayout = resolveItemsLayout(sectionConfig); + const gapless = resolveUseGaplessCards(sectionConfig); + const itemsContainer = element.querySelector('.itemsContainer'); + const layoutChanged = + !itemsContainer || + itemsContainer.getAttribute('data-layout') !== itemsLayout || + (itemsContainer.getAttribute('data-gapless') === 'true') !== gapless; + + const presentationChanged = + borderChanged || + borderColorChanged || + titleColorChanged || + hideNameChanged || + titleVisChanged || + titlePosChanged || + capChanged || + fontFamilyChanged || + fontSizeChanged || + typeChanged || + categoryChanged || + customChanged || + discoveryChanged || + layoutChanged; + + if (!presentationChanged && !forceBorders) return; + + if (borderChanged) { + if (desiredBorder) { + element.dataset.border = desiredBorder; + element.dataset.borderStyle = desiredBorder; + } else { + delete element.dataset.border; + delete element.dataset.borderStyle; + } + } + if (borderColorChanged) { + if (desiredBorderColor) element.style.setProperty('--kefin-card-border-color', desiredBorderColor); + else element.style.removeProperty('--kefin-card-border-color'); + } + if (titleColorChanged) { + if (desiredTitleColor) element.style.setProperty('--kefin-card-title-color', desiredTitleColor); + else element.style.removeProperty('--kefin-card-title-color'); + } + if (hideNameChanged) { + if (desiredHideName) element.dataset.hideSectionName = 'true'; + else delete element.dataset.hideSectionName; + } + delete element.dataset.hideName; + + if (titleVisChanged) { + if (desiredTitleVisibility && desiredTitleVisibility !== 'visible') { + element.dataset.cardTitleVisibility = desiredTitleVisibility; + } else { + delete element.dataset.cardTitleVisibility; + } + } + if (titlePosChanged) { + if (desiredTitlePosition) element.dataset.cardTitlePosition = desiredTitlePosition; + else delete element.dataset.cardTitlePosition; + } + if (capChanged) { + if (desiredCap) element.dataset.cardTitleCapitalization = desiredCap; + else delete element.dataset.cardTitleCapitalization; + } + if (fontFamilyChanged) { + if (desiredFontFamily) element.dataset.fontFamily = desiredFontFamily; + else delete element.dataset.fontFamily; + } + if (fontSizeChanged) { + if (desiredFontSize) element.dataset.fontSize = desiredFontSize; + else delete element.dataset.fontSize; + } + + if (layoutChanged) { + applyItemsLayoutState(element, itemsLayout, gapless); + } + + if (typeChanged) { + if (desiredType) element.setAttribute('data-section-type', desiredType); + else element.removeAttribute('data-section-type'); + } + if (categoryChanged) { + if (desiredCategory) element.setAttribute('data-category', desiredCategory); + else element.removeAttribute('data-category'); + } + if (customChanged) { + if (isCustomSection) element.dataset.customSection = 'true'; + else delete element.dataset.customSection; + } + if (discoveryChanged) { + if (isDiscoverySection) element.dataset.discoverySection = 'true'; + else delete element.dataset.discoverySection; + } + + if (borderChanged || forceBorders) { + ensureCardBorders(element); + } + } + + function resolveSectionCategory(sectionConfig) { + if (!sectionConfig) return 'none'; + const id = String(sectionConfig.id || ''); + if (id.startsWith('pinned-list-') || id.startsWith('pinned-parent-')) return 'pinned'; + const sectionType = String(sectionConfig.type || '').toLowerCase(); + const isDiscovery = + sectionConfig.discoveryEnabled === true || + sectionConfig.discoverySection === true || + sectionType === 'discovery' || + sectionType === 'custom-discovery' || + !!sectionConfig.discoveryType; + if (isDiscovery) return 'discovery'; + return sectionConfig.category || 'none'; + } + + /** + * Updates the scroll buttons for a scrollable container + * @param {HTMLElement} scroller - The scroller element + */ + function updateScrollableContainerScrollButtons(scroller) { + const verticalSection = scroller.parentElement; + if (!verticalSection) return; + + const scrollButtons = verticalSection.querySelector('.emby-scrollbuttons'); + if (!scrollButtons) return; + + const leftButton = scrollButtons.querySelector('button[data-direction="left"]'); + const rightButton = scrollButtons.querySelector('button[data-direction="right"]'); + if (!leftButton || !rightButton) return; + + const pos = getScrollerPosition(scroller); + const maxPosition = getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller)); + leftButton.disabled = pos <= 20; + rightButton.disabled = pos >= maxPosition; + scrollButtons.classList.toggle('hide', leftButton.disabled && rightButton.disabled); + } + /** * Sorts items based on sort order and direction * @param {Array} items - Array of Jellyfin items @@ -26,11 +731,11 @@ */ function sortItems(items, sortOrder = 'Random', sortOrderDirection = 'Ascending') { if (!items || items.length === 0) return items; - + const ascending = sortOrderDirection === 'Ascending'; const sorted = [...items]; - - switch(sortOrder) { + + switch (sortOrder) { case 'Random': return shuffle(sorted); case 'ReleaseDate': @@ -67,7 +772,7 @@ return sorted; } } - + /** * Picks a random card format for a section * @returns {string} - Random card format: 'portrait', 'thumb' @@ -76,21 +781,2272 @@ const formats = ['portrait', 'thumb']; return formats[Math.floor(Math.random() * formats.length)]; } - + + /** + * Map section cardFormat / data-card-format to JF grid column shape. + * Used for CSS grid column tables (no data-grid-shape attribute). + * @param {string|null|undefined} cardFormat + * @returns {'portrait'|'square'|'backdrop'|'banner'} + */ + function resolveGridShape(cardFormat) { + const f = String(cardFormat || '') + .trim() + .toLowerCase(); + if (f === 'banner') return 'banner'; + if (f === 'square' || f === 'sqaure' || f === 'disc') return 'square'; + if ( + f === 'backdrop' || + f === 'thumb' || + f === 'series thumb' || + f === 'logo' || + f === 'clear art' || + f === 'button' + ) { + return 'backdrop'; + } + // Poster, Series Poster, portrait, Random-resolved concrete formats, default + return 'portrait'; + } + + function sectionHasRandomQuerySort(sectionConfig) { + // Treat as random if any query has an undefined/null/non-existent SortBy without a path + if (String(sectionConfig?.sortBy || '').toLowerCase() === 'random') { + return true; + } + + if (sectionConfig?.queries?.some((q) => !q.queryOptions?.SortBy && !q.path)) { + return true; + } + return (sectionConfig?.queries || []).some((q) => String(q.queryOptions?.SortBy || '').toLowerCase() === 'random'); + } + + function flattenSeriesEpisodes(items) { + if (!items || items.length === 0) return items; + + const seenSeries = new Set(); + const result = []; + + for (const item of items) { + if (item.Type === 'Episode') { + // Only keep the first episode seen for a given SeriesId + if (item.SeriesId && !seenSeries.has(item.SeriesId)) { + seenSeries.add(item.SeriesId); + result.push(item); + } + } else { + // Keep non-Episode items as-is, in order + result.push(item); + } + } + + return result; + } + + function formatAirDate(premiereDate) { + if (!premiereDate) return ''; + + // Ignore timezone offset + const date = new Date(premiereDate.replace('Z', '')); + + // Use format: Tues Jan 4 + const options = { weekday: 'short', month: 'short', day: 'numeric' }; + return date.toLocaleDateString('en-US', options); + } + + function countAvailableArtwork(item) { + let count = 0; + const tags = item?.ImageTags; + if (tags && typeof tags === 'object') { + for (const value of Object.values(tags)) { + if (value) count++; + } + } + if (item?.SeriesPrimaryImageTag) count++; + if (item?.ParentBackdropImageTags?.length) count++; + if (item?.ParentThumbImageTag) count++; + if (item?.ParentLogoImageTag) count++; + return count; + } + + function getProviderKeys(item) { + const ids = item?.ProviderIds; + if (!ids) return []; + return [ids.Imdb, ids.Tmdb, ids.Tvdb].filter(Boolean); + } + + function deduplicateItems(items) { + const deduplicated = []; + const idToIndex = new Map(); + + for (const item of items) { + const keys = getProviderKeys(item); + if (keys.length === 0) { + deduplicated.push(item); + continue; + } + + let existingIndex = -1; + for (const key of keys) { + if (idToIndex.has(key)) { + existingIndex = idToIndex.get(key); + break; + } + } + + if (existingIndex === -1) { + const index = deduplicated.length; + deduplicated.push(item); + for (const key of keys) { + idToIndex.set(key, index); + } + continue; + } + + const existing = deduplicated[existingIndex]; + if (countAvailableArtwork(item) > countAvailableArtwork(existing)) { + deduplicated[existingIndex] = item; + } + for (const key of keys) { + idToIndex.set(key, existingIndex); + } + } + + return deduplicated; + } + + /** + * Merges NextUp and ContinueWatching items chronologically by LastPlayedDate + * Preserves NextUp's internal order and assigns LastPlayedDate to items without it + * @param {Array} nextUpItems - Array of Episode items from NextUp endpoint (already ordered) + * @param {Array} continueWatchingItems - Array of Movie items from Continue Watching endpoint + * @returns {Array} - Merged array sorted by LastPlayedDate (descending) + */ + function mergeNextUpAndContinueWatching(nextUpItems, continueWatchingItems) { + if (!nextUpItems || nextUpItems.length === 0) { + return continueWatchingItems || []; + } + if (!continueWatchingItems || continueWatchingItems.length === 0) { + return nextUpItems || []; + } + + // 1. Assign LastPlayedDate to NextUp items without it + // Items without LastPlayedDate inherit the date from the next item that has one + const processedNextUp = [...nextUpItems]; + for (let i = processedNextUp.length - 1; i >= 0; i--) { + const item = processedNextUp[i]; + if (!item.UserData || !item.UserData.LastPlayedDate) { + // Look ahead to find next item with LastPlayedDate + for (let j = i + 1; j < processedNextUp.length; j++) { + if (processedNextUp[j].UserData && processedNextUp[j].UserData.LastPlayedDate) { + // Assign the date from the next item + if (!item.UserData) item.UserData = {}; + item.UserData.LastPlayedDate = processedNextUp[j].UserData.LastPlayedDate; + break; + } + } + // If no date found ahead, assign null (will be placed at end) + if (!item.UserData || !item.UserData.LastPlayedDate) { + if (!item.UserData) item.UserData = {}; + item.UserData.LastPlayedDate = null; + } + } + } + + // 2. Sort ContinueWatching items by LastPlayedDate (descending) + const sortedContinueWatching = [...continueWatchingItems, ...processedNextUp].sort((a, b) => { + const dateA = a.UserData?.LastPlayedDate ? new Date(a.UserData.LastPlayedDate).getTime() : 0; + const dateB = b.UserData?.LastPlayedDate ? new Date(b.UserData.LastPlayedDate).getTime() : 0; + return dateB - dateA; // Descending + }); + + return sortedContinueWatching; + } + + function postProcessItemsByQuery(sectionConfig, queryResults) { + let processed = []; + for (let i = 0; i < queryResults.length; i++) { + const queryConfig = sectionConfig.queries[i]; + const queryResult = queryResults[i]; + const items = queryResult?.Items || queryResult || []; + processed.push(...postProcessItems(queryConfig, items)); + } + + if (sectionConfig.id === 'continueWatchingAndNextUp') { + // Get the movies from the items, and the rest as the episodes, then use mergeNextUpAndContinueWatching to merge them + const movies = processed.filter((item) => item.Type === 'Movie'); + const episodes = processed.filter((item) => item.Type !== 'Movie'); + processed = mergeNextUpAndContinueWatching(episodes, movies); + } + + return processed; + } + + function postProcessItems(sectionConfig, itemsData) { + let processed = itemsData?.Items || itemsData || []; + + //Check if imageTypes is used in the query to ensure only items with these image types are included + const imageTypes = sectionConfig.queries?.[0]?.queryOptions?.ImageTypes; + if (imageTypes && Array.isArray(imageTypes)) { + // Filter out items which do not have any of the image types + processed = processed.filter((item) => { + const itemImageTypes = imageTypes.filter( + (imageType) => item.ImageTags?.[imageType] || (imageType === 'Backdrop' && item.BackdropImageTags?.[0]), + ); + return itemImageTypes.length > 0; + }); + } + + // When ParentId is on the query (e.g. Popular Genres per library), assign it to each item for genre card links + const queryParentId = sectionConfig.queries?.[0]?.ParentId; + if (queryParentId && Array.isArray(processed)) { + processed.forEach((item) => { + item.ParentId = queryParentId; + }); + } + + // Check if the IsUnplayed filter is set on any of the queries + const isUnplayedFilter = sectionConfig.queries?.some( + (query) => query.queryOptions?.IsUnplayed === true || query.queryOptions?.Filters?.includes('IsUnplayed'), + ); + if (isUnplayedFilter) { + processed = processed.filter((item) => item.UserData?.Played === false); + } + + // Check if the IsPlayed filter is set on any of the queries + const isPlayedFilter = sectionConfig.queries?.some( + (query) => query.queryOptions?.IsPlayed === true || query.queryOptions?.Filters?.includes('IsPlayed'), + ); + if (isPlayedFilter) { + processed = processed.filter((item) => item.UserData?.Played === true); + } + + // Check if the IsResumable filter is set on any of the queries + const isResumableFilter = sectionConfig.queries?.some( + (query) => query.queryOptions?.IsResumable === true || query.queryOptions?.Filters?.includes('IsResumable'), + ); + if (isResumableFilter) { + processed = processed.filter( + (item) => item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage !== 100 && item.UserData?.PlayedPercentage > 0, + ); + } + + // Check if the MinPlayCount filter is set on any of the queries + const minPlayCount = sectionConfig.minPlayCount || sectionConfig.queries?.[0]?.queryOptions?.MinPlayCount || 0; + if (minPlayCount > 0) { + processed = processed.filter((item) => item.UserData?.PlayCount && item.UserData?.PlayCount >= minPlayCount); + } + + // Check if the MaxPlayCount filter is set on any of the queries + const maxPlayCount = sectionConfig.maxPlayCount || sectionConfig.queries?.[0]?.queryOptions?.MaxPlayCount || 0; + if (maxPlayCount > 0) { + processed = processed.filter((item) => item.UserData?.PlayCount && item.UserData?.PlayCount <= maxPlayCount); + } + + // Flatten Series Episodes + if (sectionConfig.flattenSeries === true) { + processed = flattenSeriesEpisodes(processed); + } + + if (sectionConfig.type === 'series-episodes' && Array.isArray(processed)) { + processed = [...processed].sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0)); + } + + /* if (sectionConfig.id === 'popularTVNetworks') { + // Sort items randomly + processed = shuffle(processed); + } */ + + if (sectionConfig.id === 'continueWatchingAndNextUp') { + // Get the movies from the items, and the rest as the episodes, then use mergeNextUpAndContinueWatching to merge them + const movies = processed.filter((item) => item.Type === 'Movie'); + const episodes = processed.filter((item) => item.Type !== 'Movie'); + processed = mergeNextUpAndContinueWatching(episodes, movies); + } + + if (sectionConfig.id === 'upcoming') { + // Remove items which aired before today + const today = new Date(); + today.setHours(0, 0, 0, 0); + processed = processed.filter((item) => { + if (!item.PremiereDate || item.LocationType !== 'Virtual') return false; + const premiereDate = new Date(item.PremiereDate); + premiereDate.setHours(0, 0, 0, 0); + return premiereDate >= today; + }); + } + + // Add Custom Secondary Text for Recently Released Movies + if ( + sectionConfig.id === 'recentlyReleased.movies' || + sectionConfig.id === 'upcoming' || + sectionConfig.id === 'recentlyReleased.episodes' + ) { + processed.forEach((item) => { + if (item.PremiereDate) { + item.CustomFooterText = formatAirDate(item.PremiereDate); + } + }); + } + + // Sort items by UserData.PlayedPercentage if PlayedPercentage is set as the SortBy + if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'PlayedPercentage') { + processed = processed.sort((a, b) => { + const playedPercentageA = a.UserData?.PlayedPercentage || 0; + const playedPercentageB = b.UserData?.PlayedPercentage || 0; + return playedPercentageB - playedPercentageA; + }); + + // Remove any items which have no UserData.PlayedPercentage + processed = processed.filter((item) => item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage > 0); + } + + // Check for SortBy ChildCount and use ChildCount to sort the items + // ChildCount = ChildCount + SeriesCount + EpisodeCount + AlbumCount + ArtistCount + ProgramCount + MovieCount + SongCount + if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'ChildCount') { + processed = processed.sort((a, b) => { + const childCountA = + (a.ChildCount || 0) + + (a.SeriesCount || 0) + + (a.EpisodeCount || 0) + + (a.AlbumCount || 0) + + (a.ArtistCount || 0) + + (a.ProgramCount || 0) + + (a.MovieCount || 0) + + (a.SongCount || 0); + const childCountB = + (b.ChildCount || 0) + + (b.SeriesCount || 0) + + (b.EpisodeCount || 0) + + (b.AlbumCount || 0) + + (b.ArtistCount || 0) + + (b.ProgramCount || 0) + + (b.MovieCount || 0) + + (b.SongCount || 0); + + if (sectionConfig.queries?.[0]?.queryOptions?.SortOrder === 'Descending') { + return childCountB - childCountA; + } + return childCountA - childCountB; + }); + } + + const limit = sectionConfig.itemLimit || sectionConfig.queries?.[0]?.queryOptions?.Limit || 0; + + if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'Random' || sectionConfig.sortBy === 'Random') { + if (limit > 0 && sectionConfig.limitBeforeSort === true) { + processed = processed.slice(0, limit); + } + processed = shuffle(processed); + } + + // Apply local limits for non-API sources + if (limit > 0) { + processed = processed.slice(0, limit); + } + + // Deduplicate items based on ProviderIds. Look for match imdb, tmdb or tvdb ids + processed = deduplicateItems(processed); + + return processed; + } + + function getQueryDisplayName(query, index) { + const trimmed = query?.name?.trim?.(); + if (trimmed) return trimmed; + return `Query ${index + 1}`; + } + + function resolveActiveQueryIndex(sectionConfig, options = {}) { + const queries = sectionConfig?.queries || []; + if (queries.length <= 1) return 0; + + const stored = sectionConfig._selectedQueryIndex; + if (Number.isInteger(stored) && stored >= 0 && stored < queries.length) { + return stored; + } + + if (options.initialize !== false) { + let index = 0; + if (sectionConfig.useRandomQuery === true) { + index = Math.floor(Math.random() * queries.length); + } + sectionConfig._selectedQueryIndex = index; + return index; + } + + return 0; + } + + function resolveQueriesToLoad(sectionConfig, options = {}) { + const queries = sectionConfig?.queries || []; + if (queries.length <= 1) return queries; + + if (sectionConfig.useMultiQueryPicker === true) { + const index = resolveActiveQueryIndex(sectionConfig, options); + return [queries[index]]; + } + + if (sectionConfig.useRandomQuery === true) { + const randomIndex = Math.floor(Math.random() * queries.length); + return [queries[randomIndex]]; + } + + return queries; + } + + function getMultiQueryPickerButtonLabel(sectionConfig) { + const customLabel = sectionConfig?.multiQueryPickerLabel?.trim?.(); + if (customLabel) return customLabel; + + const queries = sectionConfig?.queries || []; + if (queries.length <= 1) return getQueryDisplayName(queries[0], 0); + + const index = resolveActiveQueryIndex(sectionConfig, { initialize: false }); + return getQueryDisplayName(queries[index], index); + } + + function getSectionTitleForMultiQuery(sectionConfig) { + if ( + sectionConfig?.useMultiQueryPicker === true && + sectionConfig?.useQueryNamesForSection === true && + (sectionConfig?.queries?.length || 0) > 1 + ) { + const index = resolveActiveQueryIndex(sectionConfig, { initialize: true }); + return getQueryDisplayName(sectionConfig.queries[index], index); + } + return sectionConfig?.name || ''; + } + + function getActiveViewMoreUrl(sectionConfig) { + const queries = sectionConfig?.queries || []; + if (sectionConfig?.useMultiQueryPicker === true && queries.length > 1) { + const index = resolveActiveQueryIndex(sectionConfig, { initialize: true }); + const queryUrl = queries[index]?.viewMoreUrl; + if (queryUrl) return queryUrl; + } + return sectionConfig?.viewMoreUrl || null; + } + + function getSectionCaption(sectionConfig) { + return sectionConfig?.caption?.trim() || null; + } + + function getSectionCaptionUrl(sectionConfig) { + return sectionConfig?.captionUrl || null; + } + + function updateSectionTitleText(sectionElement, titleText, captionText) { + if (!sectionElement) return; + if (titleText != null) { + const titleEl = + sectionElement.querySelector('.sectionTitleContainer .sectionTitle.sectionTitle-cards') || + sectionElement.querySelector('.spotlight-section-title .emby-tab-button'); + if (titleEl) titleEl.textContent = titleText; + } + if (captionText != null) { + const captionEl = sectionElement.querySelector('.sectionTitle.sectionCaption'); + if (captionEl) captionEl.textContent = captionText; + } + } + + function updateSectionViewMoreLink(sectionElement, url) { + if (!sectionElement) return; + const titleLink = + sectionElement.querySelector('a.sectionTitle-link') || + sectionElement.querySelector('.spotlight-section-title a.emby-tab-button'); + if (!titleLink) return; + if (url) { + titleLink.href = url; + return; + } + titleLink.removeAttribute('href'); + } + + /** + * Rebuild title/caption/view-more under an existing sectionTitleContainer without + * wiping .section-controls. Used for discovery-pending → resolved and enhance. + */ + function rebuildSectionTitleInPlace(sectionElement, sectionConfig) { + if (!sectionElement || !sectionConfig) return; + const titleContainer = sectionElement.querySelector('.sectionTitleContainer'); + if (!titleContainer) { + // Spotlight titles live elsewhere — update text/link only + const title = getSectionTitleForMultiQuery(sectionConfig); + const viewMore = getActiveViewMoreUrl(sectionConfig); + updateSectionTitleText(sectionElement, title, getSectionCaption(sectionConfig)); + updateSectionViewMoreLink(sectionElement, viewMore); + return; + } + + const controls = titleContainer.querySelector('.section-controls'); + Array.from(titleContainer.children).forEach((child) => { + if (child.classList?.contains('section-controls')) return; + // Also keep controls nested under sectionTitle-wrapper + if (child.querySelector?.(':scope > .section-controls')) { + const nested = child.querySelector(':scope > .section-controls'); + if (nested) titleContainer.appendChild(nested); + } + child.remove(); + }); + + appendSectionTitleContent(titleContainer, { + title: getSectionTitleForMultiQuery(sectionConfig), + caption: getSectionCaption(sectionConfig), + captionUrl: getSectionCaptionUrl(sectionConfig), + viewMoreUrl: getActiveViewMoreUrl(sectionConfig), + discoveryPending: false, + }); + + if (controls) { + const wrapper = titleContainer.querySelector(':scope > .sectionTitle-wrapper'); + (wrapper || titleContainer).appendChild(controls); + } + } + + function resolveSectionQueryTtl(sectionConfig) { + if (Number(sectionConfig?.ttl) >= 0) return Number(sectionConfig.ttl); + const fromConfig2 = window.KefinHomeConfig2?.CACHE?.DEFAULT_TTL; + if (Number(fromConfig2) >= 0) return Number(fromConfig2); + const fromConfig = window.KefinHomeConfig?.CACHE?.DEFAULT_TTL; + if (Number(fromConfig) >= 0) return Number(fromConfig); + return 60 * 60 * 1000; + } + + function extractItemsFromQueryPayload(data) { + if (!data) return []; + if (Array.isArray(data)) return data; + if (Array.isArray(data.Items)) return data.Items; + return []; + } + + /** + * Cache-aware load of the active query set (multi-query picker switches). + * Respects section TTL — does not force-bypass cache. + * @param {Object} sectionConfig + * @returns {Promise<{ items: Array, canPaintCached: boolean, itemsPromise: Promise }>} + */ + async function loadActiveSectionQueryItems(sectionConfig) { + if (!sectionConfig.queries || !Array.isArray(sectionConfig.queries) || sectionConfig.queries.length === 0) { + console.warn(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} has no queries array`); + return { + items: [], + canPaintCached: false, + itemsPromise: Promise.resolve([]), + }; + } + + const userId = ApiClient.getCurrentUserId(); + const serverUrl = ApiClient.serverAddress(); + const ApiHelper = window.apiHelper; + const results = []; + const queriesToLoad = resolveQueriesToLoad(sectionConfig, { initialize: false }); + const sectionTtl = resolveSectionQueryTtl(sectionConfig); + + for (const query of queriesToLoad) { + let queryResult; + + if (query.dataSource) { + queryResult = await ApiHelper.fetchFromDataSource( + query.dataSource, + query.queryOptions || {}, + true, + query.dataSourceOptions, + ); + } else { + const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight', { + sectionType: sectionConfig.type, + }); + + if (typeof queryUrl === 'string') { + queryResult = await ApiHelper.getQuery(queryUrl, { + useCache: true, + ttl: sectionTtl, + }); + } else { + console.warn(`[KefinTweaks CardBuilder] Invalid query URL for section ${sectionConfig.id}`); + continue; + } + } + + results.push(queryResult); + } + + const resolveFreshItems = async () => { + if (results.length > 1) { + const merged = ApiHelper.mergeMultiQueryResults(results, sectionConfig); + // Kick SWR so _refreshPromise is attached when cache is stale + const raw = + typeof merged.result?.ensureData === 'function' + ? await merged.result.ensureData() + : await (merged.result?.dataPromise ?? merged.result?.data); + + const hasRefresh = results.some((r) => r && r._refreshPromise); + if (hasRefresh) { + const allData = await Promise.all( + results.map((r) => r._refreshPromise || Promise.resolve(r.data)), + ); + const itemsByFreshQuery = []; + allData.forEach((data) => { + if (data?.Items) itemsByFreshQuery.push(data.Items); + else if (Array.isArray(data)) itemsByFreshQuery.push(data); + }); + return postProcessItemsByQuery(sectionConfig, itemsByFreshQuery); + } + + return extractItemsFromQueryPayload(raw); + } + if (results.length === 1) { + const qr = results[0]; + // Kick SWR so _refreshPromise is attached when cache is stale + const raw = + typeof qr.ensureData === 'function' + ? await qr.ensureData() + : await (qr.dataPromise ?? qr.data); + + if (qr._refreshPromise) { + const fresh = await qr._refreshPromise; + return postProcessItems(sectionConfig, extractItemsFromQueryPayload(fresh)); + } + + return postProcessItems(sectionConfig, extractItemsFromQueryPayload(raw)); + } + return []; + }; + + if (results.length > 1) { + const merged = ApiHelper.mergeMultiQueryResults(results, sectionConfig); + const syncItems = extractItemsFromQueryPayload(merged.result?.data); + // Multi-merge is usable only when every source reported a fresh cache hit. + const canPaintCached = results.every((r) => r && r.isStale === false && r.data != null); + return { + items: canPaintCached ? syncItems : [], + canPaintCached, + itemsPromise: canPaintCached ? Promise.resolve(syncItems) : resolveFreshItems(), + }; + } + + if (results.length === 1) { + const qr = results[0]; + const syncItems = postProcessItems(sectionConfig, extractItemsFromQueryPayload(qr?.data)); + // Strict: only paint when apiHelper reported a non-stale cache hit with a payload. + const canPaintCached = qr?.isStale === false && qr?.data != null; + return { + items: canPaintCached ? syncItems : [], + canPaintCached, + itemsPromise: canPaintCached ? Promise.resolve(syncItems) : resolveFreshItems(), + }; + } + + return { + items: [], + canPaintCached: false, + itemsPromise: Promise.resolve([]), + }; + } + + /** + * Refresh section queries by re-executing them without cache + * @param {Object} sectionConfig - Section configuration with queries array + * @returns {Promise} - Processed items array + */ + async function refreshSectionQueries(sectionConfig) { + if (!sectionConfig.queries || !Array.isArray(sectionConfig.queries) || sectionConfig.queries.length === 0) { + console.warn(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} has no queries array`); + return []; + } + + const userId = ApiClient.getCurrentUserId(); + const serverUrl = ApiClient.serverAddress(); + const ApiHelper = window.apiHelper; + const results = []; + const queriesToLoad = resolveQueriesToLoad(sectionConfig, { initialize: false }); + + // Process each query in the active set + for (const query of queriesToLoad) { + let queryResult; + + if (query.dataSource) { + // Handle cache-based data sources + queryResult = await ApiHelper.fetchFromDataSource( + query.dataSource, + query.queryOptions || {}, + false, + query.dataSourceOptions, + ); + } else { + // Build and execute query + const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight', { + sectionType: sectionConfig.type, + }); + + if (typeof queryUrl === 'string') { + // Standard query - use useCache: false to bypass cache + // When useCache is false, getQuery returns a Promise directly + const data = await ApiHelper.getQuery(queryUrl, { + useCache: false, + }); + // Wrap in expected format for consistency + queryResult = { + data: data, + dataPromise: Promise.resolve(data), + isStalePromise: Promise.resolve(false), + }; + } else { + console.warn(`[KefinTweaks CardBuilder] Invalid query URL for section ${sectionConfig.id}`); + continue; + } + } + + results.push(queryResult); + } + + // If multiple queries, merge results using section-level sortBy/sortOrder + let finalResult; + if (results.length > 1) { + finalResult = ApiHelper.mergeMultiQueryResults(results, sectionConfig); + // Extract items from merged result + const items = finalResult.result?.data?.Items || finalResult.result?.data || []; + return items; + } else if (results.length === 1) { + // Single query result + const data = results[0].data || results[0]; + const items = data?.Items || data || []; + return postProcessItems(sectionConfig, items); + } + + return []; + } + + /** + * Toggle data-refreshing (drives refresh-button spin CSS). Manual refresh only — + * do not set true during enhance / stale revalidate / multi-query switch. + * @param {HTMLElement|null|undefined} el + * @param {boolean} refreshing + */ + function setSectionRefreshing(el, refreshing) { + if (!el?.dataset) return; + el.dataset.refreshing = refreshing ? 'true' : 'false'; + } + + /** + * Paper-icon button with Material Icon on a child span (native Jellyfin pattern). + * Keep material-icons off the button — paper-icon-button-light sets font-family: inherit. + */ + function createPaperIconButton({ className = '', icon = '', title = '', ariaLabel = '', showFocus = false } = {}) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `${className} paper-icon-button-light`.trim(); + if (showFocus) button.classList.add('show-focus'); + if (title) button.title = title; + if (ariaLabel || title) button.setAttribute('aria-label', ariaLabel || title); + + const iconSpan = document.createElement('span'); + iconSpan.className = `material-icons ${icon}`.trim(); + iconSpan.setAttribute('aria-hidden', 'true'); + button.appendChild(iconSpan); + return button; + } + + /** + * Show checkmark icon temporarily, then revert to refresh icon + * @param {HTMLElement} button - The refresh button element + */ + async function showRefreshComplete(button) { + // Wait a brief moment for the spinning animation to complete smoothly + // This ensures the transition from spinning to checkmark looks natural + await new Promise((resolve) => requestAnimationFrame(resolve)); + + const icon = button.querySelector('.material-icons') || button; + + // Change to checkmark icon (state classes on button for CSS; glyph on icon span) + button.classList.remove('refresh', 'icon-fade-out'); + button.classList.add('check_circle', 'icon-fade-in'); + icon.classList.remove('refresh', 'icon-fade-out'); + icon.classList.add('check_circle', 'icon-fade-in'); + + await new Promise((resolve) => requestAnimationFrame(resolve)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Wait for fade in to complete + await new Promise((resolve) => setTimeout(resolve, 200)); + button.classList.remove('icon-fade-in'); + icon.classList.remove('icon-fade-in'); + + // After 1.5 seconds, fade out checkmark and fade in refresh icon + setTimeout(async () => { + // Fade out checkmark + button.classList.add('icon-fade-out'); + icon.classList.add('icon-fade-out'); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Change to refresh icon + button.classList.remove('check_circle', 'icon-fade-out'); + button.classList.add('refresh', 'icon-fade-in'); + icon.classList.remove('check_circle', 'icon-fade-out'); + icon.classList.add('refresh', 'icon-fade-in'); + + // Wait for fade in to complete + await new Promise((resolve) => setTimeout(resolve, 200)); + button.classList.remove('icon-fade-in'); + icon.classList.remove('icon-fade-in'); + }, 1500); + } + + /** After an empty section is removed, let discovery infinite-scroll refill lead margin. */ + function notifyDiscoveryLeadFillAfterSectionRemove() { + window.homeScreen3?.scheduleMaybeFillDiscoveryViewport?.(); + } + + async function replaceSectionContent(sectionConfig, sectionElement, freshItems) { + if (freshItems.length === 0) { + sectionElement.remove(); + notifyDiscoveryLeadFillAfterSectionRemove(); + return null; + } + + let finalCardFormat = sectionConfig.cardFormat; + const currentCardFormat = sectionElement.getAttribute('data-card-format'); + if (currentCardFormat) { + finalCardFormat = currentCardFormat; + } + + const displayTitle = getSectionTitleForMultiQuery(sectionConfig); + const activeViewMoreUrl = getActiveViewMoreUrl(sectionConfig); + + if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { + const refreshed = refreshSpotlight(sectionElement, freshItems, { + name: displayTitle, + viewMoreUrl: activeViewMoreUrl, + spotlightConfig: sectionConfig.spotlightConfig, + dataItems: [], + keepCurrentSlide: false, + sectionConfig, + }); + const content = refreshed.content; + ensureCardBorders(content); + setSectionRefreshing(content, false); + attachSectionControlButtons(sectionConfig, content, freshItems); + ensureSectionRevealObservation(content); + return content; + } + + // In-place card refresh: keep section shell + sticky is=emby-itemscontainer + rebuildSectionTitleInPlace(sectionElement, { + ...sectionConfig, + name: displayTitle, + viewMoreUrl: activeViewMoreUrl, + }); + // Ensure title text reflects multi-query resolution even if config.name lags + updateSectionTitleText(sectionElement, displayTitle, getSectionCaption(sectionConfig)); + updateSectionViewMoreLink(sectionElement, activeViewMoreUrl); + + if (!sectionElement.querySelector('.itemsContainer')) { + const content = createSectionScrollableContainer( + freshItems, + displayTitle, + activeViewMoreUrl, + sectionConfig.overflowCard, + finalCardFormat, + sectionConfig, + ); + content.style.cssText = sectionElement.style.cssText; + content.className = sectionElement.className; + Array.from(sectionElement.attributes).forEach((attr) => { + content.setAttribute(attr.name, attr.value); + }); + stripCopiedSkeletonSectionClasses(content); + unobserveSkeletonShimmer(sectionElement); + sectionElement.replaceWith(content); + ensureCardBorders(content); + attachSectionControlButtons(sectionConfig, content, freshItems); + setSectionRefreshing(content, false); + registerLazyImagesInSection(content); + ensureSectionRevealObservation(content); + return content; + } + + replaceItemsContainerContents(sectionElement, freshItems, { + ...sectionConfig, + cardFormat: finalCardFormat, + }); + unobserveSkeletonShimmer(sectionElement); + setSectionRefreshing(sectionElement, false); + ensureSectionRevealObservation(sectionElement); + return sectionElement; + } + + /** + * Immediately swap section body to skeleton cards while a query fetch is in flight. + * @returns {HTMLElement} The skeleton section element now in the DOM + */ + function showSectionQuerySkeletons(sectionConfig, sectionElement) { + let finalCardFormat = sectionConfig.cardFormat; + const currentCardFormat = sectionElement.getAttribute('data-card-format'); + if (currentCardFormat) { + finalCardFormat = currentCardFormat; + } + if (finalCardFormat === 'random' || finalCardFormat === 'Random') { + finalCardFormat = getRandomCardFormat(); + } + + const displayTitle = getSectionTitleForMultiQuery(sectionConfig); + const activeViewMoreUrl = getActiveViewMoreUrl(sectionConfig); + + let skeleton; + if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { + const spotlightSettings = { ...(sectionConfig.spotlightConfig || {}) }; + if (activeViewMoreUrl) spotlightSettings.viewMoreUrl = activeViewMoreUrl; + spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true; + spotlightSettings.caption = sectionConfig.caption; + skeleton = createSkeletonSpotlightSection(displayTitle, spotlightSettings); + } else { + skeleton = createProgressivelyEnhancedScrollableContainer( + displayTitle, + activeViewMoreUrl, + finalCardFormat, + sectionConfig.overflowCard, + sectionConfig, + ); + } + + skeleton.style.cssText = sectionElement.style.cssText; + skeleton.className = sectionElement.className; + Array.from(sectionElement.attributes).forEach((attr) => { + skeleton.setAttribute(attr.name, attr.value); + }); + if (finalCardFormat) { + skeleton.setAttribute('data-card-format', finalCardFormat); + } + applySectionPresentationAttrs(skeleton, sectionConfig); + // Preserve data-refreshing from the replaced section if already set (manual refresh); + // do not force true here — multi-query / enhance must not spin the button. + + const oldItemsContainer = sectionElement.querySelector('.itemsContainer'); + const layoutFromDom = oldItemsContainer?.getAttribute('data-layout'); + const layout = layoutFromDom === 'grid' || layoutFromDom === 'row' ? layoutFromDom : resolveItemsLayout(sectionConfig); + const gapless = oldItemsContainer + ? oldItemsContainer.getAttribute('data-gapless') === 'true' + : resolveUseGaplessCards(sectionConfig); + + sectionElement.replaceWith(skeleton); + applyItemsLayoutState(skeleton, layout, gapless); + attachSectionControlButtons(sectionConfig, skeleton, []); + ensureSectionRevealObservation(skeleton); + observeSkeletonShimmer(skeleton); + return skeleton; + } + + function attachMultiQueryPicker(sectionConfig, sectionElement, titleContainer) { + const queries = sectionConfig?.queries || []; + if (queries.length <= 1 || sectionConfig.useMultiQueryPicker !== true) { + titleContainer.querySelectorAll('.multi-query-picker-button').forEach((btn) => btn.remove()); + return; + } + + titleContainer.querySelectorAll('.multi-query-picker-button').forEach((btn) => btn.remove()); + + resolveActiveQueryIndex(sectionConfig, { initialize: true }); + updateSectionTitleText(sectionElement, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig)); + updateSectionViewMoreLink(sectionElement, getActiveViewMoreUrl(sectionConfig)); + + const selectButton = document.createElement('button'); + selectButton.type = 'button'; + selectButton.className = 'emby-button raised multi-query-picker-button'; + if (isTvLayout()) selectButton.classList.add('show-focus'); + selectButton.textContent = getMultiQueryPickerButtonLabel(sectionConfig); + selectButton.style.cssText = 'margin-left: 1em; padding: 0.5em 1em; font-size: 0.9em;'; + selectButton.setAttribute('aria-label', 'Select query'); + + const controlsMount = titleContainer.querySelector('.section-controls'); + const titleLink = titleContainer.querySelector('a.sectionTitle-link'); + const sectionTitle = titleContainer.querySelector('.sectionTitle'); + // controls may live under .sectionTitle-wrapper when a caption is present + if (controlsMount?.parentNode) { + controlsMount.parentNode.insertBefore(selectButton, controlsMount); + } else if (titleLink?.parentNode) { + titleLink.parentNode.insertBefore(selectButton, titleLink.nextSibling); + } else if (sectionTitle?.parentNode) { + sectionTitle.parentNode.insertBefore(selectButton, sectionTitle.nextSibling); + } else { + titleContainer.appendChild(selectButton); + } + + const MULTI_QUERY_MODAL_ID = 'kefin-multi-query-picker'; + + const multiQueryPopoverCss = ` + .kefin-multi-query-modal { + background-color: rgba(0, 0, 0, 0.95) !important; + z-index: 1001; + min-width: 12rem; + max-width: min(90vw, 320px); + max-height: min(60vh, 420px); + overflow: hidden; + padding: 0 !important; + min-width: initial !important; + } + .kefin-multi-query-modal [data-name="kefin-modal-content"] { + overflow-y: auto !important; + max-height: min(60vh, 420px); + } + .kefinTweaks-popover.multiQueryPopover { + margin-top: 0 !important; + display: grid; + } + `; + let multiQueryStyleEl = document.getElementById('kefinTweaks-multiQueryPopover-style'); + if (!multiQueryStyleEl) { + multiQueryStyleEl = document.createElement('style'); + multiQueryStyleEl.id = 'kefinTweaks-multiQueryPopover-style'; + document.head.appendChild(multiQueryStyleEl); + } + multiQueryStyleEl.textContent = multiQueryPopoverCss; + + selectButton.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + + if (!window.ModalSystem?.create) return; + if (window.ModalSystem.isOpen?.(MULTI_QUERY_MODAL_ID)) { + window.ModalSystem.close(MULTI_QUERY_MODAL_ID); + return; + } + + const list = document.createElement('div'); + list.className = 'kefinTweaks-popover multiQueryPopover itemDetailsGroup'; + const selectedIndex = resolveActiveQueryIndex(sectionConfig, { initialize: false }); + let selectedItemElement = null; + + const selectQuery = async (index) => { + window.ModalSystem.close(MULTI_QUERY_MODAL_ID); + if (index === resolveActiveQueryIndex(sectionConfig, { initialize: false })) { + return; + } + + sectionConfig._selectedQueryIndex = index; + // Clear cached section URL so resolve uses the newly selected query + sectionConfig.viewMoreUrl = null; + + const sectionIdAttr = + typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape(String(sectionConfig.id)) + : String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + + let activeSectionEl = + document.querySelector( + `.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`, + ) || sectionElement; + + const livePicker = activeSectionEl.querySelector('.multi-query-picker-button'); + if (livePicker) { + livePicker.textContent = getMultiQueryPickerButtonLabel(sectionConfig); + } + updateSectionTitleText(activeSectionEl, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig)); + + try { + let viewMoreUrl = getActiveViewMoreUrl(sectionConfig); + if (!viewMoreUrl && typeof window.sectionHelper?.resolveViewMoreUrl === 'function') { + viewMoreUrl = await window.sectionHelper.resolveViewMoreUrl(sectionConfig); + } + if (viewMoreUrl) { + sectionConfig.viewMoreUrl = viewMoreUrl; + } + updateSectionViewMoreLink(activeSectionEl, viewMoreUrl || null); + + const loaded = await loadActiveSectionQueryItems(sectionConfig); + let newContent = null; + + if (loaded.canPaintCached) { + newContent = await replaceSectionContent(sectionConfig, activeSectionEl, loaded.items); + } else { + activeSectionEl = showSectionQuerySkeletons(sectionConfig, activeSectionEl); + const freshItems = await loaded.itemsPromise; + newContent = await replaceSectionContent(sectionConfig, activeSectionEl, freshItems); + } + + if (typeof sectionConfig._onMultiQueryChange === 'function') { + sectionConfig._onMultiQueryChange(index, sectionConfig, newContent); + } + } catch (error) { + console.error('[KefinTweaks CardBuilder] Error switching query:', error); + const live = + document.querySelector( + `.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`, + ) || activeSectionEl; + setSectionRefreshing(live, false); + } + }; + + queries.forEach((query, index) => { + const itemElement = document.createElement('button'); + itemElement.type = 'button'; + itemElement.className = 'kefinTweaks-popover-item detailsGroupItem emby-button button-flat'; + if (index === selectedIndex) { + itemElement.classList.add('selected', 'is-active'); + selectedItemElement = itemElement; + } + itemElement.textContent = getQueryDisplayName(query, index); + itemElement.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + selectQuery(index); + }); + list.appendChild(itemElement); + }); + + window.ModalSystem.create({ + id: MULTI_QUERY_MODAL_ID, + title: null, + content: list, + footer: null, + showCloseButton: false, + closeOnBackdrop: true, + closeOnEscape: true, + fixedSize: false, + returnFocus: selectButton, + initialFocus: selectedItemElement, + anchor: selectButton, + dialogClassName: 'kefin-multi-query-modal', + // Match breadcrumb season popover: page scrolls unless the list itself overflows + lockPageScroll: false, + onOpen: (modal) => { + if (modal.backdrop) modal.backdrop.style.background = 'transparent'; + // Document-absolute shell via ModalSystem.positionNearAnchor. + if (modal.dialogContent) { + modal.dialogContent.style.padding = '0'; + modal.dialogContent.style.overflowY = 'auto'; + modal.dialogContent.style.minHeight = '0'; + modal.dialogContent.style.flex = '0 1 auto'; + modal.dialogContent.style.overscrollBehavior = 'auto'; + } + if (modal.dialog) { + modal.dialog.style.overscrollBehavior = 'auto'; + } + if (selectedItemElement) { + requestAnimationFrame(() => { + selectedItemElement.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }); + } + }, + }); + }); + } + + function ensureSectionControlsMount(titleContainer) { + const wrapper = titleContainer.querySelector(':scope > .sectionTitle-wrapper'); + const mountParent = wrapper || titleContainer; + + let controls = mountParent.querySelector(':scope > .section-controls'); + if (!controls) { + controls = titleContainer.querySelector(':scope > .section-controls'); + } + + if (!controls) { + controls = document.createElement('div'); + controls.className = 'section-controls'; + + const inline = document.createElement('div'); + inline.className = 'section-controls-inline'; + + const moreButton = createPaperIconButton({ + className: 'section-controls-more', + icon: 'more_vert', + title: 'More', + showFocus: isTvLayout(), + }); + + controls.appendChild(inline); + controls.appendChild(moreButton); + mountParent.appendChild(controls); + } else if (controls.parentNode !== mountParent) { + mountParent.appendChild(controls); + } + + const inline = controls.querySelector('.section-controls-inline'); + // Controls-only group must not trap L/R — title + controls share one row (below). + inline?.classList.remove('focuscontainer-x'); + + // TV: title link + section controls in one focuscontainer-x (spotlight uses headerContainer). + if (isTvLayout() && titleContainer.classList.contains('sectionTitleContainer')) { + mountParent.classList.add('focuscontainer-x'); + } + + return { + controls, + inline, + moreButton: controls.querySelector('.section-controls-more'), + }; + } + + function normalizeStaticSectionItems(sectionConfig) { + const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot || ''; + const serverId = typeof ApiClient !== 'undefined' && typeof ApiClient.serverId === 'function' ? ApiClient.serverId() : ''; + const normalizeTemplate = (value) => + (value || '').replace(/\$\{kefinTweaksRoot\}/g, kefinTweaksRoot).replace(/\$\{serverId\}/g, serverId); + + const visibleItems = (sectionConfig.items || []).filter((item) => item && item.hidden !== true); + return visibleItems.map((item, index) => ({ + Name: item.Name, + Id: item.Id || 'static-' + sectionConfig.id + '-' + index, + Type: item.Type || 'Folder', + posterUrl: normalizeTemplate(item.posterUrl), + thumbUrl: normalizeTemplate(item.thumbUrl), + squareUrl: normalizeTemplate(item.squareUrl), + imageUrl: normalizeTemplate(item.imageUrl), + cardUrl: normalizeTemplate(item.cardUrl), + backdropUrl: normalizeTemplate(item.backdropUrl), + bannerUrl: normalizeTemplate(item.bannerUrl), + logoUrl: normalizeTemplate(item.logoUrl), + CustomFooterText: item.cardFooter || undefined, + })); + } + + /** + * Refresh section content in place (used by section control registry). + * @returns {Promise} refresh button on the new section, if any + */ + async function executeSectionRefresh(sectionConfig, sectionElement) { + setSectionRefreshing(sectionElement, true); + + try { + const hasStaticItems = Array.isArray(sectionConfig.items) && sectionConfig.items.length > 0; + let freshItems; + + if (hasStaticItems) { + if (!sectionHasRandomQuerySort(sectionConfig)) { + setSectionRefreshing(sectionElement, false); + const refreshButton = sectionElement.querySelector('.section-refresh-button'); + if (refreshButton) { + showRefreshComplete(refreshButton); + } + return refreshButton || null; + } + + freshItems = postProcessItems(sectionConfig, normalizeStaticSectionItems(sectionConfig)); + if (freshItems.length === 0) { + setSectionRefreshing(sectionElement, false); + return null; + } + } else { + freshItems = await refreshSectionQueries(sectionConfig); + if (freshItems.length === 0) { + sectionElement.remove(); + notifyDiscoveryLeadFillAfterSectionRemove(); + return null; + } + } + + let finalCardFormat = sectionConfig.cardFormat; + const currentCardFormat = sectionElement.getAttribute('data-card-format'); + if (currentCardFormat) { + finalCardFormat = currentCardFormat; + } + + let content = null; + if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { + const refreshed = refreshSpotlight(sectionElement, freshItems, { + name: getSectionTitleForMultiQuery(sectionConfig), + viewMoreUrl: getActiveViewMoreUrl(sectionConfig), + spotlightConfig: sectionConfig.spotlightConfig, + dataItems: [], + keepCurrentSlide: false, + sectionConfig, + }); + content = refreshed.content; + ensureCardBorders(content); + setSectionRefreshing(content, false); + const newButton = attachSectionControlButtons(sectionConfig, content, freshItems); + if (newButton) { + showRefreshComplete(newButton); + } + return newButton; + } + + // In-place card refresh: keep section shell + sticky is=emby-itemscontainer + if (sectionElement.querySelector('.itemsContainer')) { + rebuildSectionTitleInPlace(sectionElement, sectionConfig); + updateSectionTitleText(sectionElement, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig)); + updateSectionViewMoreLink(sectionElement, getActiveViewMoreUrl(sectionConfig)); + replaceItemsContainerContents(sectionElement, freshItems, { + ...sectionConfig, + cardFormat: finalCardFormat, + }); + unobserveSkeletonShimmer(sectionElement); + setSectionRefreshing(sectionElement, false); + ensureSectionRevealObservation(sectionElement); + const newButton = sectionElement.querySelector('.section-refresh-button'); + if (newButton) { + showRefreshComplete(newButton); + } + return newButton; + } + + content = createSectionScrollableContainer( + freshItems, + getSectionTitleForMultiQuery(sectionConfig), + getActiveViewMoreUrl(sectionConfig), + sectionConfig.overflowCard, + finalCardFormat, + sectionConfig, + ); + + content.style.cssText = sectionElement.style.cssText; + content.className = sectionElement.className; + Array.from(sectionElement.attributes).forEach((attr) => { + content.setAttribute(attr.name, attr.value); + }); + + invalidateScrollerMetrics(sectionElement.querySelector('.emby-scroller')); + stripCopiedSkeletonSectionClasses(content); + unobserveSkeletonShimmer(sectionElement); + sectionElement.replaceWith(content); + applyItemsLayoutState(content, resolveItemsLayout(sectionConfig), resolveUseGaplessCards(sectionConfig)); + ensureCardBorders(content); + + const newButton = attachSectionControlButtons(sectionConfig, content, freshItems); + setSectionRefreshing(content, false); + registerLazyImagesInSection(content); + ensureSectionRevealObservation(content); + if (newButton) { + showRefreshComplete(newButton); + } + return newButton; + } catch (error) { + console.error('[KefinTweaks CardBuilder] Error refreshing section:', error); + setSectionRefreshing(sectionElement, false); + return null; + } + } + + function attachSectionControlButtons(sectionConfig, sectionElement, cachedItems) { + const titleContainer = + sectionElement.querySelector('.sectionTitleContainer') || sectionElement.querySelector('.spotlight-section-title-container'); + if (!titleContainer) return null; + + titleContainer + .querySelectorAll(':scope > .show-all-button, :scope > .section-refresh-button, :scope > .section-configure-button') + .forEach((el) => el.remove()); + + ensureSectionControlsMount(titleContainer); + const definitions = getSectionControlDefinitions(sectionConfig, sectionElement, cachedItems); + const refreshButton = renderSectionControls(sectionConfig, sectionElement, titleContainer, definitions); + + const itemsContainer = sectionElement.querySelector('.itemsContainer'); + const layout = itemsContainer?.getAttribute('data-layout') || resolveItemsLayout(sectionConfig); + const showAllBtn = titleContainer.querySelector('.show-all-button'); + if (showAllBtn) { + const resolved = layout === 'grid' ? layout : 'row'; + showAllBtn.title = ITEMS_LAYOUT_NEXT_TITLE[resolved] || ITEMS_LAYOUT_NEXT_TITLE.row; + showAllBtn.setAttribute('aria-label', showAllBtn.title); + setShowAllIcon(showAllBtn, nextItemsLayout(resolved)); + } + + attachMultiQueryPicker(sectionConfig, sectionElement, titleContainer); + return refreshButton; + } + + let cardUserDataListenerStarted = false; + let cardLibraryChangedListenerStarted = false; + let pendingLibraryUpdatedIds = new Set(); + let libraryUpdatedFlushTimer = null; + let libraryUpdatedFlushRunning = false; + const LIBRARY_UPDATED_DEBOUNCE_MS = 750; + const LIVE_SYNC_REBUILD_BATCH_SIZE = 8; + // Match apiHelper DEFAULT_ITEM_FIELDS so rebuilt cards keep aspect/overlays/indicators. + const LIVE_SYNC_ITEM_FIELDS = + 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,ProviderIds,MediaSourceCount'; + + function scheduleLibraryUpdatedFlush() { + if (libraryUpdatedFlushTimer) clearTimeout(libraryUpdatedFlushTimer); + libraryUpdatedFlushTimer = setTimeout(() => { + void flushVisibleCardLibraryUpdates(); + }, LIBRARY_UPDATED_DEBOUNCE_MS); + } + + function yieldToMainThread() { + return new Promise((resolve) => { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => resolve()); + } else { + setTimeout(resolve, 0); + } + }); + } + + function escapeCssIdentForCardSync(value) { + return typeof CSS !== 'undefined' && CSS.escape + ? CSS.escape(String(value)) + : String(value).replace(/["\\]/g, '\\$&'); + } + + function getPrimaryUserIdForCardSync() { + return typeof ApiClient !== 'undefined' && typeof ApiClient.getCurrentUserId === 'function' ? ApiClient.getCurrentUserId() : null; + } + + function isPrimaryUserDataChange(userId) { + const primaryId = getPrimaryUserIdForCardSync(); + if (!primaryId) return false; + if (!userId) return true; + return userId === primaryId; + } + + const RESUME_OR_NEXTUP_SECTION_IDS = new Set([ + 'continueWatching', + 'nextUp', + 'continueWatchingAndNextUp', + ]); + const NEXTUP_REFRESH_SECTION_IDS = ['nextUp', 'continueWatchingAndNextUp']; + const NEXTUP_PROGRESSIVE_REFRESH_DEBOUNCE_MS = 400; + let nextUpProgressiveRefreshTimer = null; + + function getCardHomeSectionId(card) { + const sectionEl = card?.closest?.('[data-section-id]'); + return sectionEl?.getAttribute('data-section-id') || sectionEl?.dataset?.sectionId || null; + } + + function findMountedHomeSectionElement(sectionId) { + if (!sectionId) return null; + const escaped = escapeCssIdentForCardSync(sectionId); + return document.querySelector(`.homePage:not(.hide) #homeTab .sections [data-section-id="${escaped}"]`) + || document.querySelector(`[data-section-id="${escaped}"]`); + } + + function maybeDismissEmptyResumeSection(sectionElement) { + if (!sectionElement?.isConnected) return; + const itemsContainer = sectionElement.querySelector('.itemsContainer'); + const remaining = itemsContainer + ? getRealLayoutCards(itemsContainer).filter((card) => { + return card.getAttribute('data-id') + && !card.classList.contains('skeleton-card') + && !card.classList.contains('cardbuilder-card-collapse'); + }) + : []; + if (remaining.length) return; + + const holdMs = getDismissEmptySectionTimerMs(); + if (holdMs > 0) { + dismissEmptyProgressiveSection(sectionElement, { holdMs }); + } else { + sectionElement.remove(); + notifyDiscoveryLeadFillAfterSectionRemove(); + } + } + + /** + * Collapse cards with the same animation as reconcileRowItems outgoing cards, then remove. + * @param {HTMLElement[]} cards + * @param {Function} [onComplete] + */ + function collapseAndRemoveCards(cards, onComplete) { + const uniqueCards = []; + const seen = new Set(); + (cards || []).forEach((card) => { + if (!card?.isConnected || seen.has(card)) return; + seen.add(card); + uniqueCards.push(card); + }); + if (!uniqueCards.length) { + if (typeof onComplete === 'function') onComplete(); + return; + } + + const bySection = new Map(); + uniqueCards.forEach((card) => { + const sectionEl = card.closest?.('[data-section-id]'); + if (!sectionEl) { + card.remove(); + return; + } + if (!bySection.has(sectionEl)) bySection.set(sectionEl, []); + bySection.get(sectionEl).push(card); + }); + + let pending = bySection.size; + if (!pending) { + if (typeof onComplete === 'function') onComplete(); + return; + } + + const finishOne = () => { + pending -= 1; + if (pending <= 0 && typeof onComplete === 'function') onComplete(); + }; + + bySection.forEach((sectionCards, sectionElement) => { + const itemsContainer = sectionElement.querySelector('.itemsContainer'); + if (!itemsContainer) { + sectionCards.forEach((card) => card.remove()); + maybeDismissEmptyResumeSection(sectionElement); + finishOne(); + return; + } + + const layouts = sectionCards.map(captureCardLayout); + layouts.forEach((layout) => { + layout.card.classList.add('cardbuilder-card-collapse'); + applyCardExpandedSize(layout); + }); + // Force layout before collapsing (same as reconcileRowItems) + itemsContainer.offsetHeight; + itemsContainer.classList.add('cardbuilder-row-animating'); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + layouts.forEach(({ card }) => applyCardCollapsedSize(card)); + const animTarget = sectionCards[0] || itemsContainer; + runAfterTransition(animTarget, 450, () => { + sectionCards.forEach((card) => { + if (card.isConnected) card.remove(); + }); + itemsContainer.classList.remove('cardbuilder-row-animating'); + reindexSectionCards(itemsContainer); + maybeDismissEmptyResumeSection(sectionElement); + finishOne(); + }); + }); + }); + }); + } + + /** + * Invalidate-free progressive refresh for currently mounted home sections. + * @param {string[]} sectionIds + */ + async function refreshMountedSectionsProgressive(sectionIds) { + const ids = Array.isArray(sectionIds) ? sectionIds.filter(Boolean) : []; + for (const sectionId of ids) { + const sectionElement = findMountedHomeSectionElement(sectionId); + if (!sectionElement?.isConnected) continue; + + const runtime = window.KefinHomeScreenSectionRuntime?.get?.(sectionId); + const cfg = runtime?.effectiveConfig || null; + if (!cfg) continue; + + let items = []; + try { + items = await refreshSectionQueries(cfg); + } catch (e) { + console.warn('[KefinTweaks CardBuilder] Progressive refresh failed for', sectionId, e); + continue; + } + if (!Array.isArray(items)) items = []; + + if (runtime) { + runtime.cachedItems = items; + runtime.element = sectionElement; + } + + applyProgressiveEnhancement( + sectionElement, + { config: cfg, result: { data: { Items: items } } }, + items, + { updateScrollButtons: true }, + ); + await yieldToMainThread(); + } + } + + function scheduleNextUpSectionsProgressiveRefresh() { + if (nextUpProgressiveRefreshTimer) clearTimeout(nextUpProgressiveRefreshTimer); + nextUpProgressiveRefreshTimer = setTimeout(() => { + nextUpProgressiveRefreshTimer = null; + void refreshMountedSectionsProgressive(NEXTUP_REFRESH_SECTION_IDS); + }, NEXTUP_PROGRESSIVE_REFRESH_DEBOUNCE_MS); + } + + /** + * Keep data-positionticks on visible cards aligned with primary-user playstate. + * Driven by UserDataChanged (same event Jellyfin uses for progress bars) — not DOM observation. + * @param {string} itemId + * @param {Object} userData + * @param {HTMLElement[]} [cardsHint] optional pre-found cards (avoids a second DOM scan) + * @returns {HTMLElement[]} + */ + function updateCardResumeAttributes(itemId, userData, cardsHint) { + if (!itemId || !userData) return []; + + const cards = Array.isArray(cardsHint) ? cardsHint : findVisibleCardsForItemId(itemId); + if (!cards.length) return cards; + + const ticks = userData.PlaybackPositionTicks; + + cards.forEach((card) => { + if (userData.Played === true) { + card.removeAttribute('data-positionticks'); + return; + } + if (typeof ticks === 'number' && ticks > 0) { + card.setAttribute('data-positionticks', String(ticks)); + } else { + card.removeAttribute('data-positionticks'); + } + }); + return cards; + } + + function updateCardWatchlistButtons(itemId, userData) { + if (!state.watchlist) return; + if (!itemId || !userData || !('Likes' in userData)) return; + + const isLiked = userData.Likes === true; + const likedHelper = window.KefinWatchlistLikedIds; + if (likedHelper?.set) { + likedHelper.set(itemId, isLiked); + } + + itemIdDomVariants(itemId).forEach((variant) => { + const escapedId = escapeCssIdentForCardSync(variant); + document.querySelectorAll(`.watchlist-button[data-id="${escapedId}"]`).forEach((button) => { + button.dataset.active = isLiked ? 'true' : 'false'; + button.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist'; + }); + }); + } + + function updateCardFavoriteButtons(itemId, userData) { + if (!itemId || !userData || !('IsFavorite' in userData)) return; + + const isFavorite = userData.IsFavorite === true; + itemIdDomVariants(itemId).forEach((variant) => { + const escapedId = escapeCssIdentForCardSync(variant); + document.querySelectorAll( + `[is="emby-ratingbutton"][data-id="${escapedId}"], button[data-isfavorite][data-id="${escapedId}"]` + ).forEach((button) => { + button.setAttribute('data-isfavorite', isFavorite ? 'true' : 'false'); + button.title = isFavorite ? 'Remove from favorites' : 'Add to favorites'; + }); + }); + } + + function applyUserDataEntriesToCards(entries, userId) { + if (!isPrimaryUserDataChange(userId)) return; + + const cardsToCollapse = []; + entries.forEach((entry) => { + if (!entry?.ItemId) return; + + const cards = findVisibleCardsForItemId(entry.ItemId); + updateCardResumeAttributes(entry.ItemId, entry, cards); + updateCardWatchlistButtons(entry.ItemId, entry); + updateCardFavoriteButtons(entry.ItemId, entry); + + if (entry.Played === true) { + cards.forEach((card) => { + const sectionId = getCardHomeSectionId(card); + if (sectionId && RESUME_OR_NEXTUP_SECTION_IDS.has(sectionId)) { + cardsToCollapse.push(card); + } + }); + } + }); + + if (!cardsToCollapse.length) return; + + // Refresh after collapse so progressive enhance does not race outgoing animation + collapseAndRemoveCards(cardsToCollapse, () => { + scheduleNextUpSectionsProgressiveRefresh(); + }); + } + + function startCardUserDataListener() { + if (cardUserDataListenerStarted) return; + + if (!window.websocketHelper?.listen) { + setTimeout(startCardUserDataListener, 1000); + return; + } + + window.websocketHelper.listen('UserDataChanged', (msg) => { + const userId = msg?.Data?.UserId; + const entries = msg?.Data?.UserDataList; + if (!Array.isArray(entries) || !entries.length) return; + applyUserDataEntriesToCards(entries, userId); + }); + cardUserDataListenerStarted = true; + console.log('[KefinTweaks CardBuilder] Listening for UserDataChanged to sync card resume attributes'); + } + + function liveSyncLog(...args) { + console.log('[KefinTweaks CardBuilder][LiveSync]', ...args); + } + + function liveSyncWarn(...args) { + console.warn('[KefinTweaks CardBuilder][LiveSync]', ...args); + } + + /** Strip GUID dashes for comparison (server LibraryChanged often uses "N" format). */ + function normalizeItemIdKey(id) { + return String(id || '') + .replace(/-/g, '') + .toLowerCase(); + } + + /** Candidate data-id strings for DOM queries (dashed + undashed). */ + function itemIdDomVariants(id) { + const raw = String(id || '').trim(); + if (!raw) return []; + const variants = new Set([raw]); + const naked = normalizeItemIdKey(raw); + if (/^[0-9a-f]{32}$/.test(naked)) { + variants.add(naked); + variants.add( + `${naked.slice(0, 8)}-${naked.slice(8, 12)}-${naked.slice(12, 16)}-${naked.slice(16, 20)}-${naked.slice(20)}`, + ); + } + return Array.from(variants); + } + + function findVisibleCardsForItemId(itemId) { + if (!itemId) return []; + const found = new Set(); + itemIdDomVariants(itemId).forEach((variant) => { + const escapedId = escapeCssIdentForCardSync(variant); + document.querySelectorAll(`.card[data-id="${escapedId}"]:not(.skeleton-card)`).forEach((card) => { + found.add(card); + }); + }); + return Array.from(found); + } + + function resolveCardRebuildArgs(card) { + const sectionEl = card?.closest?.('[data-section-id]'); + const sectionId = sectionEl?.getAttribute('data-section-id') || sectionEl?.dataset?.sectionId || null; + const runtimeCfg = sectionId ? window.KefinHomeScreenSectionRuntime?.get?.(sectionId)?.effectiveConfig : null; + + const cardFormat = sectionEl?.getAttribute('data-card-format') || runtimeCfg?.cardFormat || null; + + let overflowCard; + if (runtimeCfg && typeof runtimeCfg.overflowCard === 'boolean') { + overflowCard = runtimeCfg.overflowCard; + } else if (/\boverflow(?:Portrait|Backdrop|Square|Banner)Card\b/.test(card?.className || '')) { + overflowCard = true; + } else if (/\b(?:portrait|backdrop|square|banner)Card\b/.test(card?.className || '')) { + overflowCard = false; + } else { + // Home rows default to overflow sizing. + overflowCard = true; + } + + const useParentCard = !!runtimeCfg?.useParentCard; + const borderStyle = + runtimeCfg?.borderStyle || sectionEl?.dataset?.borderStyle || sectionEl?.dataset?.border || null; + + return { cardFormat, overflowCard, useParentCard, borderStyle, sectionId }; + } + + /** Image tags the card builder may put in data-src (any format / fallback path). */ + function collectItemImageTagsForLiveSync(item) { + const tags = new Set(); + const add = (t) => { + if (t != null && t !== '') tags.add(String(t)); + }; + const it = item?.ImageTags; + if (it) { + add(it.Primary); + add(it.Thumb); + add(it.Logo); + add(it.Banner); + add(it.Art); + add(it.Disc); + } + add(item?.BackdropImageTags?.[0]); + add(item?.PrimaryImageTag); + add(item?.SeriesPrimaryImageTag); + add(item?.SeriesThumbImageTag); + add(item?.ParentThumbImageTag); + add(item?.ParentBackdropImageTags?.[0]); + add(item?.ParentPrimaryImageTag); + add(item?.ParentLogoImageTag); + return tags; + } + + function extractTagFromImageUrl(url) { + if (!url) return ''; + try { + const m = String(url).match(/[?&]tag=([^&]*)/i); + return m?.[1] ? decodeURIComponent(m[1]) : ''; + } catch (e) { + return ''; + } + } + + /** + * Skip DOM replace when painted card signals already match the fresh item. + * Compares name, image tag in data-src, resume ticks, played / unplayed indicators. + */ + function cardNeedsLiveSyncRebuild(oldCard, freshItem) { + if (!oldCard || !freshItem) return false; + + const img = + oldCard.querySelector?.('.cardImageContainer[data-src]') || + oldCard.querySelector?.('.cardImageContainer') || + null; + const dataSrc = img?.getAttribute('data-src') || ''; + const paintedTag = extractTagFromImageUrl(dataSrc); + const paintedName = (img?.getAttribute('aria-label') || '').trim(); + const paintedTicks = oldCard.getAttribute('data-positionticks') || ''; + const paintedPlayed = !!oldCard.querySelector?.('.playedIndicator'); + const paintedUnplayed = (oldCard.querySelector?.('.countIndicator.indicator')?.textContent || '').trim(); + const paintedMediaSources = (oldCard.querySelector?.('.mediaSourceIndicator')?.textContent || '').trim(); + + const expectedName = String(freshItem.Name || 'Unknown').trim(); + const nameChanged = paintedName !== expectedName && paintedName !== String(freshItem.Name || '').trim(); + + const freshTags = collectItemImageTagsForLiveSync(freshItem); + const hadImage = !!dataSrc; + const hasImageTags = freshTags.size > 0; + let imageChanged = false; + if (paintedTag) { + // Painted tag no longer on the item → artwork changed for this card. + imageChanged = !freshTags.has(paintedTag); + } else if (!hadImage && hasImageTags) { + imageChanged = true; + } else if (hadImage && !hasImageTags) { + imageChanged = true; + } + + const freshPlayed = freshItem.UserData?.Played === true; + const freshTicks = + !freshPlayed && typeof freshItem.UserData?.PlaybackPositionTicks === 'number' && freshItem.UserData.PlaybackPositionTicks > 0 + ? String(freshItem.UserData.PlaybackPositionTicks) + : ''; + const ticksChanged = paintedTicks !== freshTicks; + const playedChanged = paintedPlayed !== freshPlayed; + + const freshUnplayed = + freshItem.UserData?.UnplayedItemCount && freshItem.UserData.UnplayedItemCount > 0 + ? String(freshItem.UserData.UnplayedItemCount) + : ''; + const unplayedChanged = paintedUnplayed !== freshUnplayed; + + const freshMediaSources = + typeof freshItem.MediaSourceCount === 'number' && freshItem.MediaSourceCount > 1 + ? String(freshItem.MediaSourceCount) + : ''; + const mediaSourcesChanged = paintedMediaSources !== freshMediaSources; + + const needs = nameChanged || imageChanged || ticksChanged || playedChanged || unplayedChanged || mediaSourcesChanged; + if (!needs) { + liveSyncLog('Skipping rebuild — no significant painted changes', { + itemId: freshItem.Id, + name: expectedName, + paintedTag, + }); + } + return needs; + } + + function replaceVisibleCardWithFreshItem(oldCard, freshItem) { + if (!oldCard || !freshItem) return false; + if (!cardNeedsLiveSyncRebuild(oldCard, freshItem)) return false; + + const { cardFormat, overflowCard, useParentCard, borderStyle, sectionId } = resolveCardRebuildArgs(oldCard); + liveSyncLog('Rebuilding card', { + itemId: freshItem.Id, + name: freshItem.Name, + sectionId, + cardFormat, + overflowCard, + useParentCard, + borderStyle, + imagePrimaryTag: freshItem.ImageTags?.Primary || null, + }); + const next = createCardElement( + freshItem, + overflowCard, + cardFormat, + freshItem.cardFooter || freshItem.CustomFooterText || null, + useParentCard, + borderStyle, + ); + const index = oldCard.getAttribute('data-index'); + if (index != null) next.setAttribute('data-index', index); + + const hadFocus = document.activeElement === oldCard; + oldCard.replaceWith(next); + // New card has data-src + .lazy but is not in any IntersectionObserver yet. + registerLazyImagesIn(next); + if (hadFocus && typeof next.focus === 'function') { + try { + next.focus({ preventScroll: true }); + } catch (e) { + next.focus(); + } + } + return true; + } + + async function flushVisibleCardLibraryUpdates() { + libraryUpdatedFlushTimer = null; + if (libraryUpdatedFlushRunning) { + // Keep pending ids; schedule one follow-up after the in-flight flush + if (pendingLibraryUpdatedIds.size) { + liveSyncLog('Flush already running — deferring follow-up', { + pendingSize: pendingLibraryUpdatedIds.size, + }); + scheduleLibraryUpdatedFlush(); + } + return; + } + + const ids = Array.from(pendingLibraryUpdatedIds); + pendingLibraryUpdatedIds.clear(); + liveSyncLog('Flush start', { pendingIds: ids }); + if (!ids.length) return; + + libraryUpdatedFlushRunning = true; + try { + const matchCounts = {}; + const idsToFetch = ids.filter((id) => { + const count = findVisibleCardsForItemId(id).length; + matchCounts[id] = count; + return count > 0; + }); + liveSyncLog('Visible card matches', matchCounts); + + if (!idsToFetch.length) { + liveSyncLog('No on-page cards for updated ids — skipping fetch'); + return; + } + + if (typeof ApiClient === 'undefined' || typeof ApiClient.getItems !== 'function') { + liveSyncWarn('ApiClient.getItems unavailable — cannot refresh cards'); + return; + } + + const userId = ApiClient.getCurrentUserId?.(); + liveSyncLog('Fetching items', { userId, idsToFetch, fields: LIVE_SYNC_ITEM_FIELDS }); + + const response = await ApiClient.getItems(userId, { + Ids: idsToFetch.join(','), + Recursive: false, + Fields: LIVE_SYNC_ITEM_FIELDS, + }); + const itemsById = new Map(); + (response?.Items || []).forEach((item) => { + if (!item?.Id) return; + itemsById.set(normalizeItemIdKey(item.Id), item); + }); + liveSyncLog('Fetch result', { + requested: idsToFetch.length, + returned: itemsById.size, + itemSummaries: Array.from(itemsById.values()).map((item) => ({ + id: item.Id, + name: item.Name, + primaryTag: item.ImageTags?.Primary || null, + })), + }); + + // Flatten id → cards into a rebuild work list, then process in rAF batches + const work = []; + idsToFetch.forEach((id) => { + const freshItem = itemsById.get(normalizeItemIdKey(id)); + if (!freshItem) { + liveSyncWarn('Item missing from getItems response', id); + return; + } + const cards = findVisibleCardsForItemId(id); + liveSyncLog('Replacing cards for item', { id, cardCount: cards.length }); + cards.forEach((card) => { + work.push({ card, freshItem }); + }); + }); + + let rebuilt = 0; + let skipped = 0; + for (let i = 0; i < work.length; i += LIVE_SYNC_REBUILD_BATCH_SIZE) { + if (i > 0) await yieldToMainThread(); + const batch = work.slice(i, i + LIVE_SYNC_REBUILD_BATCH_SIZE); + batch.forEach(({ card, freshItem }) => { + if (replaceVisibleCardWithFreshItem(card, freshItem)) rebuilt += 1; + else skipped += 1; + }); + } + liveSyncLog('Flush complete', { rebuilt, skipped, workItems: work.length }); + } catch (err) { + console.error('[KefinTweaks CardBuilder][LiveSync] Visible card live sync failed:', err); + } finally { + libraryUpdatedFlushRunning = false; + // Ids queued during this flush — schedule another pass + if (pendingLibraryUpdatedIds.size) { + scheduleLibraryUpdatedFlush(); + } + } + } + + function queueLibraryUpdatedItemIds(ids, source = 'unknown') { + if (!Array.isArray(ids) || !ids.length) return; + const before = pendingLibraryUpdatedIds.size; + ids.forEach((id) => { + if (id == null || id === '') return; + pendingLibraryUpdatedIds.add(String(id)); + }); + liveSyncLog('Queued item ids', { + source, + incoming: ids.map(String), + pendingSizeBefore: before, + pendingSizeAfter: pendingLibraryUpdatedIds.size, + debounceMs: LIBRARY_UPDATED_DEBOUNCE_MS, + flushRunning: libraryUpdatedFlushRunning, + }); + scheduleLibraryUpdatedFlush(); + } + + function handleLibraryChangedMessage(msg) { + const data = msg?.Data ?? msg; + const updated = data?.ItemsUpdated; + const dataKeys = data && typeof data === 'object' ? Object.keys(data) : []; + liveSyncLog('LibraryChanged received', { + deliveryMode: window.websocketHelper?.getDeliveryMode?.() || null, + messageType: msg?.MessageType, + dataKeys, + itemsUpdatedType: Array.isArray(updated) ? 'array' : typeof updated, + itemsUpdatedCount: Array.isArray(updated) ? updated.length : null, + itemsUpdated: Array.isArray(updated) ? updated : updated, + itemsAdded: data?.ItemsAdded, + itemsRemoved: data?.ItemsRemoved, + }); + if (!Array.isArray(updated) || !updated.length) { + liveSyncLog('Ignoring LibraryChanged — ItemsUpdated empty or not an array'); + return; + } + queueLibraryUpdatedItemIds(updated, 'LibraryChanged.ItemsUpdated'); + } + + function startCardLibraryChangedListener() { + if (cardLibraryChangedListenerStarted) return; + + if (!window.websocketHelper?.listen) { + liveSyncLog('websocketHelper not ready — retrying listener in 1s'); + setTimeout(startCardLibraryChangedListener, 1000); + return; + } + + window.websocketHelper.listen('LibraryChanged', handleLibraryChangedMessage); + cardLibraryChangedListenerStarted = true; + liveSyncLog('Listening for LibraryChanged to sync visible card metadata', { + deliveryMode: window.websocketHelper?.getDeliveryMode?.() || 'pending', + }); + } + + let cardEditorCloseLiveSyncStarted = false; + const editorDialogDirty = new WeakMap(); + + function isImageEditorDialog(dlg) { + if (!dlg?.querySelector) return false; + return !!(dlg.querySelector('.imageEditorCard') || dlg.querySelector('#images') || dlg.querySelector('#backdrops')); + } + + function isMetadataEditorDialog(dlg) { + if (!dlg?.querySelector) return false; + if (isImageEditorDialog(dlg)) return false; + return !!( + dlg.querySelector('.metadataEditor') || + dlg.querySelector('#metadataEditor') || + dlg.querySelector('.metadataEditorContent') || + dlg.querySelector('form.metadataEditorForm') || + dlg.querySelector('.btnSaveMetadata, #btnSaveMetadata') + ); + } + + function resolveEditorDialogItemId(dlg) { + if (!dlg) return null; + const fromCard = dlg.querySelector?.('.imageEditorCard[data-id], [data-id][data-imagetype], [data-itemid]'); + const attrId = + fromCard?.getAttribute('data-id') || + fromCard?.getAttribute('data-itemid') || + dlg.getAttribute?.('data-itemid') || + dlg.getAttribute?.('data-id') || + dlg.dataset?.itemId || + null; + if (attrId) return attrId; + + try { + const hash = String(window.location.hash || ''); + const match = hash.match(/[?&]id=([^&]+)/i); + if (match?.[1]) return decodeURIComponent(match[1]); + } catch (e) { + // ignore + } + return null; + } + + function markEditorDialogDirty(dlg) { + if (!dlg) return; + editorDialogDirty.set(dlg, true); + } + + function handleEditorDialogClose(dlg) { + if (!dlg) return; + const isImage = isImageEditorDialog(dlg); + const isMeta = isMetadataEditorDialog(dlg); + if (!isImage && !isMeta) return; + + const dirty = editorDialogDirty.get(dlg) === true; + // Mirror imageeditor hasChanges: only rebuild when the dialog was dirtied while open. + if (!dirty) { + liveSyncLog('Editor closed without changes — skip rebuild', { + kind: isImage ? 'image' : 'metadata', + }); + return; + } + + const itemId = resolveEditorDialogItemId(dlg); + if (!itemId) { + liveSyncWarn('Editor closed but could not resolve item id'); + return; + } + + liveSyncLog('Editor closed — queueing live sync', { + itemId, + kind: isImage ? 'image' : 'metadata', + dirty, + }); + queueLibraryUpdatedItemIds([itemId], isImage ? 'imageEditor.close' : 'metadataEditor.close'); + } + + function watchEditorDialog(dlg) { + if (!dlg || dlg.__kefinLiveSyncWatched) return; + if (!isImageEditorDialog(dlg) && !isMetadataEditorDialog(dlg)) return; + + dlg.__kefinLiveSyncWatched = true; + editorDialogDirty.set(dlg, false); + + if (isImageEditorDialog(dlg)) { + // imageeditor.js mutates #images during initial reload — ignore until settled. + let armed = false; + const armTimer = setTimeout(() => { + armed = true; + editorDialogDirty.set(dlg, false); + }, 800); + dlg.__kefinLiveSyncArmTimer = armTimer; + + const mo = new MutationObserver(() => { + if (!armed) return; + markEditorDialogDirty(dlg); + }); + const images = dlg.querySelector('#images') || dlg.querySelector('#backdrops') || dlg; + try { + mo.observe(images, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-src', 'style', 'src'], + }); + } catch (e) { + mo.observe(dlg, { childList: true, subtree: true }); + } + dlg.__kefinLiveSyncMo = mo; + } else if (isMetadataEditorDialog(dlg)) { + const onFieldEdit = () => markEditorDialogDirty(dlg); + dlg.addEventListener('input', onFieldEdit, true); + dlg.addEventListener('change', onFieldEdit, true); + dlg.__kefinLiveSyncFieldEdit = onFieldEdit; + } + + const onClose = () => { + if (dlg.__kefinLiveSyncArmTimer) { + clearTimeout(dlg.__kefinLiveSyncArmTimer); + } + if (dlg.__kefinLiveSyncFieldEdit) { + dlg.removeEventListener('input', dlg.__kefinLiveSyncFieldEdit, true); + dlg.removeEventListener('change', dlg.__kefinLiveSyncFieldEdit, true); + } + try { + dlg.__kefinLiveSyncMo?.disconnect?.(); + } catch (e) { + // ignore + } + handleEditorDialogClose(dlg); + dlg.removeEventListener('close', onClose); + }; + dlg.addEventListener('close', onClose); + + liveSyncLog('Watching editor dialog for close', { + image: isImageEditorDialog(dlg), + metadata: isMetadataEditorDialog(dlg), + itemId: resolveEditorDialogItemId(dlg), + }); + } + + function startCardEditorCloseLiveSync() { + if (cardEditorCloseLiveSyncStarted) return; + cardEditorCloseLiveSyncStarted = true; + + document.querySelectorAll('.formDialog').forEach((dlg) => watchEditorDialog(dlg)); + + const bodyObserver = new MutationObserver((mutations) => { + mutations.forEach((m) => { + m.addedNodes.forEach((node) => { + if (!(node instanceof HTMLElement)) return; + if (node.matches?.('.formDialog')) watchEditorDialog(node); + node.querySelectorAll?.('.formDialog').forEach((dlg) => watchEditorDialog(dlg)); + }); + }); + }); + bodyObserver.observe(document.body, { childList: true, subtree: true }); + liveSyncLog('Editor-close live sync observer started'); + } + // Main card builder object const cardBuilder = { + /** + * Post-processes items for a section + * @param {Object} sectionConfig - Section configuration + * @param {Array} itemsData - Array of Jellyfin items + * @returns {Array} - Post-processed array of Jellyfin items + */ + postProcessItems: postProcessItems, + postProcessItemsByQuery: postProcessItemsByQuery, + getQueryDisplayName: getQueryDisplayName, + resolveActiveQueryIndex: resolveActiveQueryIndex, + resolveQueriesToLoad: resolveQueriesToLoad, + getSectionTitleForMultiQuery: getSectionTitleForMultiQuery, + getActiveViewMoreUrl: getActiveViewMoreUrl, + refreshSectionQueries: refreshSectionQueries, + refreshMountedSectionsProgressive: refreshMountedSectionsProgressive, + loadActiveSectionQueryItems: loadActiveSectionQueryItems, + createSectionScrollableContainer: createSectionScrollableContainer, + applySectionPresentationAttrs: applySectionPresentationAttrs, + attachSectionControlButtons: attachSectionControlButtons, + ensureCardBorders: ensureCardBorders, + applyItemsLayoutState: applyItemsLayoutState, + resolveItemsLayout: resolveItemsLayout, + resolveGridShape: resolveGridShape, + resolveSpotlightPresentation: resolveSpotlightPresentation, + resolveUseGaplessCards: resolveUseGaplessCards, + invalidateCardRadiusCache: invalidateCardRadiusCache, + ensureCardRadiusCssVar: ensureCardRadiusCssVar, + getScrollerPosition: getScrollerPosition, + setScrollerPosition: setScrollerPosition, + scrollToCardIndex: scrollToCardIndex, + invalidateScrollerMetrics: invalidateScrollerMetrics, + getItemMaterialIcon: getItemMaterialIcon, + createLibraryButtonElement: createLibraryButtonElement, /** * 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', 'thumb', or 'square' * @param {string} customFooterText - Optional custom footer text (e.g., air date for episodes) + * @param {boolean} useParentCard - When true, the parent item will be rendered as the card image and link + * @param {string|null} borderStyle - Section card border style (e.g. 'retro-poster') * @returns {HTMLElement} - The constructed card element */ - buildCard: function(item, overflowCard = false, cardFormat = null, customFooterText = null) { - return createJellyfinCardElement(item, overflowCard, cardFormat, customFooterText); + buildCard: function ( + item, + overflowCard = false, + cardFormat = null, + customFooterText = null, + useParentCard = false, + borderStyle = null, + ) { + return createCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle); }, - + + /** Push-register lazy images under an attached root (parallel native path). */ + registerLazyImagesIn: registerLazyImagesIn, + registerLazyImagesInSection: registerLazyImagesInSection, + /** * Renders a scrollable container with cards * @param {Array} items - Array of Jellyfin item objects @@ -102,22 +3058,33 @@ * @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending' * @returns {HTMLElement} - The constructed scrollable container */ - renderCards: function(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null, sortOrder = null, sortOrderDirection = 'Ascending') { - // Handle Random card format - pick one format for entire section + renderCards: function ( + items, + title, + viewMoreUrl = null, + overflowCard = false, + cardFormat = null, + sortOrder = null, + sortOrderDirection = 'Ascending', + ) { 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); + + const container = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat); + return container; + }, + + renderSpotlightSection: function (items, title, options = {}) { + return createSpotlightSection(items, title, options); }, /** @@ -131,9 +3098,472 @@ * @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending' * @returns {Promise} - The constructed scrollable container */ - renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, overflowCard = false, cardFormat = null, sortOrder = null, sortOrderDirection = 'Ascending') { + /** + * Renders cards progressively with skeleton loading state + * @param {Promise} itemsPromise - Promise that resolves to array of Jellyfin items + * @param {string} title - Title for the scrollable container + * @param {string|Function} 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', '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 (initially with skeletons) + */ + renderProgressivelyEnhancedCards: async function ( + itemsPromise, + title, + viewMoreUrl = null, + overflowCard = false, + cardFormat = null, + sortOrder = null, + sortOrderDirection = 'Ascending', + minimumItems = -1, + ) { + // Handle Random card format - Must resolve ONCE for both skeleton and real cards + let resolvedFormat = cardFormat; + if (cardFormat === 'random' || cardFormat === 'Random') { + resolvedFormat = getRandomCardFormat(); + } + + // Check if promise is already fulfilled + if ((await promiseState(itemsPromise)) === 'fulfilled') { + // Resolve the promise and get the items + const items = await itemsPromise; + // Replace the skeleton with the real cards + const container = createScrollableContainer(items, title, viewMoreUrl, overflowCard, resolvedFormat); + return container; + } + + // Create skeleton container immediately using the resolved format + const skeletonContainer = createProgressivelyEnhancedScrollableContainer(title, viewMoreUrl, resolvedFormat, overflowCard); + const randomId = Math.random().toString(36).substring(2, 15); + skeletonContainer.setAttribute('data-skeleton-id', randomId); + + // When promise resolves, replace skeletons with real cards + itemsPromise + .then(async (items) => { + // Remove the skeleton container if it exists + let skeletonContaineElement = document.querySelector(`[data-skeleton-id="${randomId}"]`); + + // Let's try to find the skeleton 20 times every 100ms + for (let i = 0; i < 20; i++) { + skeletonContaineElement = document.querySelector(`[data-skeleton-id="${randomId}"]`); + if (skeletonContaineElement) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + if (!items || items.length === 0) { + unobserveSkeletonShimmer(skeletonContaineElement); + skeletonContaineElement.remove(); + return; + } + + if (items.length < minimumItems) { + unobserveSkeletonShimmer(skeletonContaineElement); + skeletonContaineElement.remove(); + return; + } + + // Sort items if needed + let sortedItems = items; + if (sortOrder && sortOrder !== 'Random') { + sortedItems = sortItems(items, sortOrder, sortOrderDirection); + } else if (sortOrder === 'Random') { + sortedItems = shuffle([...items]); + } + + // Generate real cards container (off-DOM) using the SAME resolved format + const realContainer = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, resolvedFormat); + + // Copy critical attributes to preserve layout/order + if (skeletonContaineElement.style.order) { + realContainer.style.order = skeletonContainer.style.order; + } + if (skeletonContaineElement.hasAttribute('data-section-id')) { + realContainer.setAttribute('data-section-id', skeletonContainer.getAttribute('data-section-id')); + } + if (skeletonContaineElement.dataset.border) { + realContainer.dataset.border = skeletonContaineElement.dataset.border; + realContainer.dataset.borderStyle = + skeletonContaineElement.dataset.borderStyle || skeletonContaineElement.dataset.border; + } + + // Replace content of skeleton container with real content + // This preserves the container element itself and its layout properties (like order) + unobserveSkeletonShimmer(skeletonContaineElement); + skeletonContaineElement.replaceWith(realContainer); + ensureCardBorders(realContainer); + }) + .catch((error) => { + console.error('[KefinTweaks CardBuilder] Error loading items for progressive enhancement:', error); + // Optionally remove skeleton on error or show error state + if (skeletonContaineElement) { + unobserveSkeletonShimmer(skeletonContaineElement); + skeletonContaineElement.remove(); + } + }); + + return skeletonContainer; + }, + + /** + * Renders a progressive section (Cards or Spotlight) + * @param {HTMLElement} container - The parent container to append the section to + * @param {Array|Promise} itemsOrPromises - Data array or Promise resolving to items + * @param {string} title - Section title + * @param {Object} options - Configuration options + */ + renderProgressiveSections: async function (container, sections, options = {}) { + const { + revealSectionsSequentially = false, + waitForContainerClass = null, + enhanceOnVisible = false, + enhanceRootMargin = '30% 0px 30% 0px', + showStaleDataBeforeRefresh = true, + insertBefore = null, + } = options; + + const renderProgressiveSectionsStartTime = performance.now(); + + const sectionEnhancementTargets = []; + const fragment = document.createDocumentFragment(); + for (const sectionPromise of sections) { + const sectionPromiseStartTime = performance.now(); + const section = await sectionPromise; + const sectionPromiseEndTime = performance.now(); + const sectionPromiseDuration = sectionPromiseEndTime - sectionPromiseStartTime; + console.log(`Section promise initialization time: ${sectionPromiseDuration.toFixed(2)}ms`); + + if (!section) continue; + + const sectionStartTime = performance.now(); + + const sectionConfig = section.config; + + // Skip if the section is not enabled + if (!sectionConfig.enabled) { + console.log(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} is not enabled, skipping...`); + continue; + } + + // Skip if this section id is already in the container (overlapping home renders) + const sectionIdAttr = + typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape(String(sectionConfig.id)) + : String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + if (container.querySelector(`[data-section-id="${sectionIdAttr}"]`)) { + console.log(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} already exists in the container, skipping...`); + continue; + } + + // Handle Random card format - pick one format for entire section + let finalCardFormat = sectionConfig.cardFormat; + if (finalCardFormat === 'random' || finalCardFormat === 'Random') { + finalCardFormat = getRandomCardFormat(); + } + + let dataItems = section.result?.data?.Items ?? section.result?.data; + const isRandomQuerySort = sectionHasRandomQuerySort(sectionConfig); + + // If the section has items, it is a static query + const isStaticQuery = Array.isArray(sectionConfig.items) && sectionConfig.items.length > 0; + + const renderCachedData = + (isStaticQuery || + (!section.result?.isStale && !isRandomQuerySort) || + (showStaleDataBeforeRefresh && (!isRandomQuerySort || sectionConfig.renderMode === 'Spotlight'))) && + dataItems.length > 0; + + // Paint cached cards when valid, or when stale display is opted in + if (renderCachedData) { + let content = null; + + if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { + content = createSpotlightSection(dataItems, getSectionTitleForMultiQuery(sectionConfig), { + viewMoreUrl: getActiveViewMoreUrl(sectionConfig), + progressiveEnhancement: true, + ...sectionConfig.spotlightConfig, + }); + } else { + // Stale cached paint may still refresh — defer scroll-button register until final apply + const registerScrollButtons = isStaticQuery || section.result?.isStale !== true; + content = createScrollableContainer( + dataItems, + getSectionTitleForMultiQuery(sectionConfig), + getActiveViewMoreUrl(sectionConfig), + sectionConfig.overflowCard, + finalCardFormat, + sectionConfig, + registerScrollButtons, + ); + } + + content.setAttribute('data-section-id', sectionConfig.id); + content.style.order = sectionConfig.order; + content.dataset.order = sectionConfig.order; + + if (sectionConfig.hideCardFooter === true) { + content.dataset.hideCardFooter = 'true'; + } + applySectionPresentationAttrs(content, sectionConfig); + + // Add section control buttons to rendered section + attachSectionControlButtons(sectionConfig, content, dataItems); + + sectionEnhancementTargets.push({ element: content, section }); + //container.appendChild(content); + //content.style.contentVisibility = 'hidden'; + + if (revealSectionsSequentially) { + content.classList.add('cardbuilder-section-reveal'); + } + + fragment.appendChild(content); + const sectionEndTime = performance.now(); + const sectionDuration = sectionEndTime - sectionStartTime; + console.log(`Section ${sectionConfig.id} progressive initialization time: ${sectionDuration.toFixed(2)}ms`); + continue; + } + + // Otherwise, we need to create a skeleton section + + let sectionElement = null; + if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { + const spotlightSettings = sectionConfig.spotlightConfig ?? {}; + if (getActiveViewMoreUrl(sectionConfig)) { + spotlightSettings.viewMoreUrl = getActiveViewMoreUrl(sectionConfig); + } + spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true; + spotlightSettings.caption = sectionConfig.caption; + sectionElement = createSkeletonSpotlightSection(getSectionTitleForMultiQuery(sectionConfig), spotlightSettings); + } else { + sectionElement = createProgressivelyEnhancedScrollableContainer( + getSectionTitleForMultiQuery(sectionConfig), + getActiveViewMoreUrl(sectionConfig), + finalCardFormat, + sectionConfig.overflowCard, + sectionConfig, + ); + } + sectionElement.setAttribute('data-section-id', sectionConfig.id); + sectionElement.style.order = sectionConfig.order; + sectionElement.dataset.order = sectionConfig.order; + + if (sectionConfig.hideCardFooter === true) { + sectionElement.dataset.hideCardFooter = 'true'; + } + applySectionPresentationAttrs(sectionElement, sectionConfig); + //container.appendChild(sectionElement); + + // set section element content-visibility to hidden + //sectionElement.style.contentVisibility = 'hidden'; + + if (revealSectionsSequentially) { + sectionElement.classList.add('cardbuilder-section-reveal'); + } + + sectionEnhancementTargets.push({ element: sectionElement, section }); + fragment.appendChild(sectionElement); + const sectionEndTime = performance.now(); + const sectionDuration = sectionEndTime - sectionStartTime; + console.log(`Section ${sectionConfig.id} progressive initialization time: ${sectionDuration.toFixed(2)}ms`); + } + + // Wait for the container to have the class specified in waitForContainerClass with a timeout of 10 seconds + // Append the container to the page for now invisibly so that Jellyfin handles enhancing it with the typical Scroller functionality + // Finally when the container has the class specified in waitForContainerClass, we can move the pre-rendered sections into the container + if (waitForContainerClass && !container.classList.contains(waitForContainerClass)) { + // Wait for the container to have the class specified in waitForContainerClass with a timeout of 10 seconds + console.log(`Waiting for container to have class ${waitForContainerClass}...`); + const maxAttempts = 100; + let attempts = 0; + while (!container.classList.contains(waitForContainerClass) && attempts < maxAttempts) { + attempts++; + await new Promise((resolve) => setTimeout(resolve, 100)); + if (container.classList.contains(waitForContainerClass)) { + break; + } + } + if (!container.classList.contains(waitForContainerClass)) { + console.log(`Container did not have class ${waitForContainerClass} after ${maxAttempts} attempts, continuing...`); + } + } + + // Append in batches so style/layout work is spread across frames. + // Register lazy images per section .itemsContainer only (never the home container). + const BATCH_SIZE = 2; + const sectionNodes = Array.from(fragment.childNodes); + for (let i = 0; i < sectionNodes.length; i += BATCH_SIZE) { + const batch = sectionNodes.slice(i, i + BATCH_SIZE); + const batchFragment = document.createDocumentFragment(); + batch.forEach((node) => batchFragment.appendChild(node)); + if (insertBefore && insertBefore.parentNode === container) { + container.insertBefore(batchFragment, insertBefore); + } else { + container.appendChild(batchFragment); + } + batch.forEach((sectionEl) => { + if (sectionEl?.nodeType === 1) registerLazyImagesInSection(sectionEl); + }); + if (i + BATCH_SIZE < sectionNodes.length) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + // Set up section reveal animations if enabled (opacity/transform only — no display toggle) + if (revealSectionsSequentially) { + initializeSectionRevealObserver(); + sectionEnhancementTargets.forEach(({ element }) => { + observeSectionForReveal(element); + }); + } + + const enhanceOptions = { + enhanceOnVisible, + enhanceRootMargin, + revealSectionsSequentially, + }; + + for (let index = 0; index < sectionEnhancementTargets.length; index++) { + const sectionStartTime = performance.now(); + const { element: sectionElement, section } = sectionEnhancementTargets[index]; + + if (Array.isArray(section.config.items) && section.config.items.length > 0) { + continue; + } + scheduleSectionProgressiveEnhancement(sectionElement, section, enhanceOptions); + + const sectionEndTime = performance.now(); + const sectionDuration = sectionEndTime - sectionStartTime; + console.log(`Section ${index} progressive initialization time: ${sectionDuration.toFixed(2)}ms`); + } + const renderProgressiveSectionsEndTime = performance.now(); + const renderProgressiveSectionsDuration = renderProgressiveSectionsEndTime - renderProgressiveSectionsStartTime; + console.log(`Render progressive sections initialization time: ${renderProgressiveSectionsDuration.toFixed(2)}ms`); + }, + + /** + * Unified function to render progressive sections (Cards or Spotlight) + * @param {HTMLElement} container - The parent container to append the section to + * @param {Array|Promise} itemsOrPromise - Data array or Promise resolving to items + * @param {string} title - Section title + * @param {Object} options - Configuration options + */ + renderProgressiveCards: function (container, itemsOrPromise, title, options = {}) { + const { + spotlight = false, + viewMoreUrl = null, + cardFormat = null, + overflowCard = false, + sortOrder = null, + sortOrderDirection = 'Ascending', + sectionId = null, + order = null, + expectedItemType = null, + minimumItems = 1, + } = options; + + // Handle Random card format once + let resolvedFormat = cardFormat; + if (cardFormat === 'random' || cardFormat === 'Random') { + resolvedFormat = getRandomCardFormat(); + } + + // Function to setup attributes + const setupElement = (el) => { + if (sectionId) el.setAttribute('data-section-id', sectionId); + if (order !== null) el.style.order = order; + // Stamp resolved format for consistency on updates + if (resolvedFormat && !spotlight) el.setAttribute('data-card-format', resolvedFormat); + }; + + // Helper to render final content + const renderContent = (items) => { + if (!items || items.length < minimumItems) return null; + + // Sort items + let sortedItems = items; + if (sortOrder && sortOrder !== 'Random') { + sortedItems = sortItems(items, sortOrder, sortOrderDirection); + } else if (sortOrder === 'Random') { + sortedItems = shuffle([...items]); + } + + if (spotlight) { + return createSpotlightSection(sortedItems, title, { viewMoreUrl }); + } else { + return createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, resolvedFormat); + } + }; + + // Handle Promise + if (itemsOrPromise && typeof itemsOrPromise.then === 'function') { + // Render Skeleton + let skeleton; + if (spotlight) { + skeleton = createSkeletonSpotlightSection(title, { viewMoreUrl }); + } else { + skeleton = createProgressivelyEnhancedScrollableContainer(title, viewMoreUrl, resolvedFormat, overflowCard); + } + setupElement(skeleton); + container.appendChild(skeleton); + + // Handle Resolution + itemsOrPromise + .then((items) => { + const content = renderContent(items); + if (content) { + setupElement(content); + // Copy of any existing data attributes to the new content + Array.from(skeleton.attributes).forEach((attr) => { + if (attr.name === 'class') return; + content.setAttribute(attr.name, attr.value); + }); + stripCopiedSkeletonSectionClasses(content); + unobserveSkeletonShimmer(skeleton); + skeleton.replaceWith(content); + ensureCardBorders(content); + ensureSectionRevealObservation(content); + } else { + unobserveSkeletonShimmer(skeleton); + skeleton.remove(); + } + }) + .catch((err) => { + console.error('[CardBuilder] Error loading items:', err); + unobserveSkeletonShimmer(skeleton); + skeleton.remove(); + }); + + return skeleton; + } + + // Handle Direct Array + else { + const items = Array.isArray(itemsOrPromise) ? itemsOrPromise : []; + const content = renderContent(items); + if (content) { + setupElement(content); + container.appendChild(content); + return content; + } + } + }, + + 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) { LOG(`No item IDs provided for section: ${title}`); return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat); @@ -143,18 +3573,18 @@ // Fetch all items at once const response = await ApiClient.getItems(ApiClient.getCurrentUserId(), { Ids: itemIds.join(','), - Recursive: false + Recursive: false, }); const items = response.Items; - + LOG(`Fetched ${items.length} items for section: ${title}`); - + // 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') { @@ -162,25 +3592,80 @@ } else if (sortOrder === 'Random') { sortedItems = shuffle([...items]); } - - return createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat); + + const container = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat); + return container; } 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 {Array|Promise} items - Array of Jellyfin item objects or Promise resolving to array * @param {string} title - Title for the spotlight section * @param {Object} options - Options for the spotlight carousel + * @param {boolean} options.progressiveEnhancement - If true and items is a Promise, render skeleton first * @returns {HTMLElement} - The constructed spotlight container */ - renderSpotlightSection: function(items, title, options = {}) { - return createSpotlightSection(items, title, options); + renderSpotlightSection: function (items, title, options = {}) { + const { progressiveEnhancement = false } = options; + + // If progressive enhancement is enabled and items is a Promise + if (progressiveEnhancement && items && typeof items.then === 'function') { + // Create skeleton spotlight section immediately + const skeletonSpotlightContainer = createSkeletonSpotlightSection(title, options); + + // Add identifying data-id + const randomId = Math.random().toString(36).substring(2, 15); + skeletonSpotlightContainer.setAttribute('data-skeleton-id', randomId); + + // When promise resolves, replace skeleton with real spotlight + items + .then(async (items) => { + // Remove the skeleton container if it exists + let skeletonContainerElement = document.querySelector(`[data-skeleton-id="${randomId}"]`); + + // Let's try to find the skeleton 20 times every 100ms + for (let i = 0; i < 20; i++) { + skeletonContainerElement = document.querySelector(`[data-skeleton-id="${randomId}"]`); + if (skeletonContainerElement) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + // Generate real spotlight section + const realContainer = createSpotlightSection(items, title, options); + + // Copy critical attributes to preserve layout/order + if (skeletonContainerElement.style.order) { + realContainer.style.order = skeletonContainer.style.order; + } + if (skeletonContainerElement.hasAttribute('data-section-id')) { + realContainer.setAttribute('data-section-id', skeletonContainerElement.getAttribute('data-section-id')); + } + + // Replace skeleton container with real container + unobserveSkeletonShimmer(skeletonContainerElement); + skeletonContainerElement.replaceWith(realContainer); + }) + .catch((error) => { + console.error('[KefinTweaks CardBuilder] Error loading items for spotlight progressive enhancement:', error); + // Optionally remove skeleton on error or show error state + unobserveSkeletonShimmer(skeletonSpotlightContainer); + skeletonSpotlightContainer.remove(); + }); + + return skeletonSpotlightContainer; + } + + // Normal rendering (items is already an array) + const container = createSpotlightSection(items, title, options); + return container; }, - + /** * Sorts items based on sort order and direction (exposed for use in homeScreen) * @param {Array} items - Array of Jellyfin items @@ -188,50 +3673,692 @@ * @param {string} sortOrderDirection - Direction * @returns {Array} - Sorted array */ - sortItems: function(items, sortOrder, sortOrderDirection) { + sortItems: function (items, sortOrder, sortOrderDirection) { return sortItems(items, sortOrder, sortOrderDirection); - } + }, + + updateCardResumeAttributes: updateCardResumeAttributes, }; + /** + * Blurhash base83 character set (must match official blurhash package: includes comma and period) + * https://github.com/woltapp/blurhash/blob/master/TypeScript/src/base83.ts + */ + const BLURHASH_B83 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'; + + function blurhashDecode83(str, from, length) { + if (from + length > str.length) return NaN; + let value = 0; + for (let i = 0; i < length; i++) { + const idx = BLURHASH_B83.indexOf(str[from + i]); + if (idx < 0) return NaN; + value = value * 83 + idx; + } + return value; + } + + function blurhashSignPow(val, exp) { + const sign = val < 0 ? -1 : 1; + return sign * Math.pow(Math.abs(val), exp); + } + + function blurhashLinearToSrgb(value) { + const v = Math.max(0, Math.min(1, value)); + return v <= 0.0031308 ? Math.trunc(v * 12.92 * 255 + 0.5) : Math.trunc((1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5); + } + + function blurhashSrgbToLinear(value) { + const v = value / 255; + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + } + + /** + * Decode a blurhash string to RGBA pixel data (Uint8ClampedArray). + * Matches the official blurhash package (same as Jellyfin's worker uses). + * Layout: 1 char size, 1 char quantised max, 4 chars DC (24-bit), then 2 chars per AC (base-19 packed). + */ + function blurhashDecode(blurhash, width, height) { + if (!blurhash || blurhash.length < 6) return null; + try { + const sizeVal = blurhashDecode83(blurhash, 0, 1); + if (isNaN(sizeVal)) return null; + const numX = (sizeVal % 9) + 1; + const numY = Math.floor(sizeVal / 9) + 1; + const expectedLen = 4 + 2 * numX * numY; + if (blurhash.length !== expectedLen) return null; + + const quantisedMax = blurhashDecode83(blurhash, 1, 1); + if (isNaN(quantisedMax)) return null; + const maxVal = (quantisedMax + 1) / 166; + + const components = []; + for (let i = 0; i < numX * numY; i++) { + if (i === 0) { + const dc = blurhashDecode83(blurhash, 2, 4); + if (isNaN(dc)) return null; + const intR = dc >> 16; + const intG = (dc >> 8) & 255; + const intB = dc & 255; + components.push([blurhashSrgbToLinear(intR), blurhashSrgbToLinear(intG), blurhashSrgbToLinear(intB)]); + } else { + const acVal = blurhashDecode83(blurhash, 4 + i * 2, 2); + if (isNaN(acVal)) return null; + const quantR = Math.floor(acVal / (19 * 19)); + const quantG = Math.floor(acVal / 19) % 19; + const quantB = acVal % 19; + components.push([ + blurhashSignPow((quantR - 9) / 9, 2) * maxVal, + blurhashSignPow((quantG - 9) / 9, 2) * maxVal, + blurhashSignPow((quantB - 9) / 9, 2) * maxVal, + ]); + } + } + + const pixels = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let r = 0, + g = 0, + b = 0; + for (let j = 0; j < numY; j++) { + const basisY = Math.cos((Math.PI * y * j) / height); + for (let i = 0; i < numX; i++) { + const basis = Math.cos((Math.PI * x * i) / width) * basisY; + const color = components[i + j * numX]; + r += color[0] * basis; + g += color[1] * basis; + b += color[2] * basis; + } + } + const off = (y * width + x) * 4; + pixels[off] = blurhashLinearToSrgb(r); + pixels[off + 1] = blurhashLinearToSrgb(g); + pixels[off + 2] = blurhashLinearToSrgb(b); + pixels[off + 3] = 255; + } + } + return pixels; + } catch (e) { + return null; + } + } + + /** + * Draw decoded blurhash into an existing canvas (e.g. 20x20). + * @param {HTMLCanvasElement} canvas - Canvas to draw into + * @param {string} blurhashString - Blurhash string + * @returns {boolean} - True if drawn successfully + */ + function drawBlurhashToCanvas(canvas, blurhashString) { + if (!canvas || !blurhashString) return false; + const w = canvas.width || 20; + const h = canvas.height || 20; + const cacheKey = blurhashCacheKey(blurhashString, w, h); + let pixels = blurhashCacheGet(cacheKey); + if (!pixels) { + pixels = blurhashDecode(blurhashString, w, h); + if (pixels) blurhashCacheSet(cacheKey, pixels); + } + if (!pixels) return false; + return applyBlurhashPixelsToCanvas(canvas, pixels); + } + + // --- Deferred blurhash: cache + worker + near-viewport filler --- + const BLURHASH_CACHE_MAX = 200; + const blurhashPixelCache = new Map(); + let blurhashFillObserver = null; + let blurhashWorker = null; + let blurhashWorkerReqId = 0; + const blurhashWorkerPending = new Map(); + const blurhashIdleQueue = []; + let blurhashIdleScheduled = false; + + function blurhashCacheKey(blurhash, width, height) { + return blurhash + '|' + width + 'x' + height; + } + + function blurhashCacheGet(key) { + if (!blurhashPixelCache.has(key)) return null; + const val = blurhashPixelCache.get(key); + blurhashPixelCache.delete(key); + blurhashPixelCache.set(key, val); + return val; + } + + function blurhashCacheSet(key, pixels) { + if (blurhashPixelCache.has(key)) blurhashPixelCache.delete(key); + blurhashPixelCache.set(key, pixels); + while (blurhashPixelCache.size > BLURHASH_CACHE_MAX) { + const oldest = blurhashPixelCache.keys().next().value; + blurhashPixelCache.delete(oldest); + } + } + + function shouldSkipBlurhashDecode() { + if (!state.useBlurhash) return true; + try { + if (navigator.connection && navigator.connection.saveData === true) return true; + } catch (e) { + /* ignore */ + } + try { + if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return true; + } catch (e) { + /* ignore */ + } + return false; + } + + function getBlurhashFillRootMargin() { + return isMobileLayout() ? '120px' : '200px'; + } + + /** + * Put pixels on canvas and show blurhash until the real image loads. + * @returns {boolean} + */ + function applyBlurhashPixelsToCanvas(canvas, pixels) { + if (!canvas || !pixels || !canvas.isConnected) return false; + const w = canvas.width || 20; + const h = canvas.height || 20; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return false; + + const imageData = ctx.createImageData(w, h); + imageData.data.set(pixels); + ctx.putImageData(imageData, 0, 0); + canvas.setAttribute('data-blurhash-painted', 'true'); + canvas.removeAttribute('data-blurhash-pending'); + + const cardImageContainer = canvas.nextElementSibling; + const imageAlreadyLoaded = + cardImageContainer && cardImageContainer.classList && cardImageContainer.classList.contains('lazy-loaded'); + + if (imageAlreadyLoaded) { + canvas.classList.add('lazy-hidden'); + return true; + } + + canvas.classList.remove('lazy-hidden'); + if (cardImageContainer && cardImageContainer.classList.contains('cardImageContainer')) { + cardImageContainer.classList.add('lazy-hidden'); + } + return true; + } + + function getBlurhashWorkerSource() { + return [ + 'const BLURHASH_B83 = ' + JSON.stringify(BLURHASH_B83) + ';', + blurhashDecode83.toString(), + blurhashSignPow.toString(), + blurhashLinearToSrgb.toString(), + blurhashSrgbToLinear.toString(), + blurhashDecode.toString(), + 'self.onmessage = function(e) {', + ' var data = e.data || {};', + ' var id = data.id;', + ' try {', + ' var pixels = blurhashDecode(data.blurhash, data.width || 20, data.height || 20);', + ' if (!pixels) { self.postMessage({ id: id, ok: false }); return; }', + ' self.postMessage({ id: id, ok: true, pixels: pixels.buffer }, [pixels.buffer]);', + ' } catch (err) {', + ' self.postMessage({ id: id, ok: false, error: String(err && err.message || err) });', + ' }', + '};', + ].join('\n'); + } + + function ensureBlurhashWorker() { + if (blurhashWorker) return blurhashWorker; + if (typeof Worker === 'undefined') return null; + try { + const blob = new Blob([getBlurhashWorkerSource()], { type: 'application/javascript' }); + const url = URL.createObjectURL(blob); + const worker = new Worker(url); + worker.onmessage = (e) => { + const data = e.data || {}; + const pending = blurhashWorkerPending.get(data.id); + blurhashWorkerPending.delete(data.id); + if (!pending) return; + if (!data.ok || !data.pixels) { + pending.reject(new Error(data.error || 'Blurhash worker decode failed')); + return; + } + pending.resolve(new Uint8ClampedArray(data.pixels)); + }; + worker.onerror = (err) => { + console.warn('[KefinTweaks CardBuilder] Blurhash worker error:', err); + blurhashWorkerPending.forEach((p) => p.reject(err)); + blurhashWorkerPending.clear(); + try { + worker.terminate(); + } catch (e) { + /* ignore */ + } + try { + URL.revokeObjectURL(url); + } catch (e) { + /* ignore */ + } + blurhashWorker = null; + }; + blurhashWorker = worker; + return worker; + } catch (e) { + console.warn('[KefinTweaks CardBuilder] Could not create blurhash worker:', e); + return null; + } + } + + function decodeBlurhashInWorker(blurhash, width, height) { + return new Promise((resolve, reject) => { + const worker = ensureBlurhashWorker(); + if (!worker) { + reject(new Error('Blurhash worker unavailable')); + return; + } + const id = ++blurhashWorkerReqId; + blurhashWorkerPending.set(id, { resolve, reject }); + try { + worker.postMessage({ id, blurhash, width, height }); + } catch (e) { + blurhashWorkerPending.delete(id); + reject(e); + } + }); + } + + function scheduleIdleBlurhashDrain() { + if (blurhashIdleScheduled) return; + blurhashIdleScheduled = true; + const run = (deadline) => { + blurhashIdleScheduled = false; + let n = 0; + while (blurhashIdleQueue.length && n < 4) { + if (deadline && typeof deadline.timeRemaining === 'function' && deadline.timeRemaining() <= 0 && n > 0) { + break; + } + const job = blurhashIdleQueue.shift(); + n++; + if (!job || !job.canvas || !job.canvas.isConnected) continue; + if (!job.canvas.hasAttribute('data-blurhash-pending')) continue; + const cacheKey = blurhashCacheKey(job.blurhash, job.w, job.h); + let pixels = blurhashCacheGet(cacheKey); + if (!pixels) { + pixels = blurhashDecode(job.blurhash, job.w, job.h); + if (pixels) blurhashCacheSet(cacheKey, pixels); + } + if (pixels) applyBlurhashPixelsToCanvas(job.canvas, pixels); + else job.canvas.removeAttribute('data-blurhash-pending'); + } + if (blurhashIdleQueue.length) scheduleIdleBlurhashDrain(); + }; + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(run, { timeout: 500 }); + } else { + requestAnimationFrame(() => run({ timeRemaining: () => 5 })); + } + } + + function enqueueIdleBlurhashDecode(canvas, blurhash, width, height) { + blurhashIdleQueue.push({ canvas, blurhash, w: width, h: height }); + scheduleIdleBlurhashDrain(); + } + + async function fillBlurhashCanvas(canvas) { + if (!canvas || !canvas.isConnected) return; + const blurhash = canvas.getAttribute('data-blurhash-pending'); + if (!blurhash) return; + if (shouldSkipBlurhashDecode()) { + canvas.removeAttribute('data-blurhash-pending'); + canvas.classList.add('lazy-hidden'); + return; + } + + const w = canvas.width || 20; + const h = canvas.height || 20; + const cacheKey = blurhashCacheKey(blurhash, w, h); + const cached = blurhashCacheGet(cacheKey); + if (cached) { + applyBlurhashPixelsToCanvas(canvas, cached); + return; + } + + try { + const pixels = await decodeBlurhashInWorker(blurhash, w, h); + if (!canvas.isConnected || canvas.getAttribute('data-blurhash-pending') !== blurhash) return; + blurhashCacheSet(cacheKey, pixels); + applyBlurhashPixelsToCanvas(canvas, pixels); + } catch (e) { + enqueueIdleBlurhashDecode(canvas, blurhash, w, h); + } + } + + function observeBlurhashCanvas(canvas) { + if (!canvas || !canvas.hasAttribute('data-blurhash-pending')) return; + if (shouldSkipBlurhashDecode()) { + canvas.removeAttribute('data-blurhash-pending'); + canvas.classList.add('lazy-hidden'); + return; + } + initBlurhashFillObserver(); + if (blurhashFillObserver) { + blurhashFillObserver.observe(canvas); + } else { + fillBlurhashCanvas(canvas); + } + } + + function initBlurhashFillObserver() { + if (blurhashFillObserver) return; + if (typeof IntersectionObserver === 'undefined') return; + + blurhashFillObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + const canvas = entry.target; + blurhashFillObserver.unobserve(canvas); + fillBlurhashCanvas(canvas); + }); + }, + { + threshold: 0, + rootMargin: getBlurhashFillRootMargin(), + }, + ); + } + + function observePendingBlurhashInNode(node) { + if (!node || node.nodeType !== 1) return; + if (node.matches && node.matches('canvas.blurhash-canvas[data-blurhash-pending]')) { + observeBlurhashCanvas(node); + } + if (node.querySelectorAll) { + node.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvas); + } + } + + /** + * Get blurhash string for the card image when the image is from the current item. + * Mirrors the image URL logic; returns undefined when image comes from parent/series (we don't have their ImageBlurHashes). + * @param {Object} item - Jellyfin item + * @param {string|null} cardFormat - 'backdrop'|'square'|'portrait'|'poster'|'thumb'|'series thumb'|'series poster'|null + * @returns {string|undefined} - Blurhash string or undefined + */ + function getBlurhashForCard(item, cardFormat) { + const hashes = item.ImageBlurHashes; + if (!hashes || typeof hashes !== 'object') return undefined; + cardFormat = cardFormat?.toLowerCase() || null; + + if (cardFormat === 'backdrop') { + if (item.BackdropImageTags && item.BackdropImageTags[0]) return hashes.Backdrop?.[item.BackdropImageTags[0]]; + if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb]; + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + return undefined; + } + if (cardFormat === 'square') { + if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') { + if (item.BackdropImageTags && item.BackdropImageTags[0]) return hashes.Backdrop?.[item.BackdropImageTags[0]]; + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + return undefined; + } + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + if (item.BackdropImageTags && item.BackdropImageTags[0]) return hashes.Backdrop?.[item.BackdropImageTags[0]]; + if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb]; + return undefined; + } + if (cardFormat === 'portrait' || cardFormat === 'poster') { + if (item.Type === 'Episode' && item.SeriesPrimaryImageTag) return undefined; + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + if (item.PrimaryImageTag) return hashes.Primary?.[item.PrimaryImageTag]; + if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb]; + return undefined; + } + if (cardFormat === 'series poster') { + if (item.SeriesPrimaryImageTag) return undefined; + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + return undefined; + } + if (cardFormat === 'series thumb') { + if (item.ParentThumbImageTag) return undefined; + if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) return undefined; + if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb]; + if (item.BackdropImageTags && item.BackdropImageTags[0]) return hashes.Backdrop?.[item.BackdropImageTags[0]]; + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + return undefined; + } + if (cardFormat === 'thumb') { + if (item.Type === 'Episode') { + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + if (item.PrimaryImageTag) return hashes.Primary?.[item.PrimaryImageTag]; + if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb]; + return undefined; + } + if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb]; + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + return undefined; + } + if (item.IsJellyseerr) return undefined; + if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary]; + if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb]; + if (item.BackdropImageTags && item.BackdropImageTags.length > 0) return hashes.Backdrop?.[item.BackdropImageTags[0]]; + return undefined; + } + + function useEpisodeImagesInNextUpAndResume(userId) { + return localStorage.getItem(`${userId}-useEpisodeImagesInNextUpAndResume`) === 'true'; + } + + function getItemMaterialIcon(item) { + const itemType = item?.Type; + if (itemType === 'CollectionFolder' || item?.CollectionType) { + switch ((item.CollectionType || '').toLowerCase()) { + case 'tvshows': + return 'tv'; + case 'movies': + return 'movie'; + case 'boxsets': + return 'video_library'; + case 'playlists': + return 'queue'; + case 'books': + return 'book'; + case 'music': + return 'music_note'; + case 'homevideos': + return 'photo'; + case 'folders': + return 'folder'; + case 'livetv': + return 'live_tv'; + default: + return 'folder'; + } + } + if (itemType === 'Movie') return 'movie'; + if (itemType === 'Series' || itemType === 'Season') return 'tv'; + if (itemType === 'Episode' || itemType === 'Video') return 'play_circle'; + if (itemType === 'TvChannel' || itemType === 'Program') return 'live_tv'; + if (itemType === 'Playlist') return 'queue'; + if (itemType === 'BoxSet') return 'video_library'; + if (itemType === 'Book') return 'book'; + if (itemType === 'Person' || itemType === 'MusicArtist' || itemType === 'Artist') return 'person'; + if (itemType === 'MusicAlbum') return 'album'; + if (itemType === 'Audio') return 'music_note'; + return 'folder'; + } + + function buildCollectionFolderHref(item, serverId, options = {}) { + const { useHtmlExtension = false } = options; + if (!item?.Id || (item.Type !== 'CollectionFolder' && !item.CollectionType)) return null; + + const id = item.Id; + const ext = useHtmlExtension ? '.html' : ''; + switch ((item.CollectionType || '').toLowerCase()) { + case 'movies': + return `#/movies${ext}?topParentId=${id}&collectionType=movies`; + case 'tvshows': + return `#/tv${ext}?topParentId=${id}&collectionType=tvshows`; + case 'music': + return `#/music${ext}?topParentId=${id}&collectionType=music`; + case 'livetv': + return `#/livetv${ext}?collectionType=livetv`; + default: + return `#/list${ext}?parentId=${id}&serverId=${serverId}`; + } + } + + function getCollectionFolderUrl(item, serverId) { + return buildCollectionFolderHref(item, serverId, { useHtmlExtension: false }); + } + + function createLibraryButtonElement(item) { + const serverId = ApiClient.serverId(); + const icon = getItemMaterialIcon(item); + const href = item.cardUrl || getCollectionFolderUrl(item, serverId) || `#/details?id=${item.Id}&serverId=${serverId}`; + + const link = document.createElement('a'); + link.setAttribute('is', 'emby-linkbutton'); + link.href = href; + link.className = 'raised homeLibraryButton emby-button'; + if (item.Id) { + link.setAttribute('data-id', item.Id); + } + + const iconSpan = document.createElement('span'); + iconSpan.className = `material-icons homeLibraryIcon ${icon}`; + iconSpan.setAttribute('aria-hidden', 'true'); + + const textSpan = document.createElement('span'); + textSpan.className = 'homeLibraryText'; + textSpan.textContent = item.Name || ''; + + link.appendChild(iconSpan); + link.appendChild(textSpan); + return link; + } + + /** + * Hi-res image request for a card format: ~2× the classic fillHeight/fillWidth + * sizes (backdrop 267×474, square 297, banner 100×540, portrait 446×297). + * @param {string|null} cardFormat + * @returns {string} query params (no leading ?) + */ + function getDoubledAspectRatioImageParams(cardFormat) { + const format = (cardFormat || '').toLowerCase(); + let width; + let height; + switch (format) { + case 'backdrop': + case 'thumb': + case 'series thumb': + case 'logo': + case 'clear art': + // fillHeight=267&fillWidth=474 → ×2 + width = 948; + height = 534; + break; + case 'square': + case 'disc': + // fillHeight=297&fillWidth=297 → ×2 + width = 594; + height = 594; + break; + case 'banner': + // fillHeight=100&fillWidth=540 → ×2 + width = 1080; + height = 200; + break; + case 'portrait': + case 'poster': + case 'series poster': + default: + // fillHeight=446&fillWidth=297 → ×2 + width = 594; + height = 892; + break; + } + return `fillHeight=${height}&fillWidth=${width}`; + } + /** * 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', 'thumb', or 'square' + * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', 'series thumb', 'series poster' * @param {string} customFooterText - Optional custom footer text (e.g., air date for episodes) + * @param {boolean} useParentCard - When true, the parent item will be rendered as the card image and link + * @param {string|null} borderStyle - Section card border style; used to gate retro marquee attrs * @returns {HTMLElement} - The constructed card element */ - function createJellyfinCardElement(item, overflowCard = false, cardFormat = null, customFooterText = null) { + function createJellyfinCardElement( + item, + overflowCard = false, + cardFormat = null, + customFooterText = null, + useParentCard = false, + borderStyle = null, + cardImageParams = null + ) { + cardFormat = cardFormat?.toLowerCase() || null; + if (cardFormat === 'button') { + return createLibraryButtonElement(item); + } + const serverId = ApiClient.serverId(); const serverAddress = ApiClient.serverAddress(); - + const itemId = item.Id || ''; + const itemType = item.Type || 'Folder'; + const isCustomCard = !!(item.cardUrl || item.posterUrl || item.thumbUrl || item.squareUrl || item.imageUrl); + let parentItem = null; + + if (item.Type === 'Timer') { + parentItem = item; + item = parentItem.ProgramInfo; + } + // Determine card type based on cardFormat override or item type let cardClass, padderClass, imageParams; - cardFormat = cardFormat?.toLowerCase() || null; - + + // Use custom footer text from item property if not explicitly provided + if (!customFooterText && item.CustomFooterText) { + customFooterText = item.CustomFooterText; + } + if (cardFormat) { // Use specified cardFormat - if (cardFormat === 'backdrop' || cardFormat === 'thumb') { + if ( + cardFormat === 'backdrop' || + cardFormat === 'thumb' || + cardFormat === 'series thumb' || + cardFormat === 'logo' || + cardFormat === 'clear art' + ) { cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard'; padderClass = 'cardPadder-backdrop'; imageParams = 'fillHeight=267&fillWidth=474'; - } else if (cardFormat === 'square') { + } else if (cardFormat === 'square' || cardFormat === 'disc') { cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard'; padderClass = 'cardPadder-square'; imageParams = 'fillHeight=297&fillWidth=297'; + } else if (cardFormat === 'banner') { + cardClass = overflowCard ? 'overflowBannerCard' : 'bannerCard'; + padderClass = 'cardPadder-banner'; + imageParams = 'fillHeight=100&fillWidth=540'; } else { - // portrait (default) + // portrait / poster / series poster (default) cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard'; padderClass = 'cardPadder-portrait'; imageParams = 'fillHeight=446&fillWidth=297'; } } else { // Use item type to determine card type - if (item.Type === 'Episode' || item.Type === 'TvChannel') { + if (itemType === 'Episode' || itemType === 'TvChannel') { cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard'; padderClass = 'cardPadder-backdrop'; imageParams = 'fillHeight=267&fillWidth=474'; - } else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(item.Type)) { + } else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(itemType)) { cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard'; padderClass = 'cardPadder-square'; imageParams = 'fillHeight=297&fillWidth=297'; @@ -242,21 +4369,57 @@ imageParams = 'fillHeight=446&fillWidth=297'; } } - + + if (cardImageParams) { + imageParams = cardImageParams; + } + + const cardLinkId = useParentCard && (item.SeriesId || item.ParentId) ? item.SeriesId || item.ParentId : itemId; + // Create the main card container const card = document.createElement('div'); - card.className = `card ${cardClass} card-hoverable card-withuserdata`; + card.className = `card ${cardClass}${!isMobileLayout() ? " card-hoverable" : ""} card-withuserdata`; card.setAttribute('data-index', '0'); - card.setAttribute('data-isfolder', item.Type === 'MusicAlbum' || item.Type === 'Artist' ? 'true' : 'false'); + card.setAttribute('data-isfolder', itemType === 'MusicAlbum' || itemType === 'Artist' || item.IsFolder ? 'true' : 'false'); card.setAttribute('data-serverid', serverId); - card.setAttribute('data-id', item.Id); - card.setAttribute('data-type', item.Type); - card.setAttribute('data-mediatype', item.MediaType || 'Video'); + if (!isCustomCard) card.setAttribute('data-id', cardLinkId); + if (isCustomCard) card.setAttribute('data-custom-card', 'true'); + card.setAttribute('data-type', itemType); + if (normalizeCardBorderStyle(borderStyle) === 'retro-poster') { + card.setAttribute('data-retro-marquee', getRetroPosterMarqueeLabel(item)); + } + const mediaType = item.MediaType === 'Unknown' && item.ChannelId ? 'Video' : item.MediaType; + card.setAttribute('data-mediatype', mediaType || 'Video'); + + if (item.CollectionType) { + card.setAttribute('data-collectiontype', item.CollectionType); + } + + if (item.IsFolder && item.Path) { + card.setAttribute('data-path', item.Path); + } + + if (item.ChannelId) { + card.setAttribute('data-channelid', item.ChannelId); + } + + if (item.StartDate) { + card.setAttribute('data-startdate', item.StartDate); + } + + if (item.EndDate) { + card.setAttribute('data-enddate', item.EndDate); + } + card.setAttribute('data-prefix', item.Name?.startsWith('The ') ? 'THE' : ''); + if (item.UserData?.PlaybackPositionTicks && item.UserData?.PlaybackPositionTicks > 0) { + card.setAttribute('data-positionticks', item.UserData?.PlaybackPositionTicks); + } + // Card box container const cardBox = document.createElement('div'); - cardBox.className = 'cardBox cardBox-bottompadded'; + cardBox.className = `cardBox cardBox-bottompadded ${cardFormat === "button" ? "emby-button emby-button-foreground raised" : ""}`; // Card scalable container const cardScalable = document.createElement('div'); @@ -265,48 +4428,64 @@ // Card padder with icon const cardPadder = document.createElement('div'); cardPadder.className = `cardPadder ${padderClass} lazy-hidden-children`; - + const cardIcon = document.createElement('span'); cardIcon.className = 'cardImageIcon material-icons'; cardIcon.setAttribute('aria-hidden', 'true'); - + // Set icon based on item type - if (item.Type === 'Movie') { - cardIcon.textContent = 'movie'; - } else if (item.Type === 'Series') { - cardIcon.textContent = 'tv'; - } else if (item.Type === 'Episode') { - cardIcon.textContent = 'play_circle'; - } else if (item.Type === 'MusicAlbum') { - cardIcon.textContent = 'album'; - } else if (item.Type === 'Audio') { - cardIcon.textContent = 'music_note'; - } else if (item.Type === 'Artist') { - cardIcon.textContent = 'person'; - } else { - cardIcon.textContent = 'folder'; - } - + cardIcon.textContent = getItemMaterialIcon(item); + cardPadder.appendChild(cardIcon); - // Blurhash canvas (placeholder) + // Blurhash canvas (placeholder) – will be filled and shown when we have a blurhash const blurhashCanvas = document.createElement('canvas'); blurhashCanvas.setAttribute('aria-hidden', 'true'); blurhashCanvas.width = 20; blurhashCanvas.height = 20; - blurhashCanvas.className = 'blurhash-canvas lazy-hidden'; + // lazy-hidden only when we have no blurhash (grey card until image loads) + blurhashCanvas.className = 'blurhash-canvas'; - // Card image container - const cardImageContainer = document.createElement('a'); - cardImageContainer.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`; - cardImageContainer.className = 'cardImageContainer coveredImage cardContent itemAction lazy blurhashed lazy-image-fadein-fast'; - cardImageContainer.setAttribute('data-action', 'link'); - cardImageContainer.setAttribute('aria-label', item.Name || 'Unknown'); + let libraryUrl = ''; + if (item.Type === 'CollectionFolder') { + libraryUrl = buildCollectionFolderHref(item, serverId, { useHtmlExtension: true }) || ''; + } - // Force specific image if card format is specified - if (cardFormat === 'backdrop') { - let imageUrl = ''; - if (item.BackdropImageTags[0]) { + let cardUrl = item.cardUrl || libraryUrl || `#/details?id=${cardLinkId}&serverId=${serverId}`; + + if (!item.cardUrl && itemType === 'Genre') { + const parentParam = item.ParentId ? `&parentId=${item.ParentId}` : ''; + cardUrl = `#/list.html?genreId=${item.Id}&serverId=${serverId}${parentParam}`; + } + + let imageUrl = ''; + + const hasCustomImages = + item.posterUrl || item.thumbUrl || item.squareUrl || item.imageUrl || item.backdropUrl || item.bannerUrl || item.logoUrl; + + const imageItem = item.Type === 'Timer' ? item.ProgramInfo : item; + + if (hasCustomImages) { + if (cardFormat === 'backdrop' || cardFormat === 'thumb' || cardFormat === 'series thumb') { + imageUrl = `${item.backdropUrl || item.thumbUrl || item.imageUrl || item.posterUrl || item.squareUrl}`; + } else if (cardFormat === 'banner') { + imageUrl = `${item.bannerUrl || item.backdropUrl || item.thumbUrl || item.imageUrl || item.posterUrl || item.squareUrl}`; + } else if (cardFormat === 'logo' || cardFormat === 'clear art') { + imageUrl = `${item.logoUrl || item.imageUrl || item.posterUrl || item.thumbUrl || item.squareUrl}`; + } else if (cardFormat === 'square') { + imageUrl = `${item.squareUrl || item.imageUrl || item.posterUrl || item.thumbUrl}`; + } else { + // portrait/poster/series poster or default + imageUrl = `${item.posterUrl || item.imageUrl || item.thumbUrl || item.squareUrl}`; + } + } else if (cardFormat === 'backdrop') { + if (useParentCard && item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) { + imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; + } else if (useParentCard && item.ParentThumbImageTag) { + imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`; + } else if (useParentCard && item.SeriesPrimaryImageTag) { + imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; + } else if (item.BackdropImageTags[0]) { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`; } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) { imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; @@ -315,195 +4494,465 @@ } else { imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`; } - cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; } else if (cardFormat === 'square') { - if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') { - const imageUrl = item.BackdropImageTags[0] ? `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}` : `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`; - cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; - } else { - const imageUrl = item.PrimaryImageTags[0] ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTags[0]}` : `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`; - cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; - } - } else if (cardFormat === 'portrait') { - if (item.Type === 'Episode' && item.SeriesPrimaryImageTag) { - const imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; - cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; - } else { - const imageUrl = item.ImageTags?.Primary ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}` : `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`; - cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; - } - } else if (cardFormat === 'thumb') { - let imageUrl = ''; - if (item.ImageTags?.Thumb) { + if (useParentCard && item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) { + imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; + } else if (useParentCard && item.ParentThumbImageTag) { + imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`; + } else if (useParentCard && item.SeriesPrimaryImageTag) { + imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; + } else if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') { + imageUrl = + item.BackdropImageTags && item.BackdropImageTags[0] + ? `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}` + : item.ImageTags?.Primary + ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}` + : ''; + } else if (item.ImageTags?.Primary) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`; + } else if (item.PrimaryImageTag && item.Type === 'Episode') { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTag}`; + } else if (item.BackdropImageTags && item.BackdropImageTags[0]) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`; + } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) { + imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; + } else if (item.ImageTags?.Thumb) { 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 if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) { - imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; + imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`; } else if (item.SeriesPrimaryImageTag) { imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; + } else if (item.PrimaryImageTag) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTag}`; + } + } else if (cardFormat === 'portrait' || cardFormat === 'poster') { + if ((useParentCard || item.Type === 'Episode') && 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}`; + } else if (item.PrimaryImageTag) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTag}`; + } else if (item.ImageTags?.Thumb) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`; + } + } else if (cardFormat === 'series poster') { + 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 (cardFormat === 'series thumb') { + if (item.ParentThumbImageTag) { + imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId || item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`; + } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) { + imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; + } else if (item.ImageTags?.Thumb) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`; + } else if (item.BackdropImageTags && item.BackdropImageTags[0]) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`; + } else if (item.ImageTags?.Primary) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`; + } + } else if (cardFormat === 'thumb') { + if (item.Type === 'Episode' && !useParentCard) { + if (item.ImageTags?.Primary) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`; + } else if (item.PrimaryImageTag) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTag}`; + } else if (item.ImageTags?.Thumb) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`; + } else if (item.ParentThumbImageTag) { + imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId || item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`; + } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[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 (useParentCard && item.ParentThumbImageTag) { + imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`; + } else if (useParentCard && item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) { + imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; + } else if (item.ImageTags?.Thumb) { + // Prefer explicit thumb on the item + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`; + } else if (item.ParentThumbImageTag) { + // Fall back to a parent thumb (e.g. series thumb for episodes) + imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`; + } else { + // No item or parent thumb – decide between primary vs backdrop based on primary orientation + const primaryAspect = typeof item.PrimaryImageAspectRatio === 'number' ? item.PrimaryImageAspectRatio : null; + const hasPrimary = !!item.ImageTags?.Primary; + const primaryLooksLikeThumb = hasPrimary && primaryAspect !== null && primaryAspect >= 1.5; + + if (primaryLooksLikeThumb) { + // Primary is a wide (e.g. 16:9) thumbnail – use it directly + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`; + } else if (item.BackdropImageTags && item.BackdropImageTags.length > 0) { + // Primary looks like a poster – prefer the item's backdrop + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`; + } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) { + // Fall back to a parent backdrop if the item has none + imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; + } else if (hasPrimary) { + // No usable backdrop – final fallback to the item's primary (likely poster) + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`; + } else if (item.SeriesPrimaryImageTag) { + // As an absolute last resort, try the series primary + imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; + } + } + } else if (cardFormat === 'logo' && (item.ImageTags?.Logo || item.ParentLogoImageTag)) { + const logoTag = item.ImageTags?.Logo || item.ParentLogoImageTag; + const itemId = item.ImageTags?.Logo ? item.Id : item.ParentLogoItemId; + imageUrl = `${serverAddress}/Items/${itemId}/Images/Logo?${imageParams}&quality=96&tag=${logoTag}`; + } else if (cardFormat === 'clear art' && item.ImageTags?.Art) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Art?${imageParams}&quality=96&tag=${item.ImageTags.Art}`; + } else if (cardFormat === 'banner' && item.ImageTags?.Banner) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Banner?${imageParams}&quality=96&tag=${item.ImageTags.Banner}`; + } else if (cardFormat === 'disc' && item.ImageTags?.Disc) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Disc?${imageParams}&quality=96&tag=${item.ImageTags.Disc}`; } else if (item.ImageTags?.Primary) { - let imageUrl; if (item.IsJellyseerr) { // Jellyseerr items use external image URLs - imageUrl = item.ImageTags.Primary.startsWith('http') - ? item.ImageTags.Primary + imageUrl = item.ImageTags.Primary.startsWith('http') + ? item.ImageTags.Primary : `https://image.tmdb.org/t/p/w500${item.ImageTags.Primary}`; } else { // Regular Jellyfin items imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`; } - cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; } else if (item.ImageTags?.Thumb) { - const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`; - cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; - } else if ((item.Type === 'Season') && item.SeriesPrimaryImageTag) { - const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; - cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; - } else if ((item.Type === 'Episode') && item.ParentThumbImageTag) { - const imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`; - cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`; + } else if (item.Type === 'Season' && item.SeriesPrimaryImageTag) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; + } else if (item.Type === 'Episode' && item.ParentThumbImageTag) { + imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`; } else if (item.BackdropImageTags && item.BackdropImageTags.length > 0) { - const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`; - cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`; } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) { - const imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; - cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; - } else { - // No image - add icon as inner element + imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; + } + + // Card image container + const cardImageContainer = isCustomCard ? document.createElement('div') : document.createElement('a'); + if (!isCustomCard) { + cardImageContainer.href = cardUrl; + cardImageContainer.setAttribute('data-action', 'link'); + } + + const defaultBgClass = !imageUrl ? `defaultCardBackground${state.defaultCardBackgroundNumber++} ` : ''; + cardImageContainer.className = + `cardImageContainer ${!imageUrl || imageUrl.length === 0 ? "defaultCardBackground " : ""}${defaultBgClass}coveredImage cardContent itemAction lazy blurhashed lazy-image-fadein-fast` + .replace(/\s+/g, ' ') + .trim(); + cardImageContainer.setAttribute('aria-label', item.Name || 'Unknown'); + + if (state.defaultCardBackgroundNumber > 5) { + state.defaultCardBackgroundNumber = 1; + } + + // No image - add icon as inner element + if (!imageUrl) { const iconSpan = document.createElement('span'); iconSpan.className = 'cardImageIcon material-icons'; iconSpan.setAttribute('aria-hidden', 'true'); - + // Set icon based on item type - if (item.Type === 'Movie') { - iconSpan.textContent = 'movie'; - } else if (item.Type === 'Series') { - iconSpan.textContent = 'tv'; - } else if (item.Type === 'Episode') { - iconSpan.textContent = 'tv'; - } else if (item.Type === 'Person') { - iconSpan.textContent = 'person'; - } else if (item.Type === 'MusicAlbum') { - iconSpan.textContent = 'album'; - } else if (item.Type === 'Audio') { - iconSpan.textContent = 'music_note'; - } else if (item.Type === 'Artist' || item.Type === 'MusicArtist') { - iconSpan.textContent = 'person'; - } else { - iconSpan.textContent = 'folder'; - } - + iconSpan.textContent = getItemMaterialIcon(item); cardImageContainer.appendChild(iconSpan); } - if (item.UserData?.Played) { - const cardIndicators = document.createElement('div'); - cardIndicators.className = 'cardIndicators'; - const playedIndicator = document.createElement('div'); - playedIndicator.className = 'playedIndicator indicator'; - const playedIndicatorIcon = document.createElement('span'); - playedIndicatorIcon.className = 'material-icons indicatorIcon check'; - playedIndicatorIcon.setAttribute('aria-hidden', 'true'); - playedIndicator.appendChild(playedIndicatorIcon); - cardIndicators.appendChild(playedIndicator); - cardImageContainer.appendChild(cardIndicators); + const blurhashStr = !item.imageUrl ? getBlurhashForCard(item, cardFormat) : null; + let includeKefinBlurhashCanvas = true; + if (!blurhashStr || cardFormat === 'logo' || cardFormat === 'clear art' || cardFormat === 'disc') { + blurhashCanvas.classList.add('lazy-hidden'); + } else if (shouldSkipBlurhashDecode()) { + // Skip CPU decode; keep default chrome / lazy image path only + blurhashCanvas.classList.add('lazy-hidden'); + } else { + // Defer decode off the construction path — near-viewport filler paints later + blurhashCanvas.classList.add('lazy-hidden'); + blurhashCanvas.setAttribute('data-blurhash-pending', blurhashStr); } - // Card overlay container - const cardOverlayContainer = document.createElement('div'); - cardOverlayContainer.className = 'cardOverlayContainer itemAction'; - cardOverlayContainer.setAttribute('data-action', 'link'); + if (imageUrl) { + // Use data-src for lazy loading instead of immediate backgroundImage + cardImageContainer.setAttribute('data-src', imageUrl); + if (blurhashStr && state.useBlurhash && cardFormat !== 'logo' && cardFormat !== 'clear art' && cardFormat !== 'disc') { + cardImageContainer.setAttribute('data-blurhash', blurhashStr); + } + // Add lazy class for styling/selection + cardImageContainer.classList.add('lazy'); + } + //countIndicator indicator - // Overlay link - const overlayLink = document.createElement('a'); - overlayLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`; - overlayLink.className = 'cardImageContainer'; + if (!isCustomCard) { + const cardIndicators = document.createElement('div'); + cardIndicators.className = 'cardIndicators'; - // Play button - const playButton = document.createElement('button'); - playButton.setAttribute('is', 'paper-icon-button-light'); - playButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light cardOverlayFab-primary'; - playButton.setAttribute('data-action', 'resume'); - - const playIcon = document.createElement('span'); - playIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow'; - playIcon.setAttribute('aria-hidden', 'true'); - playButton.appendChild(playIcon); + if (item.UserData?.UnplayedItemCount && item.UserData?.UnplayedItemCount > 0) { + const unplayedIndicator = document.createElement('div'); + unplayedIndicator.className = 'countIndicator indicator'; + unplayedIndicator.textContent = item.UserData?.UnplayedItemCount; + cardIndicators.appendChild(unplayedIndicator); + } - // Button container for additional overlay buttons (watchlist, etc.) - const buttonContainer = document.createElement('div'); - buttonContainer.className = 'cardOverlayButton-br flex'; + if (item.LocationType === 'Virtual') { + if (item.PremiereDate && new Date(item.PremiereDate) > new Date()) { + const premiereIndicator = document.createElement('div'); + premiereIndicator.className = 'unairedIndicator'; + premiereIndicator.textContent = 'Unaired'; + cardIndicators.appendChild(premiereIndicator); + } else { + const virtualIndicator = document.createElement('div'); + virtualIndicator.className = 'missingIndicator'; + virtualIndicator.textContent = 'Missing'; + cardIndicators.appendChild(virtualIndicator); + } + } - // Watched button - const watchedButton = document.createElement('button'); - watchedButton.setAttribute('is', 'emby-playstatebutton'); - watchedButton.type = 'button'; - watchedButton.setAttribute('data-action', 'none'); - watchedButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button'; - watchedButton.setAttribute('data-id', item.Id); - watchedButton.setAttribute('data-serverid', serverId); - watchedButton.setAttribute('data-itemtype', item.Type); - watchedButton.setAttribute('data-played', item.UserData?.Played || 'false'); - watchedButton.title = 'Mark played'; - - const watchedIcon = document.createElement('span'); - watchedIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check playstatebutton-icon-unplayed'; - watchedIcon.setAttribute('aria-hidden', 'true'); - watchedButton.appendChild(watchedIcon); - buttonContainer.appendChild(watchedButton); + if (item.UserData?.Played) { + const playedIndicator = document.createElement('div'); + playedIndicator.className = 'playedIndicator indicator'; + const playedIndicatorIcon = document.createElement('span'); + playedIndicatorIcon.className = 'material-icons indicatorIcon check'; + playedIndicatorIcon.setAttribute('aria-hidden', 'true'); + playedIndicator.appendChild(playedIndicatorIcon); + cardIndicators.appendChild(playedIndicator); + } - // Favorite button - const favoriteButton = document.createElement('button'); - favoriteButton.setAttribute('is', 'emby-ratingbutton'); - favoriteButton.type = 'button'; - favoriteButton.setAttribute('data-action', 'none'); - favoriteButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button'; - favoriteButton.setAttribute('data-id', item.Id); - favoriteButton.setAttribute('data-serverid', serverId); - favoriteButton.setAttribute('data-itemtype', item.Type); - favoriteButton.setAttribute('data-likes', ''); - favoriteButton.setAttribute('data-isfavorite', item.UserData?.IsFavorite || 'false'); - favoriteButton.title = 'Add to favorites'; - - const favoriteIcon = document.createElement('span'); - favoriteIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite'; - favoriteIcon.setAttribute('aria-hidden', 'true'); - favoriteButton.appendChild(favoriteIcon); - buttonContainer.appendChild(favoriteButton); + if (parentItem && parentItem.Type === 'Timer') { + const timerIndicator = document.createElement('span'); + timerIndicator.className = 'material-icons timerIndicator indicatorIcon fiber_manual_record'; + timerIndicator.setAttribute('aria-hidden', 'true'); + cardIndicators.appendChild(timerIndicator); + } - const moreButton = document.createElement('button'); - moreButton.setAttribute('is', 'paper-icon-button-light'); - moreButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light'; - moreButton.setAttribute('data-action', 'menu'); - moreButton.title = 'More'; - const moreIcon = document.createElement('span'); - moreIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert'; - moreIcon.setAttribute('aria-hidden', 'true'); - moreButton.appendChild(moreIcon); + if (cardIndicators.childElementCount > 0) { + cardImageContainer.appendChild(cardIndicators); + } - buttonContainer.appendChild(moreButton); + if (item.MediaSourceCount > 1) { + const mediaSourceIndicator = document.createElement('div'); + mediaSourceIndicator.className = 'mediaSourceIndicator'; + mediaSourceIndicator.textContent = item.MediaSourceCount; + cardImageContainer.appendChild(mediaSourceIndicator); + } + if (item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage > 0) { + const innerCardFooter = document.createElement('div'); + innerCardFooter.className = 'innerCardFooter fullInnerCardFooter innerCardFooterClear'; + cardBox.appendChild(innerCardFooter); + const itemProgressBar = document.createElement('div'); + itemProgressBar.className = 'itemProgressBar'; + const itemProgressBarForeground = document.createElement('div'); + itemProgressBarForeground.className = 'itemProgressBarForeground'; + itemProgressBarForeground.style.width = `${item.UserData?.PlayedPercentage}%`; + itemProgressBar.appendChild(itemProgressBarForeground); + innerCardFooter.appendChild(itemProgressBar); + cardImageContainer.appendChild(innerCardFooter); + } else if (item.Type === 'Program') { + const isOnNow = Date.now() >= new Date(item.StartDate).getTime() && Date.now() <= new Date(item.EndDate).getTime() + if (isOnNow) { + const progress = (Date.now() - new Date(item.StartDate).getTime()) / (new Date(item.EndDate).getTime() - new Date(item.StartDate).getTime()); + const progressPercentage = Math.round(progress * 100); + const innerCardFooter = document.createElement('div'); + innerCardFooter.className = 'innerCardFooter fullInnerCardFooter innerCardFooterClear'; + cardBox.appendChild(innerCardFooter); + const itemProgressBar = document.createElement('div'); + itemProgressBar.className = 'itemProgressBar'; + const itemProgressBarForeground = document.createElement('div'); + itemProgressBarForeground.className = 'itemProgressBarForeground'; + itemProgressBarForeground.style.width = `${progressPercentage}%`; + itemProgressBar.appendChild(itemProgressBarForeground); + innerCardFooter.appendChild(itemProgressBar); + cardImageContainer.appendChild(innerCardFooter); + } + } + } - // Assemble overlay - cardOverlayContainer.appendChild(overlayLink); - cardOverlayContainer.appendChild(playButton); - cardOverlayContainer.appendChild(buttonContainer); + // Overlays: desktop = hover FAB + br actions; mobile = native bottom-right play only + const NON_PLAYABLE_TYPES = ['CollectionFolder', 'Folder', 'UserView', 'Virtual', 'Studio', 'Genre', 'Person']; + const NO_RATE_TYPES = ['UserView', 'CollectionFolder', 'Folder', 'Program', 'Timer', 'SeriesTimer', 'Channel']; + const canShowPlayOverlay = + !isCustomCard + && !NON_PLAYABLE_TYPES.includes(item.Type) + && item.LocationType !== 'Virtual' + && item.Type !== 'Person'; + // Match jellyfin-web itemHelper.canMarkPlayed (cards: no Folder/CollectionFolder/UserView) + const canMarkPlayed = + item.Type !== 'Program' + && ( + (item.MediaType === 'Video' && item.Type !== 'TvChannel') + || (item.MediaType === 'Audio' && item.Type === 'AudioBook') + || item.Type === 'Series' + || item.Type === 'Season' + || item.Type === 'BoxSet' + || item.MediaType === 'Book' + ) + && !NON_PLAYABLE_TYPES.includes(item.Type); + // Match jellyfin-web itemHelper.canRate type denylist (do not gate on missing UserData) + const canRate = !NO_RATE_TYPES.includes(item.Type); + + let cardOverlayContainer = null; + let mobileOverlayPlayButton = null; + + if (isCustomCard) { + cardOverlayContainer = document.createElement('div'); + cardOverlayContainer.className = 'cardOverlayContainer'; + const overlayLink = document.createElement('a'); + overlayLink.href = cardUrl; + overlayLink.className = 'button-link'; + overlayLink.setAttribute('is', 'emby-linkbutton'); + if (!cardUrl.startsWith('/') && !cardUrl.startsWith('#')) { + overlayLink.setAttribute('target', '_blank'); + } + cardOverlayContainer.appendChild(overlayLink); + } else if (isMobileLayout()) { + // Match jellyfin-web layoutManager.mobile: play button with cardOverlayButton-br on the button + if (canShowPlayOverlay) { + mobileOverlayPlayButton = document.createElement('button'); + mobileOverlayPlayButton.type = 'button'; + mobileOverlayPlayButton.className = + 'cardOverlayButton cardOverlayButton-br itemAction paper-icon-button-light'; + mobileOverlayPlayButton.setAttribute('data-action', 'play'); + mobileOverlayPlayButton.title = 'Play'; + const playIcon = document.createElement('span'); + playIcon.className = 'material-icons cardOverlayButtonIcon play_arrow'; + playIcon.setAttribute('aria-hidden', 'true'); + mobileOverlayPlayButton.appendChild(playIcon); + } + } else { + cardOverlayContainer = document.createElement('div'); + cardOverlayContainer.className = 'cardOverlayContainer itemAction'; + cardOverlayContainer.setAttribute('data-action', 'link'); + + const playButton = document.createElement('button'); + playButton.className = + 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light cardOverlayFab-primary'; + playButton.setAttribute('data-action', 'resume'); + + const playIcon = document.createElement('span'); + playIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow'; + playIcon.setAttribute('aria-hidden', 'true'); + playButton.appendChild(playIcon); + + const buttonContainer = document.createElement('div'); + buttonContainer.className = 'cardOverlayButton-br flex'; + + const watchlistSupportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'BoxSet', 'Playlist', 'Video']; + if (state.watchlist && watchlistSupportedTypes.includes(itemType)) { + const isLiked = item.UserData?.Likes === true; + const watchlistButton = document.createElement('button'); + watchlistButton.type = 'button'; + watchlistButton.className = + 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat'; + watchlistButton.setAttribute('data-action', 'none'); + watchlistButton.setAttribute('data-id', itemId); + watchlistButton.setAttribute('data-active', isLiked ? 'true' : 'false'); + watchlistButton.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist'; + + const watchlistIcon = document.createElement('span'); + watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist'; + watchlistIcon.setAttribute('aria-hidden', 'true'); + watchlistButton.appendChild(watchlistIcon); + buttonContainer.appendChild(watchlistButton); + } + + if (canMarkPlayed) { + const watchedButton = document.createElement('button'); + watchedButton.setAttribute('is', 'emby-playstatebutton'); + watchedButton.type = 'button'; + watchedButton.setAttribute('data-action', 'none'); + watchedButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button'; + watchedButton.setAttribute('data-id', itemId); + watchedButton.setAttribute('data-serverid', serverId); + watchedButton.setAttribute('data-itemtype', itemType); + watchedButton.setAttribute('data-played', item.UserData?.Played || 'false'); + watchedButton.title = 'Mark played'; + + const watchedIcon = document.createElement('span'); + watchedIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check playstatebutton-icon-unplayed'; + watchedIcon.setAttribute('aria-hidden', 'true'); + watchedButton.appendChild(watchedIcon); + buttonContainer.appendChild(watchedButton); + } + + if (canRate) { + const favoriteButton = document.createElement('button'); + favoriteButton.setAttribute('is', 'emby-ratingbutton'); + favoriteButton.type = 'button'; + favoriteButton.setAttribute('data-action', 'none'); + favoriteButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button'; + favoriteButton.setAttribute('data-id', itemId); + favoriteButton.setAttribute('data-serverid', serverId); + favoriteButton.setAttribute('data-itemtype', itemType); + favoriteButton.setAttribute('data-likes', ''); + favoriteButton.setAttribute('data-isfavorite', item.UserData?.IsFavorite || 'false'); + favoriteButton.title = 'Add to favorites'; + + const favoriteIcon = document.createElement('span'); + favoriteIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite'; + favoriteIcon.setAttribute('aria-hidden', 'true'); + favoriteButton.appendChild(favoriteIcon); + buttonContainer.appendChild(favoriteButton); + } + + const moreButton = document.createElement('button'); + moreButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light'; + moreButton.setAttribute('data-action', 'menu'); + moreButton.title = 'More'; + const moreIcon = document.createElement('span'); + moreIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert'; + moreIcon.setAttribute('aria-hidden', 'true'); + moreButton.appendChild(moreIcon); + buttonContainer.appendChild(moreButton); + + if (canShowPlayOverlay) { + cardOverlayContainer.appendChild(playButton); + } + cardOverlayContainer.appendChild(buttonContainer); + } // Card text container - different structure for episodes + const cardTextFragment = document.createDocumentFragment(); + const cardTextContainer = document.createElement('div'); cardTextContainer.className = 'cardText cardTextCentered cardText-first'; - if (item.Type === 'Episode') { + if (parentItem && parentItem.Type === 'Timer') { + const cardFooter = document.createElement('div'); + cardFooter.className = 'cardFooter cardFooter-withlogo'; + const cardFooterLogo = document.createElement('div'); + cardFooterLogo.className = 'lazy cardFooterLogo lazy-image-fadein-fast'; + const imageUrl = `${serverAddress}/Items/${parentItem.ChannelId}/Images/Primary?height=40&tag=${parentItem.ChannelPrimaryImageTag}&quality=90`; + cardFooterLogo.style.backgroundImage = `url(${imageUrl})`; + cardFooter.appendChild(cardFooterLogo); + + const cardFooterText = document.createElement('div'); + cardFooterText.className = 'cardText cardText-first'; + cardFooterText.textContent = item.Name || 'Unknown'; + cardFooter.appendChild(cardFooterText); + + const cardFooterSecondaryText = document.createElement('div'); + cardFooterSecondaryText.className = 'cardText cardText-secondary'; + // format the start and end time to 12:21 AM - 2:39 AM + const startTime = new Date(parentItem.StartDate).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: true, + }); + const endTime = new Date(parentItem.EndDate).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }); + cardFooterSecondaryText.textContent = startTime + ' - ' + endTime; + cardFooter.appendChild(cardFooterSecondaryText); + + cardTextFragment.appendChild(cardFooter); + } else if (itemType === 'Episode') { // Episodes: Series name as primary, episode title as secondary const seriesLink = document.createElement('a'); - seriesLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId || item.Id}&serverId=${serverId}`; + seriesLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId || itemId}&serverId=${serverId}`; seriesLink.className = 'itemAction textActionButton'; - seriesLink.setAttribute('data-id', item.SeriesId || item.Id); + if (!isCustomCard) seriesLink.setAttribute('data-id', item.SeriesId || itemId); seriesLink.setAttribute('data-serverid', serverId); seriesLink.setAttribute('data-type', 'Series'); seriesLink.setAttribute('data-mediatype', 'undefined'); @@ -516,71 +4965,17 @@ const seriesBdi = document.createElement('bdi'); seriesBdi.appendChild(seriesLink); cardTextContainer.appendChild(seriesBdi); + cardTextFragment.appendChild(cardTextContainer); // Episode title as secondary const secondaryText = document.createElement('div'); secondaryText.className = 'cardText cardTextCentered cardText-secondary'; const episodeLink = document.createElement('a'); - episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`; + const episodeName = + item.IndexNumber && item.ParentIndexNumber ? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}` : item.Name; + episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${itemId}&serverId=${serverId}`; episodeLink.className = 'itemAction textActionButton'; - episodeLink.setAttribute('data-id', item.Id); - episodeLink.setAttribute('data-serverid', serverId); - episodeLink.setAttribute('data-type', 'Episode'); - episodeLink.setAttribute('data-mediatype', 'undefined'); - episodeLink.setAttribute('data-channelid', 'undefined'); - episodeLink.setAttribute('data-isfolder', 'false'); - episodeLink.setAttribute('data-action', 'link'); - episodeLink.title = item.Name || 'Unknown Episode'; - episodeLink.textContent = item.Name || 'Unknown Episode'; - - const episodeBdi = document.createElement('bdi'); - episodeBdi.appendChild(episodeLink); - secondaryText.appendChild(episodeBdi); - } else { - // Default: Item name as primary, year as secondary - const titleLink = document.createElement('a'); - titleLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`; - titleLink.className = 'itemAction textActionButton'; - titleLink.setAttribute('data-id', item.Id); - titleLink.setAttribute('data-serverid', serverId); - titleLink.setAttribute('data-type', item.Type); - titleLink.setAttribute('data-mediatype', 'undefined'); - titleLink.setAttribute('data-channelid', 'undefined'); - titleLink.setAttribute('data-isfolder', item.Type === 'MusicAlbum' || item.Type === 'Artist' || item.Type === 'MusicArtist' ? 'true' : 'false'); - titleLink.setAttribute('data-action', 'link'); - titleLink.title = item.Name || 'Unknown'; - titleLink.textContent = item.Name || 'Unknown'; - - const titleBdi = document.createElement('bdi'); - titleBdi.appendChild(titleLink); - cardTextContainer.appendChild(titleBdi); - - // Secondary text (year) - const secondaryText = document.createElement('div'); - secondaryText.className = 'cardText cardTextCentered cardText-secondary'; - const yearBdi = document.createElement('bdi'); - yearBdi.textContent = item.ProductionYear || item.PremiereDate?.substring(0, 4) || ''; - secondaryText.appendChild(yearBdi); - } - - // Assemble card - cardScalable.appendChild(cardPadder); - cardScalable.appendChild(blurhashCanvas); - cardScalable.appendChild(cardImageContainer); - cardScalable.appendChild(cardOverlayContainer); - - cardBox.appendChild(cardScalable); - cardBox.appendChild(cardTextContainer); - - // Add secondary text after primary text - if (item.Type === 'Episode') { - const secondaryText = document.createElement('div'); - secondaryText.className = 'cardText cardTextCentered cardText-secondary'; - const episodeLink = document.createElement('a'); - const episodeName = item.IndexNumber && item.ParentIndexNumber ? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}` : item.Name; - episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`; - episodeLink.className = 'itemAction textActionButton'; - episodeLink.setAttribute('data-id', item.Id); + if (!isCustomCard) episodeLink.setAttribute('data-id', itemId); episodeLink.setAttribute('data-serverid', serverId); episodeLink.setAttribute('data-type', 'Episode'); episodeLink.setAttribute('data-mediatype', 'undefined'); @@ -593,8 +4988,8 @@ const episodeBdi = document.createElement('bdi'); episodeBdi.appendChild(episodeLink); secondaryText.appendChild(episodeBdi); - cardBox.appendChild(secondaryText); - + cardTextFragment.appendChild(secondaryText); + // Add custom footer text if provided (e.g., air date) if (customFooterText) { const footerText = document.createElement('div'); @@ -602,450 +4997,1134 @@ const footerBdi = document.createElement('bdi'); footerBdi.textContent = customFooterText; footerText.appendChild(footerBdi); - cardBox.appendChild(footerText); + cardTextFragment.appendChild(footerText); + } + } else if (itemType === 'Season') { + // Seasons: Series name as primary, season title as secondary + const seriesLink = document.createElement('a'); + seriesLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId || itemId}&serverId=${serverId}`; + seriesLink.className = 'itemAction textActionButton'; + if (!isCustomCard) seriesLink.setAttribute('data-id', item.SeriesId || itemId); + seriesLink.setAttribute('data-serverid', serverId); + seriesLink.setAttribute('data-type', 'Series'); + seriesLink.setAttribute('data-mediatype', 'undefined'); + seriesLink.setAttribute('data-channelid', 'undefined'); + seriesLink.setAttribute('data-isfolder', 'true'); + seriesLink.setAttribute('data-action', 'link'); + seriesLink.title = item.SeriesName || 'Unknown Series'; + seriesLink.textContent = item.SeriesName || 'Unknown Series'; + + const seriesBdi = document.createElement('bdi'); + seriesBdi.appendChild(seriesLink); + cardTextContainer.appendChild(seriesBdi); + cardTextFragment.appendChild(cardTextContainer); + + // Season title as secondary + const secondaryText = document.createElement('div'); + secondaryText.className = 'cardText cardTextCentered cardText-secondary'; + const seasonLink = document.createElement('a'); + seasonLink.href = `${ApiClient._serverAddress}/web/#/details?id=${itemId}&serverId=${serverId}`; + seasonLink.className = 'itemAction textActionButton'; + if (!isCustomCard) seasonLink.setAttribute('data-id', itemId); + seasonLink.setAttribute('data-serverid', serverId); + seasonLink.setAttribute('data-type', 'Season'); + seasonLink.setAttribute('data-mediatype', 'undefined'); + seasonLink.setAttribute('data-channelid', 'undefined'); + seasonLink.setAttribute('data-isfolder', 'false'); + seasonLink.setAttribute('data-action', 'link'); + seasonLink.title = item.Name || 'Unknown Season'; + seasonLink.textContent = item.Name || 'Unknown Season'; + + const seasonBdi = document.createElement('bdi'); + seasonBdi.appendChild(seasonLink); + secondaryText.appendChild(seasonBdi); + cardTextFragment.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); + cardTextFragment.appendChild(footerText); } } else { + // Default: Item name as primary, year as secondary + const titleLink = document.createElement('a'); + titleLink.href = cardUrl; + titleLink.className = 'itemAction textActionButton'; + if (!isCustomCard) titleLink.setAttribute('data-id', itemId); + titleLink.setAttribute('data-serverid', serverId); + titleLink.setAttribute('data-type', itemType); + titleLink.setAttribute('data-mediatype', 'undefined'); + titleLink.setAttribute('data-channelid', 'undefined'); + titleLink.setAttribute( + 'data-isfolder', + itemType === 'MusicAlbum' || itemType === 'Artist' || itemType === 'MusicArtist' || item.IsFolder ? 'true' : 'false', + ); + + if (item.CollectionType) { + titleLink.setAttribute('data-collectiontype', item.CollectionType); + } + + titleLink.setAttribute('data-action', 'link'); + titleLink.title = item.Name || 'Unknown'; + titleLink.textContent = item.Name || 'Unknown'; + + const titleBdi = document.createElement('bdi'); + titleBdi.appendChild(titleLink); + cardTextContainer.appendChild(titleBdi); + cardTextFragment.appendChild(cardTextContainer); + + // Secondary text (year) + // For Series items, also list their end date or Present if it's continuing aka Start Year - End Year + let secondaryTextContent = item.ProductionYear || item.PremiereDate?.substring(0, 4) || ''; + + if (itemType === 'Series' && item.Status === 'Continuing') { + secondaryTextContent = item.ProductionYear + ' - Present'; + } else if (itemType === 'Series' && item.EndDate && item.ProductionYear && item.EndDate.substring(0, 4) !== String(item.ProductionYear)) { + secondaryTextContent = item.ProductionYear + ' - ' + item.EndDate.substring(0, 4); + } else if (item.Type === 'Program') { + secondaryTextContent = new Date(item.StartDate).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: true }); + } + const secondaryText = document.createElement('div'); secondaryText.className = 'cardText cardTextCentered cardText-secondary'; const yearBdi = document.createElement('bdi'); - yearBdi.textContent = item.ProductionYear || item.PremiereDate?.substring(0, 4) || ''; + yearBdi.textContent = secondaryTextContent || item.CustomFooterText; secondaryText.appendChild(yearBdi); - cardBox.appendChild(secondaryText); + cardTextFragment.appendChild(secondaryText); + + if (secondaryTextContent && item.CustomFooterText) { + const footerText = document.createElement('div'); + footerText.className = 'cardText cardTextCentered cardText-secondary'; + const footerBdi = document.createElement('bdi'); + footerBdi.textContent = item.CustomFooterText; + footerText.appendChild(footerBdi); + cardTextFragment.appendChild(footerText); + } else if (item.Type === 'Program') { + // Format the HH:mm - HH:mm based on local time + const timeOptions = { hour: '2-digit', minute: '2-digit', hour12: true }; + let startTime = new Date(item.StartDate).toLocaleTimeString('en-US', timeOptions).replace('AM', 'a.m.').replace('PM', 'p.m.').toLowerCase(); + let endTime = new Date(item.EndDate).toLocaleTimeString('en-US', timeOptions).replace('AM', 'a.m.').replace('PM', 'p.m.').toLowerCase(); + + // Strip leading zero from hour (e.g., 01:00 a.m. -> 1:00 a.m.) + startTime = startTime.replace(/^0(\d:)/, '$1'); + endTime = endTime.replace(/^0(\d:)/, '$1'); + + secondaryTextContent = startTime + ' - ' + endTime; + + const footerText = document.createElement('div'); + footerText.className = 'cardText cardTextCentered cardText-secondary'; + const footerBdi = document.createElement('bdi'); + footerBdi.textContent = secondaryTextContent; + footerText.appendChild(footerBdi); + cardTextFragment.appendChild(footerText); + } } - + + // Assemble card (blurhash already drawn above when blurhashStr was set) + cardScalable.appendChild(cardPadder); + if (includeKefinBlurhashCanvas) { + cardScalable.appendChild(blurhashCanvas); + } + cardScalable.appendChild(cardImageContainer); + if (mobileOverlayPlayButton) { + cardScalable.appendChild(mobileOverlayPlayButton); + } else if (cardOverlayContainer) { + cardScalable.appendChild(cardOverlayContainer); + } + cardScalable.appendChild(createCardBorderHost()); + + cardBox.appendChild(cardScalable); + if (cardTextFragment.childNodes.length) { + const cardTextStack = document.createElement('div'); + cardTextStack.className = 'cardTextStack'; + cardTextStack.appendChild(cardTextFragment); + cardBox.appendChild(cardTextStack); + } else { + cardBox.appendChild(cardTextFragment); + } + card.appendChild(cardBox); + /* ensureCardRadiusCssVar(); + if (cachedCardRadius == null) { + requestAnimationFrame(() => ensureCardRadiusCssVar()); + } */ + return card; } /** - * Renders a spotlight section (Netflix-style slim banner carousel) - * @param {Array} items - Array of Jellyfin item objects + * TV layout detection (Jellyfin sets layout-tv on ). + * Evaluated at call time so Display Mode changes are respected after re-render. + */ + function isTvLayout() { + return document.documentElement.classList.contains('layout-tv'); + } + + /** Feature-detect flex gap once; JMP/Qt WebEngine often lacks it. */ + function ensureNoFlexGapClass() { + const root = document.documentElement; + if (root.dataset.kefinFlexGapChecked === 'true') return; + root.dataset.kefinFlexGapChecked = 'true'; + try { + const probe = document.createElement('div'); + probe.style.cssText = + 'display:flex;gap:1px;position:absolute;left:-9999px;top:0;width:auto;height:auto;visibility:hidden'; + const a = document.createElement('div'); + const b = document.createElement('div'); + a.style.cssText = 'width:1px;height:1px'; + b.style.cssText = 'width:1px;height:1px'; + probe.appendChild(a); + probe.appendChild(b); + document.body.appendChild(probe); + const supported = probe.scrollWidth >= 3; + probe.remove(); + root.classList.toggle('no-flex-gap', !supported); + } catch (_) { + root.classList.add('no-flex-gap'); + } + } + + function isFocusableTvEl(el) { + if (!el || el.nodeType !== 1) return false; + if (el.getAttribute('tabindex') === '-1') return false; + if (el.disabled || el.getAttribute('aria-hidden') === 'true') return false; + const tag = el.tagName; + if (tag === 'A' && el.hasAttribute('href')) return true; + if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return true; + const ti = el.getAttribute('tabindex'); + return ti != null && ti !== '' && Number(ti) >= 0; + } + + function getTvFocusables(root) { + if (!root?.querySelectorAll) return []; + return Array.from(root.querySelectorAll('a[href], button, [tabindex]')).filter(isFocusableTvEl); + } + + /** Leftmost TV focus stop in spotlight header (skips plain section labels). */ + function getFirstSpotlightHeaderFocusable(headerCluster) { + if (!headerCluster) return null; + return getTvFocusables(headerCluster)[0] || null; + } + + function focusTvEl(el) { + if (!el || typeof el.focus !== 'function') return false; + try { + el.focus({ preventScroll: true }); + } catch (_) { + el.focus(); + } + return document.activeElement === el; + } + + function getMetadataLinkFocusables(metaContainer) { + if (!metaContainer) return []; + return Array.from( + metaContainer.querySelectorAll( + 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link', + ), + ).filter((el) => el.getAttribute('tabindex') !== '-1' || metaContainer.dataset.metadataDrilled === 'true'); + } + + function getMetadataRows(metaContainer) { + if (!metaContainer) return []; + return Array.from(metaContainer.querySelectorAll(':scope > .spotlight-metadata-row')); + } + + function setMetadataDrilled(metaContainer, drilled) { + if (!metaContainer) return; + metaContainer.dataset.metadataDrilled = drilled ? 'true' : 'false'; + const links = metaContainer.querySelectorAll( + 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link', + ); + links.forEach((link) => { + if (drilled) { + link.classList.add('show-focus'); + if (link.getAttribute('tabindex') === '-1') link.removeAttribute('tabindex'); + } else { + link.setAttribute('tabindex', '-1'); + } + }); + if (drilled) { + metaContainer.setAttribute('tabindex', '-1'); + } else { + metaContainer.setAttribute('tabindex', '0'); + metaContainer.classList.add('show-focus'); + } + } + + function collapseAllMetadataDrillIn(root) { + root?.querySelectorAll?.('.metadata-container').forEach((meta) => { + setMetadataDrilled(meta, false); + }); + } + + /** + * Prefer TV button cards when layout-tv is active; otherwise desktop cards. + */ + function createCardElement( + item, + overflowCard = false, + cardFormat = null, + customFooterText = null, + useParentCard = false, + borderStyle = null, + cardImageParams = null, + ) { + if (isTvLayout()) { + return buildTvCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle); + } + return createJellyfinCardElement( + item, + overflowCard, + cardFormat, + customFooterText, + useParentCard, + borderStyle, + cardImageParams, + ); + } + + /** + * Convert a desktop card DOM tree into a Jellyfin TV focusable button card. + * Reuses desktop image/footer construction, then strips hover overlays and linked titles. + */ + function convertDesktopCardToTvButton(sourceCard, item) { + const button = document.createElement('button'); + button.type = 'button'; + + Array.from(sourceCard.attributes).forEach((attr) => { + if (attr.name === 'class') return; + button.setAttribute(attr.name, attr.value); + }); + + const classes = String(sourceCard.className || '') + .split(/\s+/) + .filter((c) => c && c !== 'card-hoverable'); + if (!classes.includes('show-focus')) classes.push('show-focus'); + if (!classes.includes('show-animation')) classes.push('show-animation'); + if (!classes.includes('itemAction')) classes.push('itemAction'); + button.className = classes.join(' '); + button.setAttribute('data-action', 'link'); + button.setAttribute('aria-label', item?.Name || sourceCard.getAttribute('aria-label') || 'Unknown'); + + while (sourceCard.firstChild) { + button.appendChild(sourceCard.firstChild); + } + + const imageLink = button.querySelector('a.cardImageContainer'); + if (imageLink) { + const imageDiv = document.createElement('div'); + Array.from(imageLink.attributes).forEach((attr) => { + if (attr.name === 'href') return; + imageDiv.setAttribute(attr.name, attr.value); + }); + while (imageLink.firstChild) { + imageDiv.appendChild(imageLink.firstChild); + } + imageLink.replaceWith(imageDiv); + } + + button.querySelectorAll('.cardOverlayContainer').forEach((el) => el.remove()); + + button.querySelectorAll('a.textActionButton').forEach((link) => { + const text = link.textContent || ''; + const bdi = link.closest('bdi'); + if (bdi) { + bdi.textContent = text; + } else { + link.replaceWith(document.createTextNode(text)); + } + }); + + // Match Jellyfin TV backdrop image request size when easy + const imageEl = button.querySelector('.cardImageContainer[data-src]'); + if (imageEl) { + const src = imageEl.getAttribute('data-src') || ''; + if (src.includes('fillHeight=267') && src.includes('fillWidth=474')) { + imageEl.setAttribute( + 'data-src', + src + .replace('fillHeight=267', 'fillHeight=335') + .replace('fillWidth=474', 'fillWidth=596'), + ); + } + } + + // Custom / external cards: overlay link was removed — navigate on activate + const customUrl = item?.cardUrl; + if (customUrl) { + button.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (customUrl.startsWith('#') || customUrl.startsWith('/')) { + window.location.href = customUrl; + } else { + window.open(customUrl, '_blank', 'noopener,noreferrer'); + } + }); + } + + return button; + } + + /** + * Dedicated TV card builder (button root, no overlays, plain titles). + * Same argument shape as createJellyfinCardElement. + */ + function buildTvCardElement( + item, + overflowCard = false, + cardFormat = null, + customFooterText = null, + useParentCard = false, + borderStyle = null, + ) { + const normalizedFormat = (cardFormat || '').toLowerCase() || null; + if (normalizedFormat === 'button') { + const libraryButton = createLibraryButtonElement(item); + libraryButton.classList.add('show-focus', 'show-animation', 'itemAction'); + if (!libraryButton.getAttribute('data-action')) { + libraryButton.setAttribute('data-action', 'link'); + } + return libraryButton; + } + + const desktopCard = createJellyfinCardElement( + item, + overflowCard, + cardFormat, + customFooterText, + useParentCard, + borderStyle, + ); + return convertDesktopCardToTvButton(desktopCard, item); + } + + /** + * Resolve spotlight layout/size/tileCount from options (incl. legacy fullScreen). + * Shared by live create, skeleton create, and configure/defaults callers. + * @param {Object} [options] + * @param {boolean} [options.capForViewport=true] - Cap tileCount by viewport width + * @returns {{ layout: string, size: string, tileCount: number }} + */ + function resolveSpotlightPresentation(options = {}) { + const fullScreen = options.fullScreen === true; + const layout = options.spotlightLayout ?? (fullScreen ? 'Borderless' : 'Border'); + const size = options.spotlightSize ?? (fullScreen ? 'full' : 'normal'); + let tileCount = options.tileCount; + if (tileCount == null || tileCount < 1 || tileCount > 3) { + tileCount = size === 'full' ? 1 : size === 'large' ? 2 : 3; + } + tileCount = Math.max(1, Math.min(3, Math.floor(tileCount))); + if (options.capForViewport !== false) { + const vw = window.innerWidth || 0; + if (vw < 900) tileCount = Math.min(tileCount, 1); + else if (vw < 1600) tileCount = Math.min(tileCount, 2); + } + return { layout, size, tileCount }; + } + + /** + * Creates a Netflix-style slim banner carousel (spotlight) + * @param {Array} itemsInput - 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) { + function createSpotlightSection(itemsInput, title, options = {}) { + let items = Array.isArray(itemsInput) ? itemsInput.slice() : []; + if (!items.length) { return document.createElement('div'); } - + const { autoPlay = true, - interval = 5000, + interval = 10000, + showSlideState = true, showDots = true, showNavButtons = true, showClearArt = false, - viewMoreUrl = null + panAnimation = true, + tileCount: tileCountOpt, + cycleBackdrops = false, + cycleBackdropsTime, + backdropsCount: backdropsCountOpt, + entranceAnimationFirst = 'fadeIn', + entranceAnimationSecond = 'fadeIn', + entranceAnimationThird = 'fadeIn', + slideAnimationFirst = 'kenBurnsZoomIn', + slideAnimationSecond = 'kenBurnsZoomIn', + slideAnimationThird = 'kenBurnsZoomIn', + viewMoreUrl = null, + initialIndex = 0, + /** Build DOM only — no timers/observers (for harvesting slides during in-place refresh). */ + inert = false, } = options; - + const { layout, size, tileCount } = resolveSpotlightPresentation({ + ...options, + tileCount: tileCountOpt, + }); + let backdropsCount = backdropsCountOpt; + if (backdropsCount == null && cycleBackdropsTime > 0 && interval > 0) { + backdropsCount = Math.max(1, Math.round(interval / cycleBackdropsTime)); + } + backdropsCount = Math.max(1, parseInt(backdropsCount, 10) || 1); + const perBackdropMs = Math.max(500, Math.round(interval / backdropsCount)); const serverId = ApiClient.serverId(); const serverAddress = ApiClient.serverAddress(); - let currentIndex = 0; + const sectionKey = 'spotlight_' + Date.now() + '_' + Math.random().toString(36).slice(2, 9); + const cycleBackdropMap = new Map(); + let currentIndex = Math.max(0, Math.min(items.length - 1, parseInt(initialIndex, 10) || 0)); let autoPlayTimer = null; + let cycleBackdropTimer = null; + let advanceDue = false; let isPaused = false; - + let isVisible = true; // updated by Intersection Observer; start true so initial startAutoPlay runs + let currentSlideAnimationComplete = false; + + function shuffleArray(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; + } + // Create main container const container = document.createElement('div'); container.className = 'spotlight-section padded-left'; - + // Live toggle target for section configure (setSpotlightAnimation); read via isPanAnimationEnabled() + container.dataset.panAnimation = panAnimation !== false ? 'true' : 'false'; + // 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) { - let sectionTitleEl; - - if (viewMoreUrl) { - // Create clickable title - const titleLink = document.createElement('a'); - titleLink.className = 'spotlight-section-title spotlight-title-link'; - titleLink.textContent = title; - titleLink.title = 'See All'; - titleLink.style.textDecoration = 'none'; - const cssStyle = document.createElement('style'); - cssStyle.textContent = ` - .spotlight-title-link:hover { - text-decoration: underline !important; + // Header wraps title + nav so TV Left/Right stays in-family (one focuscontainer-x). + // Title container hosts section name + refresh/configure controls. + // Always create it so controls can attach even when the section has no name. + { + const headerContainer = document.createElement('div'); + headerContainer.className = 'spotlight-header-container'; + if (isTvLayout()) headerContainer.classList.add('focuscontainer-x'); + + const sectionTitleContainer = document.createElement('div'); + sectionTitleContainer.className = 'spotlight-section-title-container'; + + if (title) { + let sectionTitleEl; + + if (viewMoreUrl) { + const titleLink = document.createElement('a'); + titleLink.className = 'emby-tab-button emby-tab-button-active emby-button-foreground emby-button'; + titleLink.textContent = title; + titleLink.title = 'See All'; + titleLink.style.textDecoration = 'none'; + if (isTvLayout()) titleLink.classList.add('show-focus'); + + if (typeof viewMoreUrl === 'function') { + if (isTvLayout()) titleLink.setAttribute('tabindex', '0'); + titleLink.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + viewMoreUrl(); + }); + } else { + titleLink.href = viewMoreUrl; + titleLink.addEventListener('click', (e) => { + e.stopPropagation(); + }); } - `; - titleLink.appendChild(cssStyle); - - // Handle both URL and function - if (typeof viewMoreUrl === 'function') { - titleLink.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - viewMoreUrl(); - }); + + sectionTitleEl = titleLink; } else { - titleLink.href = viewMoreUrl; - titleLink.addEventListener('click', (e) => { - e.stopPropagation(); - }); + // Label only — not a focus stop; section-controls / nav are first when entering header + sectionTitleEl = document.createElement('div'); + sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground emby-button'; + sectionTitleEl.textContent = title; } - - sectionTitleEl = titleLink; - } else { - // Regular non-clickable title - sectionTitleEl = document.createElement('div'); - sectionTitleEl.className = 'spotlight-section-title'; - sectionTitleEl.textContent = title; + + const sectionTitleWrapper = document.createElement('div'); + sectionTitleWrapper.className = `spotlight-section-title ${viewMoreUrl ? "" : "spotlight-title-link "}headerTabs sectionTabs`; + sectionTitleWrapper.appendChild(sectionTitleEl); + sectionTitleContainer.appendChild(sectionTitleWrapper); } - - bannerContainer.appendChild(sectionTitleEl); + + headerContainer.appendChild(sectionTitleContainer); + bannerContainer.appendChild(headerContainer); } - + // 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 + + function applyAnimationToImg(img, entranceName, slideName) { + img.style.setProperty('--spotlight-entrance-animation', entranceName); + img.style.setProperty('--spotlight-slide-animation', slideName); + img.setAttribute('data-entrance-animation', entranceName); + img.setAttribute('data-slide-animation', slideName); + } + + // Sliding window: render current + 4 prev + 4 next (9 slides). Update DOM as user navigates. + let currentWindowIndices = getSpotlightWindowIndices(currentIndex, items.length); + + function createAndAppendSlide(item, index, eagerLoadImages) { 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'; - } + itemDiv.setAttribute('data-id', item.Id); + itemDiv.setAttribute('data-index', String(index)); + itemDiv.setAttribute('data-item-type', itemType); 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}`; + // Build list of available backdrop sources for this item + let backdropSources = []; + + if (item.Type === 'Episode' && Array.isArray(item.ParentBackdropImageTags) && item.ParentBackdropImageTags.length) { + backdropSources = item.ParentBackdropImageTags.map((tag) => ({ + itemId: item.ParentBackdropItemId, + tag, + })); + } else if (Array.isArray(item.BackdropImageTags) && item.BackdropImageTags.length) { + backdropSources = item.BackdropImageTags.map((tag) => ({ + itemId: item.Id, + tag, + })); + } else { + // Handle Custom items with static images + const customImageOrder = [ + "backdropUrl", + "thumbUrl", + "bannerUrl", + "squareUrl", + "posterUrl", + "imageUrl", + "logoUrl" + ]; + for (const key of customImageOrder) { + const url = item[key]; + if (typeof url === "string" && url.trim()) { + // Use custom URL as only 'backdrop' for static slides + backdropSources = [{ + itemId: null, + tag: null, + url: url.trim(), + key + }]; + break; + } + } + } + + // Set backdrop width based on layout-mobile presence + let backdropWidth = 2560; + if (document.querySelector('.layout-mobile')) { + backdropWidth = 1920; } - - if (imageUrl) { - itemDiv.style.backgroundImage = `url("${imageUrl}")`; + + const buildBackdropUrl = (src, idx) => src.url ? src.url : + `${serverAddress}/Items/${src.itemId}/Images/Backdrop/${idx}?maxWidth=${backdropWidth}&quality=96&tag=${src.tag}`; + + /* Cap layers by available backdrops so we never build dual/triple with undefined sources. */ + const effectiveTileCount = Math.min(tileCount, Math.max(1, backdropSources.length)); + + if (effectiveTileCount >= 2 && backdropSources.length >= 2) { + const indices = shuffleArray(backdropSources.map((_, i) => i)); + const pick = (i) => backdropSources[indices[i]]; + const dualBackgroundContainer = document.createElement('div'); + dualBackgroundContainer.className = 'spotlight-background-dual'; + + const leftLayer = document.createElement('div'); + leftLayer.className = 'spotlight-background-layer spotlight-background-layer-left'; + const leftImg = document.createElement('img'); + const leftUrl = buildBackdropUrl(pick(0), indices[0]); + leftImg.setAttribute('data-src', leftUrl); + leftImg.alt = ''; + leftImg.draggable = false; + applyAnimationToImg(leftImg, entranceAnimationFirst, slideAnimationFirst); + if (eagerLoadImages) { + leftImg.src = leftUrl; + leftImg.loading = 'eager'; + } + leftLayer.appendChild(leftImg); + dualBackgroundContainer.appendChild(leftLayer); + + if (effectiveTileCount >= 3) { + const centerLayer = document.createElement('div'); + centerLayer.className = 'spotlight-background-layer spotlight-background-layer-center'; + const centerImg = document.createElement('img'); + const centerUrl = buildBackdropUrl(pick(1), indices[1]); + centerImg.setAttribute('data-src', centerUrl); + centerImg.alt = ''; + centerImg.draggable = false; + applyAnimationToImg(centerImg, entranceAnimationSecond, slideAnimationSecond); + if (eagerLoadImages) { + centerImg.src = centerUrl; + centerImg.loading = 'eager'; + } + centerLayer.appendChild(centerImg); + dualBackgroundContainer.appendChild(centerLayer); + } + + const rightLayer = document.createElement('div'); + rightLayer.className = 'spotlight-background-layer spotlight-background-layer-right'; + const rightImg = document.createElement('img'); + const rightIdx = effectiveTileCount >= 3 ? 2 : 1; + const rightUrl = buildBackdropUrl(pick(rightIdx), indices[rightIdx]); + rightImg.setAttribute('data-src', rightUrl); + rightImg.alt = ''; + rightImg.draggable = false; + applyAnimationToImg( + rightImg, + effectiveTileCount >= 3 ? entranceAnimationThird : entranceAnimationSecond, + effectiveTileCount >= 3 ? slideAnimationThird : slideAnimationSecond, + ); + if (eagerLoadImages) { + rightImg.src = rightUrl; + rightImg.loading = 'eager'; + } + rightLayer.appendChild(rightImg); + dualBackgroundContainer.appendChild(rightLayer); + itemDiv.appendChild(dualBackgroundContainer); + } else { + let singleUrl = ''; + const shuffled = shuffleArray(backdropSources.map((s, i) => ({ src: s, idx: i }))); + if (shuffled.length > 0 && shuffled.some(s => s.src.url)) { + singleUrl = shuffled.find(s => s.src.url).src.url; + } else if (cycleBackdrops && tileCount === 1 && backdropSources.length > 0) { + const urls = shuffled.map( + ({ src, idx }) => + `${serverAddress}/Items/${src.itemId}/Images/Backdrop/${idx}?maxWidth=${backdropWidth}&quality=96&tag=${src.tag}`, + ); + const mapKey = sectionKey + '_' + item.Id; + cycleBackdropMap.set(mapKey, { shuffledUrls: urls, currentIndex: 0 }); + singleUrl = urls[0]; + } else if (backdropSources.length) { + const first = backdropSources[0]; + singleUrl = `${serverAddress}/Items/${first.itemId}/Images/Backdrop?maxWidth=${backdropWidth}&quality=96&tag=${first.tag}`; + } else if (item.ImageTags?.Primary) { + singleUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?maxWidth=${backdropWidth}&quality=96&tag=${item.ImageTags.Primary}`; + } + if (singleUrl) { + const singleBackdropContainer = document.createElement('div'); + singleBackdropContainer.className = + 'spotlight-background-single' + + (cycleBackdrops && tileCount === 1 && backdropSources.length > 0 ? ' cycle-backdrops' : ''); + const singleImg = document.createElement('img'); + singleImg.setAttribute('data-src', singleUrl); + singleImg.alt = ''; + singleImg.draggable = false; + applyAnimationToImg(singleImg, entranceAnimationFirst, slideAnimationFirst); + if (eagerLoadImages) { + singleImg.src = singleUrl; + singleImg.loading = 'eager'; + } + singleBackdropContainer.appendChild(singleImg); + if (cycleBackdrops && tileCount === 1 && backdropSources.length > 0) { + const singleImgNext = document.createElement('img'); + singleImgNext.setAttribute('data-src', singleUrl); + singleImgNext.alt = ''; + singleImgNext.draggable = false; + singleImgNext.style.opacity = '0'; + applyAnimationToImg(singleImgNext, entranceAnimationFirst, slideAnimationFirst); + singleBackdropContainer.appendChild(singleImgNext); + } + itemDiv.appendChild(singleBackdropContainer); + } } - + // 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'); + const logoHeight = size === 'full' ? '300' : size === 'large' ? '250' : '200'; + const logoUrl = `${serverAddress}/Items/${item.Id}/Images/Logo?maxHeight=${logoHeight}&quality=96&tag=${item.ImageTags.Logo}`; + titleEl = document.createElement('div'); titleEl.className = 'spotlight-item-logo'; - titleEl.src = logoUrl; + titleEl.setAttribute('data-background-url', logoUrl); + if (eagerLoadImages) titleEl.style.backgroundImage = `url("${logoUrl}")`; titleEl.alt = item.Name || 'Unknown'; } else { // Use text title as fallback - titleEl = document.createElement('h3'); + titleEl = document.createElement('div'); titleEl.className = 'spotlight-item-title'; titleEl.textContent = item.Name || 'Unknown'; } - + + // Helper: wrap content in native Jellyfin itemDetailsGroup > detailsGroupItem structure + // icon: optional Material Icons name (e.g. 'schedule') - same pattern as genres/director/writer + function createMetadataItem(content, tag = 'span', extraClasses = '', icon = null) { + const group = document.createElement('div'); + group.className = 'headerTabs sectionTabs emby-tabs-slider'; + const item = document.createElement(tag); + item.className = 'emby-tab-button emby-button-foreground' + (extraClasses ? ' ' + extraClasses : ''); + if (icon) item.setAttribute('data-icon', icon); + if (typeof content === 'string') { + item.textContent = content; + } else { + item.appendChild(content); + } + group.appendChild(item); + return group; + } + // 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); + metadataRow.appendChild(createMetadataItem(String(year))); } - - // Runtime - if (item.RunTimeTicks) { + + // Runtime and estimated end time (Movies only - not for Series/Season/Episode) + const isSeriesType = itemType === 'Series' || itemType === 'Season' || itemType === 'Episode'; + if (!isSeriesType && 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(); + runtimeText = `${hours}h ${minutes > 0 ? minutes + "m" : ""}`.trim(); } else { runtimeText = `${minutes}m`; } - - const runtimeEl = document.createElement('span'); - runtimeEl.textContent = runtimeText; - metadataRow.appendChild(runtimeEl); - + + metadataRow.appendChild(createMetadataItem(runtimeText)); + // 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); + const endTimeItem = createMetadataItem( + endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + 'span', + 'spotlight-end-time', + 'schedule', + ); + metadataRow.appendChild(endTimeItem); } - - // 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); + // Genres - merged into single container, max 3 visible, tooltip shows full list on hover + if (item.Genres && item.Genres.length > 0 && item.GenreItems && item.GenreItems.length > 0) { + const genreContainer = createTruncatedList('', item.GenreItems, 3, false, 'theater_comedy', 'genre'); + if (genreContainer) { + metadataRow.appendChild(genreContainer); } } - - // Helper function to create truncated list that expands on ellipsis hover + + // Helper function to create truncated list with tooltip for overflow items // items can be array of strings (names) or array of objects with Name and Id - function createTruncatedList(label, items, maxItems = 3, isPeople = false) { + // icon: optional Material Icons name for full-screen display (e.g. 'movie', 'edit') + // linkType: 'person' | 'genre' | 'studio' | null - when set, create links + function createTruncatedList(label, items, maxItems = 3, isPeople = false, icon = null, linkType = null) { const container = document.createElement('div'); - container.className = 'spotlight-truncated-list'; + container.className = 'spotlight-truncated-list headerTabs sectionTabs emby-tabs-slider'; + container.setAttribute('data-label', label); + if (icon) container.setAttribute('data-icon', icon); if (!items || items.length === 0) { return container; } - + const displayContainer = document.createElement('span'); - const labelSpan = document.createElement('span'); - labelSpan.textContent = `${label} `; - displayContainer.appendChild(labelSpan); - + displayContainer.className = 'emby-tab-button emby-button-foreground'; + if (icon) displayContainer.setAttribute('data-icon', icon); + if (label) { + const labelSpan = document.createElement('span'); + labelSpan.className = 'spotlight-truncated-list-label'; + 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); - + 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) + + // Resolve link type: explicit linkType, or fall back to isPeople for person links + const usePersonLinks = linkType === 'person' || (linkType !== 'genre' && linkType !== 'studio' && isPeople); + const useGenreLinks = linkType === 'genre'; + const useStudioLinks = linkType === 'studio'; + + // Create spans for visible names only (no layout-shifting expand) itemsData.forEach((itemData, index) => { + if (index >= maxItems) return; let nameElement; - - // Create link if it's a person with an ID, otherwise just text - if (isPeople && itemData.id) { + + if (usePersonLinks && 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; + if (isTvLayout()) nameElement.classList.add('show-focus'); nameElement.addEventListener('click', (e) => { e.stopPropagation(); }); + } else if (useGenreLinks && itemData.id) { + nameElement = document.createElement('a'); + nameElement.className = 'spotlight-genre-link'; + nameElement.href = `${serverAddress}/web/#/list.html?genreId=${itemData.id}&serverId=${serverId}`; + nameElement.textContent = itemData.name; + if (isTvLayout()) nameElement.classList.add('show-focus'); + nameElement.addEventListener('click', (e) => e.stopPropagation()); + } else if (useStudioLinks && itemData.id) { + nameElement = document.createElement('a'); + nameElement.className = 'spotlight-studio-link'; + nameElement.href = `${serverAddress}/web/#/list.html?studioId=${itemData.id}&serverId=${serverId}`; + nameElement.textContent = itemData.name; + if (isTvLayout()) nameElement.classList.add('show-focus'); + 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); + + // Bullet separator (except for last visible item - use actual count when fewer than maxItems) + const lastVisibleIndex = Math.min(maxItems, itemsData.length) - 1; + if (index < lastVisibleIndex) { + const separator = document.createElement('span'); + separator.className = 'spotlight-list-separator'; + separator.textContent = '•'; + displayContainer.appendChild(separator); } }); - - // 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; + + // Tooltip when more items exist (full list on hover) — skip on TV (no hover; links steal focus) + if (hasMore && !isTvLayout()) { + // Tooltip: full list as vertical stack with clickable links (no layout shift) + const tooltip = document.createElement('div'); + tooltip.className = 'spotlight-truncated-list-tooltip'; + if (label) { + const labelSpan = document.createElement('span'); + labelSpan.className = 'spotlight-truncated-list-tooltip-label'; + labelSpan.textContent = label + ' '; + tooltip.appendChild(labelSpan); + } + const listWrapper = document.createElement('div'); + listWrapper.className = 'spotlight-truncated-list-tooltip-items'; + itemsData.forEach((itemData) => { + const itemRow = document.createElement('div'); + itemRow.className = 'spotlight-truncated-list-tooltip-item'; + let linkEl; + if (usePersonLinks && itemData.id) { + linkEl = document.createElement('a'); + linkEl.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`; + linkEl.className = 'spotlight-person-link'; + linkEl.textContent = itemData.name; + linkEl.addEventListener('click', (e) => e.stopPropagation()); + } else if (useGenreLinks && itemData.id) { + linkEl = document.createElement('a'); + linkEl.href = `${serverAddress}/web/#/list.html?genreId=${itemData.id}&serverId=${serverId}`; + linkEl.className = 'spotlight-genre-link'; + linkEl.textContent = itemData.name; + linkEl.addEventListener('click', (e) => e.stopPropagation()); + } else if (useStudioLinks && itemData.id) { + linkEl = document.createElement('a'); + linkEl.href = `${serverAddress}/web/#/list.html?studioId=${itemData.id}&serverId=${serverId}`; + linkEl.className = 'spotlight-studio-link'; + linkEl.textContent = itemData.name; + linkEl.addEventListener('click', (e) => e.stopPropagation()); + } else { + linkEl = document.createElement('span'); + linkEl.textContent = itemData.name; + } + itemRow.appendChild(linkEl); + listWrapper.appendChild(itemRow); + }); + tooltip.appendChild(listWrapper); + container.appendChild(tooltip); + container.style.position = 'relative'; + + const TOOLTIP_DELAY_MS = 600; + const HIDE_DELAY_MS = 150; + let tooltipTimer = null; + let hideTimer = null; + + const showTooltip = () => { + tooltip.classList.add('visible'); + tooltipTimer = null; + }; + const hideTooltip = () => { + tooltip.classList.remove('visible'); + if (tooltipTimer) { + clearTimeout(tooltipTimer); + tooltipTimer = null; + } + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + }; + const scheduleHide = () => { + if (hideTimer) clearTimeout(hideTimer); + hideTimer = setTimeout(hideTooltip, HIDE_DELAY_MS); + }; + + container.addEventListener('mouseenter', () => { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + tooltipTimer = setTimeout(showTooltip, TOOLTIP_DELAY_MS); + }); + container.addEventListener('mouseleave', () => { + scheduleHide(); + }); + tooltip.addEventListener('mouseenter', () => { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; } }); - - // 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; - } + tooltip.addEventListener('mouseleave', () => { + hideTooltip(); }); } - + container.appendChild(displayContainer); return container; } - + + // Studios - show 1 visible, rest in tooltip with links (like directors/writers) + const studiosData = + Array.isArray(item.Studios) && item.Studios.length > 0 + ? item.Studios + : typeof item.Studio === 'string' && item.Studio + ? item.Studio.split('|') + .map((s) => ({ Name: s.trim(), Id: null })) + .filter((x) => x.Name) + : []; + const studioContainer = + studiosData.length > 0 ? createTruncatedList('Produced by', studiosData, 1, false, 'apartment', 'studio') : null; + // 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'; - + + // Create series info container (seasons/episodes count) + const seriesInfoContainer = document.createElement('div'); + seriesInfoContainer.className = 'spotlight-metadata-row spotlight-series-info'; + if (itemType === 'Series') { + // Studio first (before seasons/episodes) + if (studioContainer) seriesInfoContainer.appendChild(studioContainer); // 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 - } - })(); + if (seasonsCount > 1) { + const seasonsText = `${seasonsCount} ${seasonsCount === 1 ? "season" : "seasons"}`; + seriesInfoContainer.appendChild(createMetadataItem(seasonsText)); + } + const episodesCount = item.RecursiveItemCount || 0; + if (episodesCount > 0) { + const episodesText = `${episodesCount} ${episodesCount === 1 ? "episode" : "episodes"}`; + seriesInfoContainer.appendChild(createMetadataItem(episodesText)); + } } 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'); - + 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); - + 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]); - + 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); + combinedContainer = createTruncatedList('Directed and Written by', directors, 1, true, 'movie'); // 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); + if (directors.length > 0) { + directorContainer = createTruncatedList('Directed by', directors, 1, true, 'movie'); + } + if (writers.length > 0) { + writerContainer = createTruncatedList('Written by', writers, 1, true, 'edit'); + } } } } else if (itemType === 'Season') { + // Studio first (before episodes) + if (studioContainer) seriesInfoContainer.appendChild(studioContainer); // For Seasons, show episodes count const episodesCount = item.RecursiveItemCount || 0; - const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`; - seriesInfoContainer.textContent = episodesText; + const episodesText = `${episodesCount} ${episodesCount === 1 ? "episode" : "episodes"}`; + seriesInfoContainer.appendChild(createMetadataItem(episodesText)); } else if (itemType === 'Episode') { + // Studio first (before season/episode) + if (studioContainer) seriesInfoContainer.appendChild(studioContainer); // 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}`; + seriesInfoContainer.appendChild(createMetadataItem(`${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; - } - } - + + // Tagline (tagline only) and Overview (separate element) - show/hide via CSS based on spotlight type + const taglineEl = document.createElement('p'); + taglineEl.className = 'emby-tab-button emby-tab-button-active'; + const tagline = item.Taglines && Array.isArray(item.Taglines) && item.Taglines.length > 0 ? item.Taglines[0] : ''; + if (tagline) taglineEl.textContent = tagline; + + const overviewEl = document.createElement('p'); + overviewEl.className = 'spotlight-overview'; + if (item.Overview) overviewEl.textContent = item.Overview; + // Buttons container const buttonsContainer = document.createElement('div'); - buttonsContainer.className = 'spotlight-buttons-container'; - + buttonsContainer.className = 'spotlight-buttons-container mainDetailButtons'; + if (isTvLayout()) buttonsContainer.classList.add('focuscontainer-x'); + // Play button (material icon span button) const playButton = document.createElement('button'); - playButton.className = 'spotlight-play-button emby-button'; + playButton.className = 'emby-button raised button-flat btnPlay detailButton'; + playButton.title = 'Play'; + if (isTvLayout()) playButton.classList.add('show-focus'); const playIcon = document.createElement('span'); playIcon.className = 'material-icons'; playIcon.textContent = 'play_arrow'; @@ -1068,115 +6147,104 @@ 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); + + // Watchlist button (same Likes / watchlist-button pattern as cards; clicks via watchlist.js) + const spotlightWatchlistSupportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'BoxSet', 'Playlist', 'Video']; + let watchlistButton = null; + if (state.watchlist && spotlightWatchlistSupportedTypes.includes(itemType)) { + const isLiked = item.UserData?.Likes === true; + watchlistButton = document.createElement('button'); + watchlistButton.type = 'button'; + watchlistButton.className = 'watchlist-button spotlight-watchlist-button emby-button button-flat paper-icon-button-light'; + if (isTvLayout()) watchlistButton.classList.add('show-focus'); + watchlistButton.setAttribute('data-action', 'none'); + watchlistButton.setAttribute('data-id', item.Id); + watchlistButton.setAttribute('data-active', isLiked ? 'true' : 'false'); + watchlistButton.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist'; + + const watchlistIcon = document.createElement('span'); + watchlistIcon.className = 'material-icons watchlist'; + watchlistIcon.setAttribute('aria-hidden', 'true'); + watchlistButton.appendChild(watchlistIcon); + + if (typeof window.KefinWatchlistLikedIds?.bindWatchlistButton === 'function') { + window.KefinWatchlistLikedIds.bindWatchlistButton(watchlistButton, item.Id, itemType); } - }); - + } + // Info button (shows overview on hover) const infoButton = document.createElement('button'); - infoButton.className = 'spotlight-info-button emby-button'; + infoButton.className = 'emby-button button-flat spotlight-info-button paper-icon-button-light'; + if (isTvLayout()) infoButton.classList.add('show-focus'); const infoIcon = document.createElement('span'); infoIcon.className = 'material-icons'; infoIcon.textContent = 'info'; + infoIcon.title = 'View Details'; infoButton.appendChild(infoIcon); - - // Overview tooltip (hidden by default, shown on hover) - if (item.Overview) { + + // Overview tooltip (hover only) — skip on TV + if (item.Overview && !isTvLayout()) { const overviewTooltip = document.createElement('div'); overviewTooltip.className = 'spotlight-overview-tooltip'; overviewTooltip.textContent = item.Overview; infoButton.appendChild(overviewTooltip); } - + buttonsContainer.appendChild(playButton); - buttonsContainer.appendChild(watchlistButton); + if (watchlistButton) { + 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'; - + ratingContainer.className = 'emby-button button-flat starRatingContainer mediaInfoItem 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'; + starIcon.className = 'material-icons starIcon star spotlight-rating-star'; + if (rating && typeof rating === 'number') { - ratingValue.textContent = rating.toFixed(1); - } else { - ratingValue.textContent = 'N/A'; - ratingValue.classList.add('na'); + ratingContainer.innerText = rating.toFixed(1); + ratingContainer.prepend(starIcon); + buttonsContainer.appendChild(ratingContainer); } - - 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'; + // TV: real emby-button so Jellyfin spatial nav lands on metadata (plain div is Tab-only) + const metadataContainer = isTvLayout() + ? document.createElement('button') + : document.createElement('div'); + metadataContainer.className = isTvLayout() + ? 'metadata-container emby-button show-focus' + : 'metadata-container'; + if (isTvLayout()) { + metadataContainer.type = 'button'; + metadataContainer.setAttribute('tabindex', '0'); + metadataContainer.setAttribute('aria-label', 'Item metadata'); + metadataContainer.dataset.metadataDrilled = 'false'; + // Collapsed click must not navigate; drill-in is Right/Enter only + metadataContainer.addEventListener('click', (e) => { + if (metadataContainer.dataset.metadataDrilled !== 'true') { + e.preventDefault(); + e.stopPropagation(); + } + }); + } + if (isTvLayout()) metadataRow.classList.add('focuscontainer-x'); 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) { + if (isTvLayout()) seriesInfoContainer.classList.add('focuscontainer-x'); metadataContainer.appendChild(seriesInfoContainer); } } else { @@ -1187,260 +6255,2238 @@ 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); - } + const creditsRow = document.createElement('div'); + creditsRow.className = 'spotlight-metadata-row spotlight-credits-row'; + if (isTvLayout()) creditsRow.classList.add('focuscontainer-x'); + if (studioContainer) creditsRow.appendChild(studioContainer); + creditsRow.appendChild(combinedContainer); + metadataContainer.appendChild(creditsRow); + } else if (directorContainer || writerContainer) { + const creditsRow = document.createElement('div'); + creditsRow.className = 'spotlight-metadata-row spotlight-credits-row'; + if (isTvLayout()) creditsRow.classList.add('focuscontainer-x'); + if (studioContainer) creditsRow.appendChild(studioContainer); + if (directorContainer) creditsRow.appendChild(directorContainer); + if (writerContainer) creditsRow.appendChild(writerContainer); + metadataContainer.appendChild(creditsRow); + } else if (studioContainer) { + const studioRow = document.createElement('div'); + studioRow.className = 'spotlight-metadata-row spotlight-credits-row'; + if (isTvLayout()) studioRow.classList.add('focuscontainer-x'); + studioRow.appendChild(studioContainer); + metadataContainer.appendChild(studioRow); } } - if (metadataContainer.children.length > 0) { - overlay.appendChild(metadataContainer); + if (isTvLayout()) { + // Collapsed by default: links not in vertical focus path until Right/OK drill-in + setMetadataDrilled(metadataContainer, false); } - if (taglineEl) { - overlay.appendChild(taglineEl); - } - overlay.appendChild(buttonsContainer); - + // Fixed 4-section grid: Logo, Metadata, Overview, Buttons (always render all to prevent layout shift) + const logoSection = document.createElement('div'); + logoSection.className = 'spotlight-logo-section'; + logoSection.appendChild(titleEl); + + const metadataSection = document.createElement('div'); + metadataSection.className = 'spotlight-metadata-section'; + metadataSection.appendChild(metadataContainer); + + const overviewSection = document.createElement('div'); + overviewSection.className = 'spotlight-overview-section'; + if (overviewEl.textContent) overviewSection.appendChild(overviewEl); + + const buttonsSection = document.createElement('div'); + buttonsSection.className = 'spotlight-buttons-section'; + buttonsSection.appendChild(buttonsContainer); + + overlay.appendChild(logoSection); + overlay.appendChild(metadataSection); + overlay.appendChild(overviewSection); + overlay.appendChild(buttonsSection); + itemDiv.appendChild(overlay); - + if (taglineEl.textContent) { + const taglineContainer = document.createElement('div'); + taglineContainer.className = 'spotlight-tagline headerTabs sectionTabs'; + taglineContainer.appendChild(taglineEl); + itemDiv.appendChild(taglineContainer); + } + // 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.setAttribute('data-src', clearArtUrl); + if (eagerLoadImages) 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(); + return itemDiv; } - // Touch swipe handling - let touchStartX = 0; - let touchStartY = 0; - - bannerContainer.addEventListener('touchstart', (e) => { - touchStartX = e.touches[0].clientX; - touchStartY = e.touches[0].clientY; - }, { passive: true }); - - bannerContainer.addEventListener('touchmove', (e) => { - if (!e.touches[0]) return; - - const touchX = e.touches[0].clientX; - const touchY = e.touches[0].clientY; - - const deltaX = touchX - touchStartX; - const deltaY = touchY - touchStartY; - - // If horizontal movement is dominant, prevent vertical scrolling and propagation - if (Math.abs(deltaX) > Math.abs(deltaY)) { - if (e.cancelable) { + // Initial render: create slides for window around currentIndex + currentWindowIndices.forEach((idx) => { + createAndAppendSlide(items[idx], idx, idx === currentIndex); + }); + + bannerContainer.appendChild(itemsContainer); + + // Mark the initially visible slide as active so only it is shown + const firstItemActive = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`); + if (firstItemActive) { + firstItemActive.setAttribute('data-active', 'true'); + } + + function syncSpotlightTvFocus() { + if (!isTvLayout()) return; + const isTooltipFocusable = (el) => + !!el.closest?.('.spotlight-truncated-list-tooltip, .spotlight-overview-tooltip'); + const isMetaLink = (el) => + el.classList?.contains('spotlight-genre-link') || + el.classList?.contains('spotlight-person-link') || + el.classList?.contains('spotlight-studio-link'); + bannerContainer.querySelectorAll('.spotlight-item').forEach((slide) => { + const active = slide.getAttribute('data-active') === 'true'; + const meta = slide.querySelector('.metadata-container'); + const drilled = meta?.dataset?.metadataDrilled === 'true'; + + slide.querySelectorAll('a[href], button').forEach((el) => { + if (el.classList.contains('spotlight-dot') || isTooltipFocusable(el)) { + el.setAttribute('tabindex', '-1'); + return; + } + if (!active) { + el.setAttribute('tabindex', '-1'); + return; + } + // Active slide: metadata links gated by drill-in + if (isMetaLink(el)) { + if (drilled) { + if (el.getAttribute('tabindex') === '-1') el.removeAttribute('tabindex'); + } else { + el.setAttribute('tabindex', '-1'); + } + return; + } + if (el.getAttribute('tabindex') === '-1') el.removeAttribute('tabindex'); + }); + + if (meta) { + if (!active) { + meta.setAttribute('tabindex', '-1'); + } else if (drilled) { + meta.setAttribute('tabindex', '-1'); + } else { + meta.setAttribute('tabindex', '0'); + meta.classList.add('show-focus'); + } + } + }); + bannerContainer.querySelectorAll('.spotlight-dot').forEach((dot) => { + dot.setAttribute('tabindex', '-1'); + }); + } + + function captureSpotlightFocusSlot() { + if (!isTvLayout()) return null; + const ae = document.activeElement; + if (!ae || !container.contains(ae)) return null; + if (ae.closest('.spotlight-section-title-container')) return { slot: 'title' }; + if (ae.closest('.spotlight-nav-container')) return { slot: 'nav' }; + const meta = ae.closest('.metadata-container'); + if (meta && container.contains(meta)) { + if (meta.dataset.metadataDrilled === 'true') { + const links = Array.from( + meta.querySelectorAll( + 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link', + ), + ); + return { slot: 'metadata-drilled', linkIndex: Math.max(0, links.indexOf(ae)) }; + } + return { slot: 'metadata' }; + } + const btn = ae.closest('.spotlight-buttons-container'); + if (btn && container.contains(btn)) { + let role = 'play'; + if (ae.classList.contains('watchlist-button') || ae.closest('.watchlist-button')) role = 'watchlist'; + else if (ae.classList.contains('spotlight-info-button') || ae.closest('.spotlight-info-button')) + role = 'info'; + else if (ae.classList.contains('btnPlay') || ae.closest('.btnPlay')) role = 'play'; + return { slot: 'buttons', role }; + } + return null; + } + + function restoreSpotlightFocusSlot(slot) { + if (!slot || !isTvLayout()) return; + const activeSlide = bannerContainer.querySelector('.spotlight-item[data-active="true"]'); + if (slot.slot === 'title') { + const headerCluster = bannerContainer.querySelector('.spotlight-header-container'); + const first = getFirstSpotlightHeaderFocusable(headerCluster); + if (first) focusTvEl(first); + return; + } + if (slot.slot === 'nav') { + const nav = bannerContainer.querySelector('.spotlight-nav-buttons-container'); + const first = getTvFocusables(nav)[0]; + if (first) focusTvEl(first); + return; + } + if (!activeSlide) return; + if (slot.slot === 'metadata') { + collapseAllMetadataDrillIn(activeSlide); + const meta = activeSlide.querySelector('.metadata-container'); + if (meta) { + setMetadataDrilled(meta, false); + focusTvEl(meta); + } + return; + } + if (slot.slot === 'metadata-drilled') { + const meta = activeSlide.querySelector('.metadata-container'); + if (!meta) return; + setMetadataDrilled(meta, true); + const links = Array.from( + meta.querySelectorAll( + 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link', + ), + ); + const idx = Math.min(slot.linkIndex || 0, Math.max(0, links.length - 1)); + if (links[idx]) focusTvEl(links[idx]); + else if (links[0]) focusTvEl(links[0]); + else { + setMetadataDrilled(meta, false); + focusTvEl(meta); + } + return; + } + if (slot.slot === 'buttons') { + const buttons = activeSlide.querySelector('.spotlight-buttons-container'); + if (!buttons) return; + let target = null; + if (slot.role === 'watchlist') target = buttons.querySelector('.watchlist-button'); + else if (slot.role === 'info') target = buttons.querySelector('.spotlight-info-button'); + else target = buttons.querySelector('.btnPlay'); + focusTvEl(target || getTvFocusables(buttons)[0]); + } + } + + if (!inert) { + // Delegated handler: info button click -> navigate to item details for the current slide + bannerContainer.addEventListener('click', (e) => { + const infoBtn = e.target.closest('.spotlight-info-button'); + if (!infoBtn || !bannerContainer.contains(infoBtn)) return; + + e.preventDefault(); + e.stopPropagation(); + + const slide = infoBtn.closest('.spotlight-item'); + if (!slide) return; + + const itemId = slide.getAttribute('data-id'); + if (!itemId) return; + + const detailsUrl = `#/details?id=${itemId}&serverId=${serverId}`; + if (typeof Dashboard !== 'undefined' && Dashboard && typeof Dashboard.navigate === 'function') { + Dashboard.navigate(detailsUrl); + } else { + const fullDetailsUrl = `${serverAddress}/web/#${detailsUrl}`; + window.location.href = fullDetailsUrl; + } + }); + + // Initial Ken Burns (.animate) is deferred until first visibilityObserver intersect + one rAF + // (see visibilityObserver below). Navigation/timer still applies sync via goToItem. + + // Navigation buttons (top right) + if (showNavButtons && items.length > 1) { + const navButtonsContainer = document.createElement('div'); + navButtonsContainer.className = 'spotlight-nav-buttons-container emby-tab-button emby-tab-button-active emby-button-foreground'; + + const prevButton = document.createElement('button'); + prevButton.className = 'spotlight-nav-button spotlight-nav-prev emby-button'; + if (isTvLayout()) prevButton.classList.add('show-focus'); + 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'; + if (isTvLayout()) nextButton.classList.add('show-focus'); + 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); + }); + + navButtonsContainer.appendChild(prevButton); + navButtonsContainer.appendChild(nextButton); + + const navContainer = document.createElement('div'); + navContainer.className = 'spotlight-nav-container headerTabs sectionTabs'; + navContainer.appendChild(navButtonsContainer); + + const headerContainer = + bannerContainer.querySelector('.spotlight-header-container') || bannerContainer; + headerContainer.appendChild(navContainer); + } + + // Pause button (bottom right) — only when nav chrome exists (showNavButtons) + if (autoPlay && items.length > 1) { + const navButtonsContainer = bannerContainer.querySelector('.spotlight-nav-container .spotlight-nav-buttons-container'); + if (navButtonsContainer) { + const pauseButton = document.createElement('button'); + pauseButton.className = 'spotlight-pause-button spotlight-nav-button emby-button'; + if (isTvLayout()) pauseButton.classList.add('show-focus'); + 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) { + if (autoPlayTimer) { + clearTimeout(autoPlayTimer); + autoPlayTimer = null; + } + pauseIcon.textContent = 'play_arrow'; + } else { + startAutoPlay(); + pauseIcon.textContent = 'pause'; + } + }); + + navButtonsContainer.insertBefore(pauseButton, navButtonsContainer.firstChild); + } + } + + // Slide state container (dots or numeric "N / X") + const MAX_DOTS = 5; + if (showSlideState && items.length > 1) { + const dotsContainer = document.createElement('div'); + dotsContainer.className = 'spotlight-dots' + (showDots ? '' : ' spotlight-dots-numeric'); + dotsContainer.setAttribute('data-index', String(currentIndex + 1)); /* 1-based for "N / X" display */ + dotsContainer.setAttribute('data-total-items', String(items.length)); + + if (showDots) { + const numDots = Math.min(items.length, MAX_DOTS); + const activeDotIndex = currentIndex % numDots; + for (let i = 0; i < numDots; i++) { + const dot = document.createElement('button'); + dot.className = 'spotlight-dot' + (i === activeDotIndex ? ' active' : ''); + dot.setAttribute('data-dot-index', i); + dot.addEventListener('click', (e) => { + e.stopPropagation(); + const windowStart = Math.floor(currentIndex / numDots) * numDots; + const targetIndex = Math.min(windowStart + i, items.length - 1); + goToItem(targetIndex, true); + }); + dotsContainer.appendChild(dot); + } + } + + bannerContainer.appendChild(dotsContainer); + } + + // Load images for a slide (and optionally preload next). Only sets src/backgroundImage from data-* so browser loads on demand. + function ensureSlideImagesLoaded(slideIndex) { + if (slideIndex < 0 || slideIndex >= items.length) return; + const itemEl = bannerContainer.querySelector(`.spotlight-item[data-index="${slideIndex}"]`); + if (!itemEl) return; + itemEl.querySelectorAll('img[data-src]').forEach((img) => { + const url = img.getAttribute('data-src'); + // Use content attribute — img.src IDL resolves '' to the document URL and would skip reload + if (url && img.getAttribute('src') !== url) img.setAttribute('src', url); + }); + const bgUrl = itemEl.getAttribute('data-background-url'); + if (bgUrl && !itemEl.style.backgroundImage) itemEl.style.backgroundImage = `url("${bgUrl}")`; + const logoEl = itemEl.querySelector('.spotlight-item-logo[data-background-url]'); + if (logoEl) { + const logoUrl = logoEl.getAttribute('data-background-url'); + if (logoUrl && !logoEl.style.backgroundImage) logoEl.style.backgroundImage = `url("${logoUrl}")`; + } + } + + // Apply pan animation to a slide and track when it completes (for re-trigger on viewport re-entry) + const SLIDE_ANIMATION_NAMES = new Set([ + 'kenBurnsZoomOut', + 'kenBurnsZoomIn', + 'kenBurnsZoomOutFullscreen', + 'kenBurnsZoomInFullscreen', + 'kenBurnsPanRight', + 'kenBurnsPanLeft', + 'kenBurnsPanUp', + 'kenBurnsDiagonal', + 'fadeInScale', + 'parallaxFloat', + 'depthPulse', + 'slowRotate', + 'breathe', + 'heatHaze', + 'colorWash', + 'vignetteIn', + ]); + const SLIDE_ANIMATION_FINAL_TRANSFORM = { + kenBurnsZoomIn: 'scale3d(1.12, 1.12, 1)', + kenBurnsZoomOut: 'scale3d(1.02, 1.02, 1)', + kenBurnsZoomInFullscreen: 'scale3d(1.12, 1.12, 1)', + kenBurnsZoomOutFullscreen: 'scale3d(1.08, 1.08, 1)', + kenBurnsPanRight: 'scale3d(1.1, 1.1, 1) translateX(5%)', + kenBurnsPanLeft: 'scale3d(1.1, 1.1, 1) translateX(-5%)', + kenBurnsPanUp: 'scale3d(1.1, 1.1, 1) translateY(-5%)', + kenBurnsDiagonal: 'scale3d(1.13, 1.13, 1) translate(5%, -5%)', + fadeInScale: 'scale3d(1, 1, 1)', + parallaxFloat: 'scale3d(1, 1, 1) translateY(0)', + depthPulse: 'scale3d(1, 1, 1)', + slowRotate: 'scale3d(1.15, 1.15, 1) rotate(3deg)', + breathe: 'scale3d(1, 1, 1)', + heatHaze: 'scale3d(1.05, 1.05, 1) skewX(0deg)', + }; + /** Prefer live dataset (section configure toggle) over create-time option. */ + function isPanAnimationEnabled() { + return container.dataset.panAnimation !== 'false'; + } + + function applyPanAnimationToSlide(slideElement) { + if (!slideElement || !isPanAnimationEnabled()) return; + const imgs = slideElement.querySelectorAll('.spotlight-background-layer img, .spotlight-background-single img'); + if (!imgs.length) return; + currentSlideAnimationComplete = false; + let finishedCount = 0; + const checkAllDone = () => { + finishedCount++; + if (finishedCount >= imgs.length) currentSlideAnimationComplete = true; + }; + imgs.forEach((img) => { + img.style.transform = ''; + img.style.animationPlayState = ''; + img.classList.remove('animate'); + img.classList.add('animate'); + const slideName = img.getAttribute('data-slide-animation') || ''; + const handler = (e) => { + if (e.animationName === slideName || SLIDE_ANIMATION_NAMES.has(e.animationName)) { + img.removeEventListener('animationend', handler); + checkAllDone(); + var finalTransform = SLIDE_ANIMATION_FINAL_TRANSFORM[e.animationName]; + if (finalTransform) { + requestAnimationFrame(() => { + img.style.transform = finalTransform; + }); + } + } + }; + img.addEventListener('animationend', handler); + }); + } + + function setSlideAnimationPlayState(slideElement, playState) { + if (!slideElement || !isPanAnimationEnabled()) return; + slideElement + .querySelectorAll('.spotlight-background-layer img, .spotlight-background-single img') + .forEach((img) => { + img.style.animationPlayState = playState; + }); + } + + function getCurrentSpotlightSlide() { + return bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`); + } + + function startCycleBackdropTimer(slideElement, itemId) { + if (cycleBackdropTimer) { + clearInterval(cycleBackdropTimer); + cycleBackdropTimer = null; + } + if (!cycleBackdrops || tileCount !== 1 || !isVisible) return; + const container = slideElement.querySelector('.spotlight-background-single.cycle-backdrops'); + const imgs = container ? container.querySelectorAll('img') : []; + const mapKey = sectionKey + '_' + itemId; + const entry = cycleBackdropMap.get(mapKey); + if (!entry || !entry.shuffledUrls.length) return; + var cycleCount = 0; + var maxCycles = Math.max(0, backdropsCount - 1); + if (imgs.length >= 2) { + const bottomImg = imgs[0]; + const topImg = imgs[1]; + const crossfadeDuration = '0.8s'; + cycleBackdropTimer = setInterval(() => { + if (!isVisible) return; + if (cycleCount >= maxCycles) { + if (cycleBackdropTimer) { + clearInterval(cycleBackdropTimer); + cycleBackdropTimer = null; + } + return; + } + cycleCount++; + entry.currentIndex = (entry.currentIndex + 1) % entry.shuffledUrls.length; + const nextUrl = entry.shuffledUrls[entry.currentIndex]; + const preload = new Image(); + preload.onload = () => { + topImg.src = nextUrl; + topImg.setAttribute('data-src', nextUrl); + topImg.style.transition = 'opacity ' + crossfadeDuration + ' ease-in-out'; + topImg.style.opacity = '0'; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + topImg.style.opacity = '1'; + }); + }); + const handler = (e) => { + if (e.propertyName !== 'opacity') return; + topImg.removeEventListener('transitionend', handler); + bottomImg.src = nextUrl; + bottomImg.setAttribute('data-src', nextUrl); + topImg.style.opacity = '0'; + }; + topImg.addEventListener('transitionend', handler); + }; + preload.src = nextUrl; + }, perBackdropMs); + } else { + const img = slideElement.querySelector('.spotlight-background-single img'); + if (!img) return; + cycleBackdropTimer = setInterval(() => { + if (!isVisible) return; + if (cycleCount >= maxCycles) { + if (cycleBackdropTimer) { + clearInterval(cycleBackdropTimer); + cycleBackdropTimer = null; + } + return; + } + cycleCount++; + entry.currentIndex = (entry.currentIndex + 1) % entry.shuffledUrls.length; + const nextUrl = entry.shuffledUrls[entry.currentIndex]; + const preload = new Image(); + preload.onload = () => { + img.style.opacity = '0'; + img.style.transition = 'opacity 0.8s ease-in-out'; + requestAnimationFrame(() => { + img.src = nextUrl; + img.setAttribute('data-src', nextUrl); + requestAnimationFrame(() => { + img.style.opacity = '1'; + }); + }); + }; + preload.src = nextUrl; + }, perBackdropMs); + } + } + + // Go to item function (crossfade transition, then only active slide is visible) + function goToItem(index, resetTimer = true) { + if (index === currentIndex) return; + const focusSlot = captureSpotlightFocusSlot(); + if (cycleBackdropTimer) { + clearInterval(cycleBackdropTimer); + cycleBackdropTimer = null; + } + const newWindowIndices = getSpotlightWindowIndices(index, items.length); + const indexToRemove = currentWindowIndices.find((i) => !newWindowIndices.includes(i)); + const indexToAdd = newWindowIndices.find((i) => !currentWindowIndices.includes(i)); + + var slideToRemove = undefined; + if (indexToRemove !== undefined) { + slideToRemove = bannerContainer.querySelector(`.spotlight-item[data-index="${indexToRemove}"]`); + } + if (indexToAdd !== undefined) { + createAndAppendSlide(items[indexToAdd], indexToAdd, false); + } + currentWindowIndices = newWindowIndices; + + ensureSlideImagesLoaded(index); + ensureSlideImagesLoaded((index + 1) % items.length); + + const currentItem = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`); + const nextItem = bannerContainer.querySelector(`.spotlight-item[data-index="${index}"]`); + + // Prune the slide that left the window so the container never exceeds 9 items. + // If it's not the one we're fading out, remove it now; otherwise remove it in onFadeOutEnd. + if (slideToRemove && slideToRemove.parentNode && slideToRemove !== currentItem) { + slideToRemove.remove(); + } + + if (currentItem && nextItem) { + // Start crossfade: outgoing stays visible but fades out; incoming becomes visible and fades in + currentItem.removeAttribute('data-active'); + currentItem.setAttribute('data-fade-out', 'true'); + nextItem.setAttribute('data-active', 'true'); + nextItem.setAttribute('data-entering', 'true'); + applyPanAnimationToSlide(nextItem); + startCycleBackdropTimer(nextItem, items[index].Id); + + // After next frame, remove data-entering so incoming slide transitions from 0 to 1 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + nextItem.removeAttribute('data-entering'); + }); + }); + + // When outgoing slide opacity transition ends, clear its fade-out state and prune if it left the window + const onFadeOutEnd = (e) => { + if (e.propertyName !== 'opacity') return; + currentItem.removeEventListener('transitionend', onFadeOutEnd); + currentItem.removeAttribute('data-fade-out'); + if (slideToRemove && slideToRemove.parentNode) { + slideToRemove.remove(); + } + }; + currentItem.addEventListener('transitionend', onFadeOutEnd); + } else if (slideToRemove && slideToRemove.parentNode) { + slideToRemove.remove(); + } + currentIndex = index; + + // Update slide state (dots or data attrs for numeric) + if (showSlideState) { + const dotsContainer = bannerContainer.querySelector('.spotlight-dots'); + if (dotsContainer) { + dotsContainer.setAttribute('data-index', String(index + 1)); // 1-based for display + } + if (showDots) { + const dots = bannerContainer.querySelectorAll('.spotlight-dot'); + const numDots = dots.length; + const activeDotIndex = numDots > 0 ? index % numDots : 0; + dots.forEach((dot, i) => { + if (i === activeDotIndex) { + dot.classList.add('active'); + } else { + dot.classList.remove('active'); + } + }); + } + } + + // Reset auto-play timer when manually navigating (always reset delay) + if (resetTimer && autoPlay && !isPaused) { + if (autoPlayTimer) { + clearTimeout(autoPlayTimer); + autoPlayTimer = null; + } + advanceDue = false; + startAutoPlay(); + } + + syncSpotlightTvFocus(); + if (focusSlot) { + requestAnimationFrame(() => restoreSpotlightFocusSlot(focusSlot)); + } + } + + // Auto-play: single-timeout per slide, controlled only by pause/visibility (hover is ignored) + function startAutoPlay() { + if (autoPlayTimer) { + clearTimeout(autoPlayTimer); + autoPlayTimer = null; + } + if (!autoPlay || items.length <= 1 || isPaused || !isVisible) return; + autoPlayTimer = setTimeout(onAutoPlayTimerFired, interval); + } + + function onAutoPlayTimerFired() { + autoPlayTimer = null; + if (isPaused) { + advanceDue = true; + return; + } + if (!autoPlay || !isVisible || items.length <= 1) return; + goToItem((currentIndex + 1) % items.length, false); + advanceDue = false; + // Schedule next slide timer for the new current slide + if (autoPlay && !isPaused && isVisible && items.length > 1) { + autoPlayTimer = setTimeout(onAutoPlayTimerFired, interval); + } + } + + // Only cycle when spotlight is in view: pause CSS pan + stop timers when off-screen. + // First intersect also gates initial .animate (post one rAF so IO + animate don't stack). + let initialPanScheduled = false; + const visibilityObserver = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry) return; + isVisible = entry.isIntersecting; + const currentSlide = getCurrentSpotlightSlide(); + if (!isVisible) { + if (autoPlayTimer) { + clearTimeout(autoPlayTimer); + autoPlayTimer = null; + } + if (cycleBackdropTimer) { + clearInterval(cycleBackdropTimer); + cycleBackdropTimer = null; + } + setSlideAnimationPlayState(currentSlide, 'paused'); + } else { + ensureSlideImagesLoaded(currentIndex); + if (items.length > 1) { + ensureSlideImagesLoaded((currentIndex + 1) % items.length); + } + + const needsInitialPan = + isPanAnimationEnabled() && + currentSlide && + !initialPanScheduled && + !currentSlide.querySelector( + '.spotlight-background-layer img.animate, .spotlight-background-single img.animate', + ); + + if (needsInitialPan) { + initialPanScheduled = true; + requestAnimationFrame(() => { + if (!container.isConnected || !isPanAnimationEnabled() || !isVisible) return; + const slide = getCurrentSpotlightSlide(); + if (!slide) return; + if ( + !slide.querySelector( + '.spotlight-background-layer img.animate, .spotlight-background-single img.animate', + ) + ) { + applyPanAnimationToSlide(slide); + } + if (items[currentIndex]) { + startCycleBackdropTimer(slide, items[currentIndex].Id); + } + }); + } else { + // Re-entering viewport: resume pan, restart timers + setSlideAnimationPlayState(currentSlide, 'running'); + if (currentSlide && items[currentIndex]) { + startCycleBackdropTimer(currentSlide, items[currentIndex].Id); + } + } + + if (autoPlay && !isPaused) { + startAutoPlay(); + } + } + }, + { root: null, rootMargin: '0px', threshold: 0 }, + ); + visibilityObserver.observe(container); + + document.addEventListener('visibilitychange', () => { + const currentSlide = getCurrentSpotlightSlide(); + if (document.hidden) { + if (autoPlayTimer) { + clearTimeout(autoPlayTimer); + autoPlayTimer = null; + } + setSlideAnimationPlayState(currentSlide, 'paused'); + } else { + if (isVisible) { + setSlideAnimationPlayState(currentSlide, 'running'); + } + if (autoPlay && !isPaused && isVisible) { + startAutoPlay(); + } + } + }); + + // Start auto-play (will no-op if container not yet in DOM or not visible once observer runs) + if (autoPlay) { + startAutoPlay(); + } + + // Preload next slide soon after mount so transition has no pop + if (items.length > 1) { + requestAnimationFrame(() => ensureSlideImagesLoaded(1)); + } + + // TV D-pad: metadata drill-in, row-scoped seams (header L/R is native focuscontainer-x) + if (isTvLayout()) { + const ARROW = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']); + + function getHeaderCluster() { + return bannerContainer.querySelector('.spotlight-header-container'); + } + function getActiveMeta() { + return bannerContainer.querySelector( + '.spotlight-item[data-active="true"] .metadata-container', + ); + } + function getActiveButtons() { + return bannerContainer.querySelector( + '.spotlight-item[data-active="true"] .spotlight-buttons-container', + ); + } + + function exitMetadataTo(direction) { + const meta = getActiveMeta(); + if (meta) setMetadataDrilled(meta, false); + if (direction === 'up') { + focusTvEl(getFirstSpotlightHeaderFocusable(getHeaderCluster())); + } else { + const buttons = getActiveButtons(); + focusTvEl(getTvFocusables(buttons)[0]); + } + } + + function enterMetadataDrillIn() { + const meta = getActiveMeta(); + if (!meta) return false; + setMetadataDrilled(meta, true); + const rows = getMetadataRows(meta); + for (const row of rows) { + const links = Array.from( + row.querySelectorAll( + 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link', + ), + ); + if (links[0]) { + focusTvEl(links[0]); + return true; + } + } + setMetadataDrilled(meta, false); + return false; + } + + bannerContainer.addEventListener( + 'keydown', + (e) => { + if (!ARROW.has(e.key) && e.key !== 'Enter' && e.key !== ' ') return; + const ae = document.activeElement; + if (!ae || !bannerContainer.contains(ae)) return; + + const meta = getActiveMeta(); + const inMeta = !!(meta && meta.contains(ae)); + const drilled = meta?.dataset?.metadataDrilled === 'true'; + + // Metadata container: Right/Enter drills in; Up/Down leave to neighbors + if (inMeta && ae === meta && !drilled) { + if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + enterMetadataDrillIn(); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + exitMetadataTo('up'); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + exitMetadataTo('down'); + return; + } + } + + // Metadata drilled: row-scoped L/R + Up/Down exit + if (inMeta && drilled && meta) { + const rows = getMetadataRows(meta).filter((row) => + row.querySelector( + 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link', + ), + ); + const row = ae.closest('.spotlight-metadata-row'); + const rowIndex = rows.indexOf(row); + const rowLinks = row + ? Array.from( + row.querySelectorAll( + 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link', + ), + ) + : []; + const linkIndex = rowLinks.indexOf(ae); + + if (e.key === 'ArrowUp') { + if (rowIndex <= 0) { + e.preventDefault(); + e.stopPropagation(); + exitMetadataTo('up'); + return; + } + // Move to previous row rightmost for predictability when using Up between rows + const prevLinks = Array.from( + rows[rowIndex - 1].querySelectorAll( + 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link', + ), + ); + if (prevLinks.length) { + e.preventDefault(); + e.stopPropagation(); + focusTvEl(prevLinks[prevLinks.length - 1]); + } + return; + } + if (e.key === 'ArrowDown') { + if (rowIndex < 0 || rowIndex >= rows.length - 1) { + e.preventDefault(); + e.stopPropagation(); + exitMetadataTo('down'); + return; + } + const nextLinks = Array.from( + rows[rowIndex + 1].querySelectorAll( + 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link', + ), + ); + if (nextLinks[0]) { + e.preventDefault(); + e.stopPropagation(); + focusTvEl(nextLinks[0]); + } + return; + } + if (e.key === 'ArrowLeft' && linkIndex === 0) { + e.preventDefault(); + e.stopPropagation(); + if (rowIndex > 0) { + // Bottom/mid row first link → previous row (not category rail) + const prevLinks = Array.from( + rows[rowIndex - 1].querySelectorAll( + 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link', + ), + ); + focusTvEl(prevLinks[prevLinks.length - 1] || meta); + } else { + // Top row first link → title / header + exitMetadataTo('up'); + } + return; + } + if (e.key === 'ArrowRight' && linkIndex === rowLinks.length - 1) { + if (rowIndex >= 0 && rowIndex < rows.length - 1) { + const nextLinks = Array.from( + rows[rowIndex + 1].querySelectorAll( + 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link', + ), + ); + if (nextLinks[0]) { + e.preventDefault(); + e.stopPropagation(); + focusTvEl(nextLinks[0]); + } + } else { + // Right on last bottom link → buttons row + e.preventDefault(); + e.stopPropagation(); + exitMetadataTo('down'); + } + return; + } + // Left/Right mid-row: let focuscontainer-x / browser handle + } + }, + true, + ); + + // Vertical entry into header / buttons → snap leftmost + bannerContainer.addEventListener( + 'focusin', + (e) => { + const target = e.target; + if (!target || !bannerContainer.contains(target)) return; + const related = e.relatedTarget; + + const headerCluster = getHeaderCluster(); + const buttons = getActiveButtons(); + + const enteredHeader = + headerCluster?.contains(target) && + (!related || !headerCluster.contains(related)); + const enteredButtons = + buttons?.contains(target) && (!related || !buttons.contains(related)); + + if (enteredHeader) { + const first = getFirstSpotlightHeaderFocusable(headerCluster); + if (first && target !== first) focusTvEl(first); + } else if (enteredButtons) { + const fs = getTvFocusables(buttons); + if (fs[0] && target !== fs[0]) focusTvEl(fs[0]); + } + }, + true, + ); + } + + // Touch swipe handling + let touchStartX = 0; + let touchStartY = 0; + + bannerContainer.addEventListener( + 'touchstart', + (e) => { + touchStartX = e.touches[0].clientX; + touchStartY = e.touches[0].clientY; + }, + { passive: true }, + ); + + bannerContainer.addEventListener( + 'touchmove', + (e) => { + if (!e.touches[0]) return; + + const touchX = e.touches[0].clientX; + const touchY = e.touches[0].clientY; + + const deltaX = touchX - touchStartX; + const deltaY = touchY - touchStartY; + + // If horizontal movement is dominant, prevent vertical scrolling and propagation + if (Math.abs(deltaX) > Math.abs(deltaY)) { + if (e.cancelable) { + e.preventDefault(); + e.stopPropagation(); + } + } + }, + { passive: false }, + ); + + bannerContainer.addEventListener( + 'touchend', + (e) => { + if (!e.changedTouches[0]) return; + + const touchEndX = e.changedTouches[0].clientX; + const touchEndY = e.changedTouches[0].clientY; + + const deltaX = touchEndX - touchStartX; + const deltaY = touchEndY - touchStartY; + + // Threshold for swipe (50px) + if (Math.abs(deltaX) > 50 && Math.abs(deltaX) > Math.abs(deltaY)) { + // Stop propagation to prevent parent handlers (like tab switching) + e.stopPropagation(); + + if (deltaX > 0) { + // Swipe right (previous) + goToItem((currentIndex - 1 + items.length) % items.length, true); + } else { + // Swipe left (next) + goToItem((currentIndex + 1) % items.length, true); + } + } + }, + { passive: true }, + ); + } // end !inert wiring + + container.appendChild(bannerContainer); + + container.dataset.layout = layout; + container.dataset.size = size; + container.dataset.tileCount = String(tileCount); + + // Allow in-place SWR to update the closed-over items list used by goToItem / windowing + container._kefinSpotlightApplyItems = (nextItems, opts = {}) => { + items = Array.isArray(nextItems) ? nextItems.slice() : []; + if (typeof opts.currentIndex === 'number' && items.length) { + currentIndex = Math.max(0, Math.min(items.length - 1, opts.currentIndex)); + } else if (currentIndex >= items.length) { + currentIndex = Math.max(0, items.length - 1); + } + currentWindowIndices = getSpotlightWindowIndices(currentIndex, items.length); + }; + + container._kefinSpotlightPruneWindow = () => { + if (!items.length) return; + currentWindowIndices = getSpotlightWindowIndices(currentIndex, items.length); + const keep = new Set(currentWindowIndices.map(String)); + itemsContainer.querySelectorAll(':scope > .spotlight-item').forEach((slide) => { + const idx = slide.getAttribute('data-index'); + if (keep.has(idx)) return; + if (slide.getAttribute('data-active') === 'true') return; + slide.remove(); + }); + currentWindowIndices.forEach((idx) => { + if (itemsContainer.querySelector(`.spotlight-item[data-index="${idx}"]`)) return; + if (!items[idx]) return; + createAndAppendSlide(items[idx], idx, idx === currentIndex); + }); + syncSpotlightTvFocus(); + }; + + syncSpotlightTvFocus(); + + return container; + } + + const ITEMS_LAYOUT_CYCLE = ['row', 'grid']; + const ITEMS_LAYOUT_ICONS = { + row: 'view_array', + grid: 'grid_view', + }; + const ITEMS_LAYOUT_NEXT_TITLE = { + row: 'Grid layout', + grid: 'Row layout', + }; + + function resolveItemsLayout(sectionConfig) { + if (!sectionConfig) return 'row'; + if (sectionConfig.itemsLayout === 'grid') return 'grid'; + if (sectionConfig.itemsLayout === 'row') return 'row'; + if (sectionConfig.gridExpanded === true) return 'grid'; + return 'row'; + } + + function resolveUseGaplessCards(sectionConfig) { + return sectionConfig?.useGaplessCards === true; + } + + function nextItemsLayout(current) { + const idx = ITEMS_LAYOUT_CYCLE.indexOf(current); + return ITEMS_LAYOUT_CYCLE[(idx < 0 ? 0 : idx + 1) % ITEMS_LAYOUT_CYCLE.length]; + } + + function setShowAllIcon(button, layout) { + if (!button) return; + const iconName = ITEMS_LAYOUT_ICONS[layout] || 'view_array'; + const icon = button.querySelector('.material-icons') || button; + icon.classList.remove('view_array', 'grid_view', 'grid_on'); + icon.classList.add(iconName); + // Keep glyph classes off the paper-icon button itself + button.classList.remove('view_array', 'grid_view', 'grid_on'); + } + + function isButtonCardFormat(cardFormat) { + return (cardFormat || '').toLowerCase() === 'button'; + } + + function getPaintedSectionItems(itemsContainer, cardFormat) { + if (!itemsContainer) return []; + if (isButtonCardFormat(cardFormat)) { + return Array.from(itemsContainer.querySelectorAll(':scope > .homeLibraryButton')); + } + return Array.from(itemsContainer.querySelectorAll(':scope > .card')); + } + + function getRealLayoutCards(itemsContainer) { + if (!itemsContainer) return []; + return Array.from(itemsContainer.querySelectorAll(':scope > .card')); + } + + /** + * Apply items layout (row|grid) and optional gapless cards on a scrollable section. + * @param {HTMLElement} verticalSection + * @param {string} layout + * @param {boolean} [gapless] + */ + function applyItemsLayoutState(verticalSection, layout, gapless) { + if (!verticalSection) return; + const itemsContainer = verticalSection.querySelector('.itemsContainer'); + if (!itemsContainer) return; + const resolved = layout === 'grid' ? 'grid' : 'row'; + const showAllButton = verticalSection.querySelector('.show-all-button'); + const scrollButtons = verticalSection.querySelector('.emby-scrollbuttons'); + itemsContainer.setAttribute('data-layout', resolved); + verticalSection.setAttribute('data-layout', resolved); + itemsContainer.removeAttribute('data-expanded'); + if (gapless === true) { + itemsContainer.setAttribute('data-gapless', 'true'); + } else if (gapless === false) { + itemsContainer.removeAttribute('data-gapless'); + } + if (resolved === 'row') { + if (scrollButtons) scrollButtons.style.display = ''; + } else if (scrollButtons) { + scrollButtons.style.display = 'none'; + } + if (showAllButton) { + const nextLayout = nextItemsLayout(resolved); + showAllButton.title = ITEMS_LAYOUT_NEXT_TITLE[resolved] || ITEMS_LAYOUT_NEXT_TITLE.row; + showAllButton.setAttribute('aria-label', showAllButton.title); + setShowAllIcon(showAllButton, nextLayout); + } + } + + /** @deprecated Use applyItemsLayoutState */ + function applyGridExpandedState(verticalSection, expanded) { + applyItemsLayoutState(verticalSection, expanded ? 'grid' : 'row'); + } + + const pendingItemsLayoutSaves = new Map(); + let itemsLayoutSaveTimeout = null; + + function scheduleItemsLayoutSave(sectionId, layout, sectionType) { + if (!sectionId) return; + const value = layout === 'grid' ? layout : 'row'; + pendingItemsLayoutSaves.set(sectionId, { layout: value, type: sectionType }); + window.KefinHomeScreenSectionConfigure?.updateRuntimeItemsLayout?.(sectionId, value); + clearTimeout(itemsLayoutSaveTimeout); + itemsLayoutSaveTimeout = setTimeout(async () => { + const batch = new Map(pendingItemsLayoutSaves); + pendingItemsLayoutSaves.clear(); + const api = window.KefinUserHomeScreenConfig; + const save = api?.saveSectionItemsLayout || api?.saveSectionGridExpanded; + if (!save) return; + for (const [id, entry] of batch) { + try { + const itemsLayout = typeof entry === 'object' ? entry.layout : entry; + const type = typeof entry === 'object' ? entry.type : undefined; + await save.call(api, id, itemsLayout, type ? { type } : undefined); + } catch (e) { + console.warn('[KefinTweaks CardBuilder] Failed to save itemsLayout:', e); + } + } + }, 300); + } + + function isCardScrollerSection(sectionElement) { + return !!sectionElement?.querySelector('.itemsContainer'); + } + + function getShowAllControlLabel(sectionElement) { + const itemsContainer = sectionElement?.querySelector('.itemsContainer'); + const currentLayout = itemsContainer?.getAttribute('data-layout') || 'row'; + const resolved = currentLayout === 'grid' ? currentLayout : 'row'; + return ITEMS_LAYOUT_NEXT_TITLE[resolved] || ITEMS_LAYOUT_NEXT_TITLE.row; + } + + function getShowAllControlIcon(sectionElement) { + const itemsContainer = sectionElement?.querySelector('.itemsContainer'); + const currentLayout = itemsContainer?.getAttribute('data-layout') || 'row'; + const resolved = currentLayout === 'grid' ? currentLayout : 'row'; + return ITEMS_LAYOUT_ICONS[nextItemsLayout(resolved)] || 'grid_view'; + } + + function handleShowAllLayoutToggle(sectionElement) { + const verticalSection = sectionElement.closest('.emby-scroller-container') || sectionElement; + const itemsContainer = verticalSection.querySelector('.itemsContainer'); + if (!itemsContainer) return; + + const scroller = verticalSection.querySelector('.emby-scroller'); + const currentLayout = itemsContainer.getAttribute('data-layout') || 'row'; + const gapless = itemsContainer.getAttribute('data-gapless') === 'true'; + const layout = nextItemsLayout(currentLayout === 'grid' ? currentLayout : 'row'); + const sectionId = verticalSection.getAttribute('data-section-id') || verticalSection.dataset?.sectionId; + const sectionType = verticalSection.getAttribute('data-section-type') || verticalSection.dataset?.sectionType; + + if (layout === 'row') { + applyItemsLayoutState(verticalSection, 'row', gapless); + updateScrollButtonStateForSection(verticalSection); + const saved = parseFloat(itemsContainer.getAttribute('data-row-scroll-x')); + const position = Number.isFinite(saved) ? saved : 0; + setScrollerPosition(scroller, position, true); + requestAnimationFrame(() => applyScrollButtonState(verticalSection)); + scheduleItemsLayoutSave(sectionId, 'row', sectionType); + } else { + if (currentLayout === 'row') { + itemsContainer.setAttribute('data-row-scroll-x', String(getScrollerPosition(scroller))); + setScrollerPosition(scroller, 0, false); + } + applyItemsLayoutState(verticalSection, layout, gapless); + scheduleItemsLayoutSave(sectionId, layout, sectionType); + } + } + + function getSectionControlDefinitions(sectionConfig, sectionElement, cachedItems) { + const definitions = []; + + definitions.push({ + id: 'refresh', + icon: 'refresh', + label: 'Refresh', + className: 'section-refresh-button', + isVisible: () => true, + onClick: async (e) => { + e.preventDefault(); + e.stopPropagation(); + await executeSectionRefresh(sectionConfig, sectionElement); + }, + }); + + if (isCardScrollerSection(sectionElement)) { + definitions.push({ + id: 'showAll', + icon: 'grid_view', + getIcon: () => getShowAllControlIcon(sectionElement), + getLabel: () => getShowAllControlLabel(sectionElement), + label: 'Grid layout', + className: 'show-all-button', + isVisible: () => true, + onClick: (e) => { e.preventDefault(); e.stopPropagation(); - } + handleShowAllLayoutToggle(sectionElement); + }, + }); + } + + const configureDef = window.KefinHomeScreenSectionConfigure?.getConfigureSectionControl?.( + sectionConfig, + sectionElement, + cachedItems, + ); + if (configureDef) { + definitions.push(configureDef); + } + + return definitions; + } + + function renderSectionControls(sectionConfig, sectionElement, titleContainer, definitions) { + const { inline, moreButton } = ensureSectionControlsMount(titleContainer); + inline.innerHTML = ''; + + const visibleDefinitions = definitions.filter((def) => !def.isVisible || def.isVisible()); + let refreshButton = null; + + visibleDefinitions.forEach((def) => { + const label = typeof def.getLabel === 'function' ? def.getLabel() : def.label; + const icon = typeof def.getIcon === 'function' ? def.getIcon() : def.icon; + const button = createPaperIconButton({ + className: def.className || '', + icon: icon || '', + title: label, + showFocus: isTvLayout(), + }); + button.dataset.sectionControlId = def.id; + button.addEventListener('click', (e) => { + def.onClick(e, button); + }); + inline.appendChild(button); + if (def.id === 'refresh') { + refreshButton = button; } - }, { passive: false }); - - bannerContainer.addEventListener('touchend', (e) => { - if (!e.changedTouches[0]) return; - - const touchEndX = e.changedTouches[0].clientX; - const touchEndY = e.changedTouches[0].clientY; - - const deltaX = touchEndX - touchStartX; - const deltaY = touchEndY - touchStartY; - - // Threshold for swipe (50px) - if (Math.abs(deltaX) > 50 && Math.abs(deltaX) > Math.abs(deltaY)) { - // Stop propagation to prevent parent handlers (like tab switching) - e.stopPropagation(); - - if (deltaX > 0) { - // Swipe right (previous) - goToItem((currentIndex - 1 + items.length) % items.length, true); + }); + + const newMoreButton = moreButton.cloneNode(true); + newMoreButton.classList.add('paper-icon-button-light'); + if (isTvLayout()) newMoreButton.classList.add('show-focus'); + if (!newMoreButton.querySelector('.material-icons')) { + const iconSpan = document.createElement('span'); + iconSpan.className = 'material-icons more_vert'; + iconSpan.setAttribute('aria-hidden', 'true'); + newMoreButton.appendChild(iconSpan); + } + // Ensure legacy more buttons no longer carry icon font classes on the button itself + newMoreButton.classList.remove('material-icons', 'more_vert'); + moreButton.replaceWith(newMoreButton); + + const MORE_CONTROLS_MODAL_ID = 'kefin-section-controls-more'; + let activeMorePopover = null; + const closeMorePopover = () => { + if (window.ModalSystem?.isOpen?.(MORE_CONTROLS_MODAL_ID)) { + window.ModalSystem.close(MORE_CONTROLS_MODAL_ID); + } + if (activeMorePopover) { + activeMorePopover.remove(); + activeMorePopover = null; + } + }; + + const buildMorePopoverContent = (onActivate, { forModal = false } = {}) => { + const popover = document.createElement('div'); + popover.className = forModal + ? 'kefinTweaks-popover section-controls-more-menu' + : 'kefinTweaks-popover section-controls-more-popover'; + visibleDefinitions.forEach((def) => { + const label = typeof def.getLabel === 'function' ? def.getLabel() : def.label; + const icon = typeof def.getIcon === 'function' ? def.getIcon() : def.icon; + const item = document.createElement('div'); + item.className = 'kefinTweaks-popover-item detailsGroupItem section-controls-more-item'; + item.setAttribute('role', 'button'); + item.setAttribute('tabindex', '0'); + + const iconSpan = document.createElement('span'); + iconSpan.className = `material-icons section-controls-more-item-icon ${icon || ''}`.trim(); + iconSpan.setAttribute('aria-hidden', 'true'); + + const labelSpan = document.createElement('span'); + labelSpan.className = 'section-controls-more-item-label'; + labelSpan.textContent = label; + + item.appendChild(iconSpan); + item.appendChild(labelSpan); + + const activate = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + onActivate(); + def.onClick(ev, newMoreButton); + }; + item.addEventListener('click', activate); + item.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + activate(ev); + } + }); + popover.appendChild(item); + }); + return popover; + }; + + const positionMoreModal = (modal, anchorBtn) => { + if (!modal?.dialog || !anchorBtn) return; + modal.dialog.classList.remove('centeredDialog', 'formDialog', 'smoothScrollY', 'dialog-fixedSize'); + modal.dialog.style.position = 'fixed'; + modal.dialog.style.margin = '0'; + modal.dialog.style.maxHeight = 'none'; + modal.dialog.style.height = 'auto'; + modal.dialog.style.minHeight = 'auto'; + modal.dialog.style.minWidth = '12rem'; + modal.dialog.style.width = 'auto'; + modal.dialog.style.padding = '0.25em 0'; + if (typeof window.ModalSystem?.applyOpenAnimation === 'function') { + window.ModalSystem.applyOpenAnimation(modal.dialog); + } else { + modal.dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup'; + } + if (modal.dialogContent) { + modal.dialogContent.style.padding = '0'; + modal.dialogContent.style.overflow = 'visible'; + modal.dialogContent.style.minHeight = 'auto'; + modal.dialogContent.style.height = 'auto'; + modal.dialogContent.style.flex = '0 0 auto'; + } + if (modal.backdrop) { + modal.backdrop.style.background = 'transparent'; + } + if (modal.dialogContainer) { + modal.dialogContainer.style.pointerEvents = 'none'; + } + modal.dialog.style.pointerEvents = 'auto'; + + const btnRect = anchorBtn.getBoundingClientRect(); + const margin = 8; + const gap = 4; + requestAnimationFrame(() => { + const menuHeight = modal.dialog.offsetHeight || 0; + const menuWidth = Math.max(modal.dialog.offsetWidth || 0, 180); + let top = btnRect.bottom + gap; + if (top + menuHeight > window.innerHeight - margin) { + top = Math.max(margin, btnRect.top - menuHeight - gap); + } + let left = btnRect.right - menuWidth; + left = Math.min(Math.max(left, margin), window.innerWidth - menuWidth - margin); + modal.dialog.style.top = `${top}px`; + modal.dialog.style.left = `${left}px`; + }); + }; + + newMoreButton.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + + if (activeMorePopover || window.ModalSystem?.isOpen?.(MORE_CONTROLS_MODAL_ID)) { + closeMorePopover(); + return; + } + + if (isMobileLayout() && window.ModalSystem?.create) { + const content = buildMorePopoverContent(() => closeMorePopover(), { forModal: true }); + window.ModalSystem.create({ + id: MORE_CONTROLS_MODAL_ID, + title: '', + content, + showCloseButton: false, + closeOnBackdrop: true, + closeOnEscape: true, + fixedSize: false, + onOpen: (modal) => { + if (modal?.dialogHeader) modal.dialogHeader.style.display = 'none'; + if (modal?.dialogFooter) modal.dialogFooter.style.display = 'none'; + positionMoreModal(modal, newMoreButton); + }, + }); + return; + } + + const popover = buildMorePopoverContent(() => closeMorePopover()); + titleContainer.style.position = 'relative'; + newMoreButton.parentNode.insertBefore(popover, newMoreButton.nextSibling); + + const buttonRect = newMoreButton.getBoundingClientRect(); + const containerRect = titleContainer.getBoundingClientRect(); + popover.style.right = '40px'; + popover.style.left = 'auto'; + popover.style.top = '0px'; + popover.style.minWidth = `${Math.max(buttonRect.width, 180)}px`; + popover.style.maxWidth = `${Math.max(0, containerRect.width)}px`; + + activeMorePopover = popover; + + const closeHandler = (ev) => { + if (!popover.contains(ev.target) && !newMoreButton.contains(ev.target)) { + closeMorePopover(); + document.removeEventListener('click', closeHandler); + } + }; + setTimeout(() => { + document.addEventListener('click', closeHandler); + }, 100); + }); + + return refreshButton; + } + + function isMobileLayout() { + return document.documentElement.classList.contains('layout-mobile') || window.innerWidth < 900; + } + + function getSectionSkeletonCount(sectionConfig, cardFormat = null, itemsLayout = null) { + const raw = sectionConfig?.itemLimit || sectionConfig?.queries?.[0]?.queryOptions?.Limit || 0; + const fullLimit = raw > 0 ? raw : 16; + const layout = itemsLayout || resolveItemsLayout(sectionConfig); + if (layout === 'grid') return fullLimit; + + const format = String(cardFormat || '').toLowerCase(); + // Default/empty format resolves to portrait in createSkeletonCard + let rowCap = 6; + if (format === 'banner') { + rowCap = 3; + } else if ( + !format || + format === 'portrait' || + format === 'poster' || + format === 'series poster' || + format === 'square' || + format === 'disc' + ) { + rowCap = 8; + } + return Math.min(fullLimit, rowCap); + } + + const SKELETON_ONE_LINE_ITEM_TYPES = new Set(['Book', 'Genre', 'CollectionFolder', 'BoxSet', 'Playlist', 'Folder', 'Studio']); + + const SKELETON_CUSTOM_FOOTER_SECTION_IDS = new Set(['upcoming', 'recentlyReleased.movies', 'recentlyReleased.episodes']); + + function sectionUsesCustomFooterText(sectionConfig) { + if (!sectionConfig) return false; + if (SKELETON_CUSTOM_FOOTER_SECTION_IDS.has(sectionConfig.id)) return true; + if (Array.isArray(sectionConfig.items) && sectionConfig.items.some((item) => item?.CustomFooterText || item?.cardFooter)) { + return true; + } + return false; + } + + function inferSkeletonItemType(sectionConfig) { + if (!sectionConfig) return null; + const queries = sectionConfig.queries || []; + const activeIndex = resolveActiveQueryIndex(sectionConfig, { initialize: false }); + const query = queries[activeIndex] || queries[0]; + const path = typeof query?.path === 'string' ? query.path : ''; + if (path.includes('/Studios')) return 'Studio'; + if (path.includes('/Genres')) return 'Genre'; + + let includeTypes = query?.queryOptions?.IncludeItemTypes; + if (typeof includeTypes === 'string') { + includeTypes = includeTypes + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + } + if (!Array.isArray(includeTypes) || includeTypes.length === 0) return null; + if (includeTypes.length === 1) return includeTypes[0]; + if (includeTypes.every((t) => SKELETON_ONE_LINE_ITEM_TYPES.has(t))) { + return includeTypes[0]; + } + return null; + } + + function getSkeletonFooterLineCount(sectionConfig) { + if (sectionConfig?.hideCardFooter === true) return 0; + if (sectionUsesCustomFooterText(sectionConfig)) return 3; + const itemType = inferSkeletonItemType(sectionConfig); + if (itemType && SKELETON_ONE_LINE_ITEM_TYPES.has(itemType)) return 1; + return 2; + } + + function readScrollerPadding(scroller) { + if (!scroller) return { left: 0, right: 0 }; + const metrics = getScrollerMetrics(scroller); + if (metrics.pad) return metrics.pad; + + // Reuse first measured pad for sibling scrollers in the same sections container + const host = scroller.closest?.('.homeSectionsContainer, .sections'); + if (host && host._kefinSharedScrollerPad) { + metrics.pad = host._kefinSharedScrollerPad; + return metrics.pad; + } + + const style = window.getComputedStyle(scroller); + const pad = { + left: parseFloat(style.paddingLeft) || 0, + right: parseFloat(style.paddingRight) || 0, + }; + metrics.pad = pad; + if (host) host._kefinSharedScrollerPad = pad; + return pad; + } + + /** Frame content-box width (clientWidth minus horizontal padding) — viewport for slidee math. */ + function getScrollerViewportWidth(scroller, pad) { + const p = pad || readScrollerPadding(scroller); + return Math.max(0, (scroller?.clientWidth || 0) - (p.left || 0) - (p.right || 0)); + } + + function readCardPadding(card) { + if (!card) return { left: 0, right: 0 }; + const cached = cardPaddingByEl.get(card); + if (cached) return cached; + + const style = window.getComputedStyle(card); + const pad = { + left: parseFloat(style.paddingLeft) || 0, + right: parseFloat(style.paddingRight) || 0, + }; + cardPaddingByEl.set(card, pad); + return pad; + } + + function readSampleCardPadding(scroller) { + if (!scroller) return { left: 0, right: 0 }; + const metrics = getScrollerMetrics(scroller); + if (metrics.cardPad) return metrics.cardPad; + + const sampleCard = scroller.querySelector?.('.itemsContainer > .card:not(.skeleton-card)'); + const pad = readCardPadding(sampleCard); + metrics.cardPad = pad; + return pad; + } + + /** Inner slidee that receives translateX (Jellyfin .scrollSlider), not the outer frame. */ + function getScrollSlidee(scroller) { + if (!scroller) return null; + return scroller.querySelector('.scrollSlider') || scroller.querySelector('.itemsContainer'); + } + + function getCurrentPosition(scroller) { + if (!scroller) return 0; + const metrics = getScrollerMetrics(scroller); + if (metrics && Number.isFinite(metrics.scrollX)) return metrics.scrollX; + return 0; + } + + function setStoredScrollPosition(scroller, scrollX) { + const metrics = getScrollerMetrics(scroller); + if (metrics) metrics.scrollX = scrollX; + } + + function setMaxScrollPosition(scroller, maxScroll) { + const metrics = getScrollerMetrics(scroller); + if (metrics) metrics.maxScroll = maxScroll; + } + + /** + * Apply row scroll as translateX on the slidee (itemsContainer), matching Jellyfin. + * Clears any leftover transform on the outer scroller frame. + */ + function setSlideePosition(scroller, position, options = {}) { + if (!scroller) return 0; + const opts = options || {}; + const slidee = getScrollSlidee(scroller); + + if (opts.freshMax) { + const metrics = getScrollerMetrics(scroller); + if (metrics) { + metrics.maxScroll = null; + metrics.pad = null; + metrics.cardPad = null; + } + } + + const max = Math.max( + 0, + getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller)), + ); + const scrollPosition = Math.min(Math.max(position, 0), max); + const clampedPosition = scrollPosition > 20 ? scrollPosition : 0; + + if (scroller.style.transform) scroller.style.transform = ''; + if (scroller.style.transition) scroller.style.transition = ''; + + if (slidee) { + if (opts.animate) { + slidee.style.transition = 'transform 270ms ease-out'; + slidee.style.willChange = 'transform'; + } else { + slidee.style.transition = 'none'; + } + slidee.style.transform = `translateX(-${clampedPosition}px)`; + } + + setStoredScrollPosition(scroller, clampedPosition); + return clampedPosition; + } + + /** + * Content-space X of a card relative to the scroller frame (undoes slidee translate). + */ + function getCardContentOffsetX(scroller, card) { + if (!scroller || !card) return 0; + const current = getCurrentPosition(scroller); + const scrollerRect = scroller.getBoundingClientRect(); + const cardRect = card.getBoundingClientRect(); + return cardRect.left - scrollerRect.left + current; + } + + /** + * JF-style toCenter: put card center on frame center, clamped to [0, max]. + * Early cards stay left-aligned (desired < 0 → 0); mid/late track center. + */ + function toCenterCard(scroller, card, options = {}) { + if (!scroller || !card || !scroller.contains(card)) return; + if (card.classList.contains('skeleton-card')) return; + + const animate = options.animate !== false; + const metrics = getScrollerMetrics(scroller); + if (metrics) metrics.maxScroll = null; + + const pad = readScrollerPadding(scroller); + const cardPad = readSampleCardPadding(scroller); + const contentOffset = getCardContentOffsetX(scroller, card); + const cardWidth = card.offsetWidth || card.getBoundingClientRect().width || 0; + // Center within the frame content box (same viewport as max-scroll) + const frameInnerWidth = getScrollerViewportWidth(scroller, pad); + const cardCenterInContent = contentOffset + cardWidth / 2 - pad.left; + const desired = cardCenterInContent - frameInnerWidth / 2; + const max = getMaxPositionFromPadding(scroller, pad, cardPad); + setSlideePosition(scroller, Math.min(Math.max(desired, 0), max), { animate: animate }); + } + + function attachTvFocusScroll(scroller) { + if (!scroller || scroller.dataset.tvFocusScroll === '1') return; + scroller.dataset.tvFocusScroll = '1'; + + let rafId = 0; + let pendingCard = null; + + scroller.addEventListener( + 'focusin', + (e) => { + const card = e.target?.closest?.('.card'); + if (!card || !scroller.contains(card)) return; + if (card.classList.contains('skeleton-card')) return; + pendingCard = card; + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = 0; + const target = pendingCard; + pendingCard = null; + if (target && scroller.contains(target)) { + toCenterCard(scroller, target, { animate: true }); + } + }); + }, + true, + ); + } + + function getFullyVisibleCardCount(scroller, cards) { + const epsilon = 1; + const pad = readScrollerPadding(scroller); + const cardPad = readSampleCardPadding(scroller); + const scrollerRect = scroller.getBoundingClientRect(); + // Slidee is translated; frame is fixed — visible window is the content box + const contentWidth = getScrollerViewportWidth(scroller, pad); + const visibleLeft = scrollerRect.left + pad.left; + const visibleRight = visibleLeft + contentWidth; + let count = 0; + cards.forEach((card) => { + const cardRect = card.getBoundingClientRect(); + if (cardRect.width <= 0) return; + const cardContentLeft = cardRect.left + cardPad.left; + const cardContentRight = cardRect.right - cardPad.right; + if (cardContentLeft >= visibleLeft - epsilon && cardContentRight <= visibleRight + epsilon) { + count++; + } + }); + return count; + } + + /** + * Max translateX so slidee content flushes to the frame content-box right edge. + * Pairs slidee.scrollWidth with viewport = clientWidth - padL - padR (not clientWidth + endInset hacks). + * @param {HTMLElement} scroller + * @param {{left:number,right:number}} [pad] + * @param {{left:number,right:number}} [_cardPad] unused; kept for call-site compatibility + */ + function getMaxPositionFromPadding(scroller, pad, _cardPad) { + if (!scroller) return 0; + + const metrics = getScrollerMetrics(scroller); + if (metrics && metrics.maxScroll != null && Number.isFinite(metrics.maxScroll)) { + return metrics.maxScroll; + } + + const cards = scroller.querySelectorAll('.itemsContainer > .card:not(.skeleton-card)'); + const fullyVisible = getFullyVisibleCardCount(scroller, cards); + if (fullyVisible === cards.length) { + setMaxScrollPosition(scroller, 0); + return 0; + } + + const resolvedPad = pad || readScrollerPadding(scroller); + const slidee = getScrollSlidee(scroller); + const contentWidth = slidee ? slidee.scrollWidth : scroller.scrollWidth; + const viewport = getScrollerViewportWidth(scroller, resolvedPad); + const maxScroll = Math.max(0, contentWidth - viewport); + setMaxScrollPosition(scroller, maxScroll); + return maxScroll; + } + + function createScrollerElement() { + const scroller = document.createElement('div'); + const isMobile = isMobileLayout(); + + scroller.setAttribute('data-horizontal', 'true'); + scroller.setAttribute('data-centerfocus', 'true'); + scroller.className = `padded-top-focusscale padded-bottom-focusscale emby-scroller padded-left custom-scroller${isMobile ? " scrollX hiddenScrollX" : ""}`; + scroller.setAttribute('data-scroll-mode-x', isMobile ? 'native' : 'custom'); + scroller.style.scrollSnapType = 'none'; + scroller.style.touchAction = 'auto'; + scroller.style.overscrollBehaviorX = 'contain'; + scroller.style.overscrollBehaviorY = 'auto'; + scroller.style.webkitOverflowScrolling = 'touch'; + // Desktop custom path: clip frame + translateX slidee. Mobile scrollX: native overflow swipe. + if (!isMobile) { + scroller.style.overflow = 'hidden'; + } + setStoredScrollPosition(scroller, 0); + + return scroller; + } + + function createItemsSliderElement(isEmbyItemscontainer = true) { + const isMobile = isMobileLayout(); + const itemsContainer = document.createElement('div'); + if (isEmbyItemscontainer) { + itemsContainer.setAttribute('is', 'emby-itemscontainer'); + } + const tvClass = isTvLayout() ? ' itemsContainer-tv' : ''; + itemsContainer.className = `focuscontainer-x itemsContainer scrollSlider${!isMobile ? " animatedScrollX" : ""}${tvClass}`; + itemsContainer.style.whiteSpace = 'nowrap'; + return itemsContainer; + } + + /** + * Attach scroll buttons and desktop drag handlers to a scrollable section scroller. + * @param {HTMLElement} verticalSection + * @param {HTMLElement} scroller + * @returns {HTMLElement} scrollButtons container + */ + function attachScrollableSectionChrome(verticalSection, scroller) { + const isMobile = isMobileLayout(); + const scrollButtons = document.createElement('div'); + scrollButtons.className = 'emby-scrollbuttons padded-right'; + + const leftButton = document.createElement('button'); + leftButton.type = 'button'; + leftButton.setAttribute('data-ripple', 'false'); + leftButton.setAttribute('data-direction', 'left'); + leftButton.setAttribute('title', 'Previous'); + leftButton.className = 'emby-scrollbuttons-button paper-icon-button-light'; + leftButton.disabled = true; + const leftSpan = document.createElement('span'); + leftSpan.className = 'material-icons chevron_left'; + leftSpan.setAttribute('aria-hidden', 'true'); + leftButton.appendChild(leftSpan); + + function getCurrentPosition() { + return getScrollerPosition(scroller); + } + + // Gesture-local padding: captured once per drag/wheel burst, cleared on end/idle + let gesturePad = null; + let gestureCardPad = null; + let wheelIdleTimer = null; + + function ensureGestureInsets() { + if (!gesturePad) gesturePad = readScrollerPadding(scroller); + if (!gestureCardPad) gestureCardPad = readSampleCardPadding(scroller); + } + + function clearGestureInsets() { + gesturePad = null; + gestureCardPad = null; + } + + function getMaxPosition() { + ensureGestureInsets(); + return getMaxPositionFromPadding(scroller, gesturePad, gestureCardPad); + } + + function getMaxPositionFresh() { + const metrics = getScrollerMetrics(scroller); + if (metrics) { + metrics.maxScroll = null; + metrics.pad = null; + metrics.cardPad = null; + } + return getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller)); + } + + function setPosition(newPosition, options) { + const opts = options || {}; + const slidee = getScrollSlidee(scroller); + const clampedPosition = setSlideePosition(scroller, newPosition, { + animate: !!opts.animate, + freshMax: !!opts.freshMax, + }); + + const section = scroller.closest('.emby-scroller-container'); + if (section && typeof applyScrollButtonState === 'function') { + if (opts.animate && slidee) { + const onTransitionEnd = (e) => { + if (e && e.target !== slidee) return; + slidee.removeEventListener('transitionend', onTransitionEnd); + applyScrollButtonState(section); + }; + slidee.addEventListener('transitionend', onTransitionEnd); } else { - // Swipe left (next) - goToItem((currentIndex + 1) % items.length, true); + requestAnimationFrame(() => applyScrollButtonState(section)); } } - }, { passive: true }); - - container.appendChild(bannerContainer); - - return container; + return clampedPosition; + } + + function getScrollCards() { + const itemsContainer = scroller.querySelector('.itemsContainer'); + if (!itemsContainer) return []; + return Array.from(itemsContainer.querySelectorAll(':scope > .card:not(.skeleton-card)')); + } + + function getCardScrollLeft(card) { + // Align card content edge (border-box left + card paddingLeft) to the + // scroller's padded content gutter. Content-space (undoes slidee translate). + const pad = readScrollerPadding(scroller); + const cardPad = readSampleCardPadding(scroller); + return getCardContentOffsetX(scroller, card) - pad.left + cardPad.left; + } + + /** + * Cards fully inside the frame content-box clip. + * Frame is fixed; slidee translate moves cards — use live rects vs content-box edges. + */ + function getFullyVisibleCardIndices(cards) { + const fullyVisible = []; + const epsilon = 1; + const pad = readScrollerPadding(scroller); + const cardPad = readSampleCardPadding(scroller); + const scrollerRect = scroller.getBoundingClientRect(); + const contentWidth = getScrollerViewportWidth(scroller, pad); + const visibleLeft = scrollerRect.left + pad.left; + const visibleRight = visibleLeft + contentWidth; + cards.forEach((card, index) => { + const cardRect = card.getBoundingClientRect(); + if (cardRect.width <= 0) return; + // Use content box (inside card padding) for visibility + const cardContentLeft = cardRect.left + cardPad.left; + const cardContentRight = cardRect.right - cardPad.right; + if (cardContentLeft >= visibleLeft - epsilon && cardContentRight <= visibleRight + epsilon) { + fullyVisible.push(index); + } + }); + return fullyVisible; + } + + function snapScrollToCardIndex(cards, targetIndex) { + if (!cards.length) return; + const clampedIndex = Math.max(0, Math.min(targetIndex, cards.length - 1)); + // Last card: flush to frame content-box end (getMaxPositionFresh) + if (clampedIndex === cards.length - 1) { + setPosition(getMaxPositionFresh(), { animate: true, freshMax: true }); + return; + } + const targetLeft = getCardScrollLeft(cards[clampedIndex]); + setPosition(targetLeft, { animate: true, freshMax: true }); + } + + leftButton.addEventListener('click', () => { + const cards = getScrollCards(); + if (!cards.length) return; + const fullyVisible = getFullyVisibleCardIndices(cards); + const firstVisible = fullyVisible.length ? fullyVisible[0] : 0; + const step = Math.max(1, fullyVisible.length); + snapScrollToCardIndex(cards, firstVisible - step); + }); + + scrollButtons.appendChild(leftButton); + + const rightButton = document.createElement('button'); + rightButton.type = 'button'; + rightButton.setAttribute('data-ripple', 'false'); + rightButton.setAttribute('data-direction', 'right'); + rightButton.setAttribute('title', 'Next'); + rightButton.className = 'emby-scrollbuttons-button paper-icon-button-light'; + const rightSpan = document.createElement('span'); + rightSpan.className = 'material-icons chevron_right'; + rightSpan.setAttribute('aria-hidden', 'true'); + rightButton.appendChild(rightSpan); + + rightButton.addEventListener('click', () => { + const cards = getScrollCards(); + if (!cards.length) return; + const fullyVisible = getFullyVisibleCardIndices(cards); + const lastVisible = fullyVisible.length ? fullyVisible[fullyVisible.length - 1] : -1; + const nextIndex = lastVisible + 1; + const lastWillBeVisible = nextIndex + fullyVisible.length >= cards.length; + if (nextIndex >= cards.length || lastWillBeVisible) { + setPosition(getMaxPositionFresh(), { animate: true, freshMax: true }); + return; + } + snapScrollToCardIndex(cards, nextIndex); + }); + + scrollButtons.appendChild(rightButton); + + let dragState = { + isDragging: false, + startX: 0, + startY: 0, + startPosition: 0, + currentPosition: 0, + maxPosition: 0, + hasMoved: false, + lastX: 0, + lastTime: 0, + velocity: 0, + momentumId: null, + }; + + if (!isMobile) { + const DRAG_THRESHOLD_PX = 8; + + function onPointerDown(e) { + if (e.type === 'mousedown' && e.button !== 0) { + return; + } + + const point = e.touches ? e.touches[0] : e; + + if (dragState.momentumId !== null) { + cancelAnimationFrame(dragState.momentumId); + dragState.momentumId = null; + } + + dragState.startX = point.clientX; + dragState.startY = point.clientY; + dragState.isDragging = false; + + function checkThreshold(moveEvent) { + const p = moveEvent.touches ? moveEvent.touches[0] : moveEvent; + const dx = p.clientX - dragState.startX; + const dy = p.clientY - dragState.startY; + if (Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX) return; + + ensureGestureInsets(); + dragState.isDragging = true; + dragState.startPosition = getCurrentPosition(); + dragState.currentPosition = dragState.startPosition; + dragState.maxPosition = getMaxPosition(); + dragState.hasMoved = false; + dragState.lastX = p.clientX; + dragState.lastTime = Date.now(); + dragState.velocity = 0; + + cleanupPending(); + if (e.touches) { + document.addEventListener('touchmove', onDragMove, { passive: false }); + document.addEventListener('touchend', onDragEnd, { passive: true }); + document.addEventListener('touchcancel', onDragEnd, { passive: true }); + } else { + document.addEventListener('mousemove', onDragMove); + document.addEventListener('mouseup', onDragEnd); + } + if (!moveEvent.touches) moveEvent.preventDefault(); + } + + function cleanupPending() { + if (e.touches) { + document.removeEventListener('touchmove', checkThreshold); + document.removeEventListener('touchend', onPointerUp); + } else { + document.removeEventListener('mousemove', checkThreshold); + document.removeEventListener('mouseup', onPointerUp); + } + } + + function onPointerUp() { + if (!dragState.isDragging) cleanupPending(); + } + + if (e.touches) { + document.addEventListener('touchmove', checkThreshold, { passive: false }); + document.addEventListener('touchend', onPointerUp, { passive: true }); + } else { + document.addEventListener('mousemove', checkThreshold); + document.addEventListener('mouseup', onPointerUp); + e.preventDefault(); + } + } + + function onDragMove(e) { + if (!dragState.isDragging) return; + + const point = e.touches ? e.touches[0] : e; + const deltaX = dragState.startX - point.clientX; + const deltaY = dragState.startY - point.clientY; + + if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY) * 1.5) { + dragState.hasMoved = true; + + const rawNewPosition = dragState.startPosition + deltaX; + const clampedPosition = Math.min(Math.max(rawNewPosition, 0), Math.max(dragState.maxPosition, 0)); + dragState.currentPosition = clampedPosition; + + setPosition(clampedPosition, { animate: false }); + + const currentTime = Date.now(); + const timeDelta = currentTime - dragState.lastTime; + if (timeDelta > 0) { + const positionDelta = point.clientX - dragState.lastX; + dragState.velocity = positionDelta / timeDelta; + } + dragState.lastX = point.clientX; + dragState.lastTime = currentTime; + + if (e.touches) { + e.preventDefault(); + } + } + } + + function onDragEnd(e) { + if (!dragState.isDragging) return; + + if (e.type === 'touchend' || e.type === 'touchcancel') { + document.removeEventListener('touchmove', onDragMove); + document.removeEventListener('touchend', onDragEnd); + document.removeEventListener('touchcancel', onDragEnd); + } else { + document.removeEventListener('mousemove', onDragMove); + document.removeEventListener('mouseup', onDragEnd); + } + + if (dragState.hasMoved) { + scroller.style.pointerEvents = 'none'; + requestAnimationFrame(() => { + scroller.style.pointerEvents = ''; + }); + } + + dragState.isDragging = false; + + const friction = 0.965; + const velocityThreshold = 0.01; + const velocityBoost = 2.0; + + dragState.velocity *= velocityBoost; + + if (Math.abs(dragState.velocity) > velocityThreshold) { + let currentPosition = dragState.currentPosition; + const maxPosition = dragState.maxPosition; + let lastTimestamp = 0; + + const step = (timestamp) => { + const frameTime = lastTimestamp ? timestamp - lastTimestamp : 16; + lastTimestamp = timestamp; + + if (Math.abs(dragState.velocity) > velocityThreshold) { + currentPosition -= dragState.velocity * frameTime; + currentPosition = Math.min(Math.max(currentPosition, 0), Math.max(maxPosition, 0)); + setPosition(currentPosition, { animate: false }); + + if (currentPosition === 0 || currentPosition === maxPosition) { + dragState.velocity = 0; + dragState.momentumId = null; + clearGestureInsets(); + return; + } + + dragState.velocity *= friction; + dragState.momentumId = requestAnimationFrame(step); + } else { + dragState.velocity = 0; + dragState.momentumId = null; + clearGestureInsets(); + } + }; + + dragState.momentumId = requestAnimationFrame(step); + } else { + dragState.velocity = 0; + dragState.momentumId = null; + clearGestureInsets(); + } + } + + scroller.addEventListener('mousedown', onPointerDown); + scroller.addEventListener('touchstart', onPointerDown, { passive: true }); + + // Trackpad / mouse wheel → custom transform scroll (native overflow scrollers handle this themselves). + // Keep passive:false so we can preventDefault on horizontal gestures, but coalesce apply work + // into one rAF so the event callback stays cheap under high-frequency trackpad input. + const WHEEL_DELTA_MIN = 1; + const WHEEL_IDLE_MS = 150; + let pendingWheelDx = 0; + let wheelRafId = null; + const flushPendingWheel = () => { + wheelRafId = null; + const dx = pendingWheelDx; + pendingWheelDx = 0; + if (!dx) return; + ensureGestureInsets(); + setPosition(getCurrentPosition() + dx, { animate: false }); + }; + scroller.addEventListener( + 'wheel', + (e) => { + if (scroller.classList.contains('scrollX')) return; + + let dx = e.deltaX; + let dy = e.deltaY; + // Line mode: normalize without layout. Page mode: defer clientWidth until apply frame. + if (e.deltaMode === 1) { + dx *= 16; + dy *= 16; + } + + const shiftHorizontal = e.shiftKey && Math.abs(dy) >= WHEEL_DELTA_MIN && Math.abs(dx) < WHEEL_DELTA_MIN; + if (shiftHorizontal) { + dx = dy; + dy = 0; + } + + if (Math.abs(dx) < WHEEL_DELTA_MIN) return; + if (Math.abs(dx) < Math.abs(dy)) return; + + e.preventDefault(); + + if (e.deltaMode === 2) { + dx *= scroller.clientWidth || 1; + } + + pendingWheelDx += dx; + if (wheelIdleTimer) clearTimeout(wheelIdleTimer); + wheelIdleTimer = setTimeout(() => { + wheelIdleTimer = null; + clearGestureInsets(); + }, WHEEL_IDLE_MS); + + if (wheelRafId == null) { + wheelRafId = requestAnimationFrame(flushPendingWheel); + } + }, + { passive: false }, + ); + } + + return scrollButtons; } /** @@ -1452,22 +8498,47 @@ * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square' * @returns {HTMLElement} - The constructed scrollable container */ - function createScrollableContainer(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) { - // Create the main vertical section container - const verticalSection = document.createElement('div'); - verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container'; + function getFacetRowType(items) { + if (!Array.isArray(items) || items.length === 0) return null; + let genreCount = 0; + let studioCount = 0; + items.forEach((item) => { + if (item?.Type === 'Genre') genreCount++; + else if (item?.Type === 'Studio') studioCount++; + }); + const threshold = Math.ceil(items.length / 2); + if (genreCount >= threshold) return 'Genre'; + if (studioCount >= threshold) return 'Studio'; + return null; + } - // Create section title - const sectionTitleContainer = document.createElement('div'); - sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left'; - - if (viewMoreUrl) { - // Create clickable title with chevron icon + function appendSectionTitleContent(sectionTitleContainer, { title, caption, captionUrl, viewMoreUrl, discoveryPending }) { + const captionText = caption && String(caption).trim(); + const hasCaption = !!captionText; + const useSkeletonTitle = discoveryPending === true; + let parent = sectionTitleContainer; + + if (hasCaption) { + const wrapper = document.createElement('div'); + wrapper.className = 'sectionTitle-wrapper'; + sectionTitleContainer.appendChild(wrapper); + parent = wrapper; + } + + if (useSkeletonTitle) { + const titleText = document.createElement('h2'); + titleText.className = 'sectionTitle sectionTitle-cards'; + const skeletonTitle = document.createElement('span'); + skeletonTitle.className = 'skeleton-text-line skeleton-section-title'; + skeletonTitle.setAttribute('aria-hidden', 'true'); + titleText.appendChild(skeletonTitle); + parent.appendChild(titleText); + } else if (viewMoreUrl) { const titleLink = document.createElement('a'); - titleLink.className = 'sectionTitle-link sectionTitleTextButton'; + titleLink.className = 'sectionTitle-link button-flat button-flat-mini sectionTitleTextButton emby-button'; + if (isTvLayout()) titleLink.classList.add('show-focus'); titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;'; - - // Handle both URL and function + if (typeof viewMoreUrl === 'function') { titleLink.addEventListener('click', (e) => { e.preventDefault(); @@ -1476,106 +8547,2344 @@ } else { titleLink.href = viewMoreUrl; } - + const titleText = document.createElement('h2'); titleText.className = 'sectionTitle sectionTitle-cards'; titleText.textContent = title; - + const chevronIcon = document.createElement('span'); chevronIcon.className = 'material-icons chevron_right'; chevronIcon.setAttribute('aria-hidden', 'true'); - + titleLink.appendChild(titleText); titleLink.appendChild(chevronIcon); - sectionTitleContainer.appendChild(titleLink); + parent.appendChild(titleLink); } else { - // Regular non-clickable title const titleText = document.createElement('h2'); titleText.className = 'sectionTitle sectionTitle-cards'; titleText.textContent = title; - sectionTitleContainer.appendChild(titleText); + parent.appendChild(titleText); } - // Create "Show All" button (will be added after items are created) - const showAllButton = document.createElement('button'); - showAllButton.type = 'button'; - showAllButton.className = 'show-all-button'; - showAllButton.style.cssText = 'margin-left: 10px; font-size: 12px; padding: 4px 8px; min-width: auto; background: transparent; border: 1px solid rgba(255, 255, 255, 0.3) !important; border-radius: 4px; cursor: pointer; color: var(--main-text, #fff) !important; margin-bottom: .35em; align-self: center;'; - showAllButton.textContent = 'Expand'; - showAllButton.title = 'Show all items'; + if (hasCaption) { + let captionEl; + if (useSkeletonTitle) { + captionEl = document.createElement('span'); + captionEl.className = 'sectionTitle sectionCaption'; + const skeletonCaption = document.createElement('span'); + skeletonCaption.className = 'skeleton-text-line skeleton-section-caption'; + skeletonCaption.setAttribute('aria-hidden', 'true'); + captionEl.appendChild(skeletonCaption); + } else if (captionUrl) { + captionEl = document.createElement('a'); + captionEl.className = 'sectionTitle sectionCaption button-flat button-flat-mini sectionTitleTextButton emby-button'; + if (isTvLayout()) captionEl.classList.add('show-focus'); + if (typeof captionUrl === 'function') { + captionEl.addEventListener('click', (e) => { + e.preventDefault(); + captionUrl(); + }); + } else { + captionEl.href = captionUrl; + } + captionEl.textContent = captionText; + } else { + captionEl = document.createElement('span'); + captionEl.className = 'sectionTitle sectionCaption'; + captionEl.textContent = captionText; + } + parent.appendChild(captionEl); + } + } + + function createScrollableContainer( + items, + title, + viewMoreUrl = null, + overflowCard = false, + cardFormat = null, + sectionConfig = null, + registerScrollButtons = true, + ) { + const normalizedFormat = (cardFormat || '').toLowerCase(); + + // Create the main vertical section container + const verticalSection = document.createElement('div'); + verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container'; + const facetRowType = getFacetRowType(items); + if (facetRowType) { + verticalSection.style.setProperty('--row-hue', `${Math.floor(Math.random() * 360)}deg`); + verticalSection.dataset.facetRowType = facetRowType; + } + + // Persist the card format if provided (ensures consistency for random/updates) + if (cardFormat) { + verticalSection.setAttribute('data-card-format', cardFormat); + } + + // Create section title + const sectionTitleContainer = document.createElement('div'); + sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left'; + + appendSectionTitleContent(sectionTitleContainer, { + title, + caption: getSectionCaption(sectionConfig), + captionUrl: getSectionCaptionUrl(sectionConfig), + viewMoreUrl, + }); + + ensureSectionControlsMount(sectionTitleContainer); // Create scroller container - const scroller = document.createElement('div'); - scroller.setAttribute('is', 'emby-scroller'); - scroller.setAttribute('data-horizontal', 'true'); - scroller.setAttribute('data-centerfocus', 'card'); - scroller.className = 'padded-top-focusscale padded-bottom-focusscale emby-scroller custom-scroller'; - scroller.setAttribute('data-scroll-mode-x', 'custom'); - // Enable smooth native horizontal touch scrolling (no snapping, no buttons) - scroller.style.scrollSnapType = 'none'; - // Allow both axes so vertical page scroll isn't blocked when gesture starts over the scroller - scroller.style.touchAction = 'auto'; - // Keep horizontal scroll self-contained but allow vertical to bubble to page - scroller.style.overscrollBehaviorX = 'contain'; - scroller.style.overscrollBehaviorY = 'auto'; - // iOS inertia scrolling - scroller.style.webkitOverflowScrolling = 'touch'; + const scroller = createScrollerElement(); + const itemsContainer = createItemsSliderElement(); - // Create items container - const itemsContainer = document.createElement('div'); - itemsContainer.setAttribute('is', 'emby-itemscontainer'); - itemsContainer.className = 'focuscontainer-x itemsContainer scrollSlider animatedScrollX'; - itemsContainer.style.whiteSpace = 'nowrap'; + // Add items to container (TV → button cards via createCardElement) + const useParentCard = !!sectionConfig?.useParentCard; + const borderStyle = sectionConfig?.borderStyle || null; - // Add items to container - items.forEach((item, index) => { - const card = createJellyfinCardElement(item, overflowCard, cardFormat); + items.forEach((item, index) => { + let imageParams = null; + if (sectionConfig?.sectionCssClass && sectionConfig?.sectionCssClass.includes('rows-2') && index === 0) { + imageParams = getDoubledAspectRatioImageParams(cardFormat); + } + + const card = createCardElement( + item, + overflowCard, + cardFormat, + item.cardFooter, + useParentCard, + borderStyle, + imageParams, + ); card.setAttribute('data-index', index); itemsContainer.appendChild(card); }); scroller.appendChild(itemsContainer); - // Add "Show All" button to title if there are more than 20 items - if (items.length > 20) { - sectionTitleContainer.appendChild(showAllButton); + let scrollButtons = null; + if (isTvLayout()) { + attachTvFocusScroll(scroller); + } else { + scrollButtons = attachScrollableSectionChrome(verticalSection, scroller); } - // Toggle between scroll and grid view - let isShowingAll = false; - const originalItemsContainerStyle = itemsContainer.style.cssText; - - showAllButton.addEventListener('click', () => { - // Find the scroll buttons for this container - const scrollerContainer = showAllButton.closest('.emby-scroller-container'); - const scrollButtons = scrollerContainer ? scrollerContainer.querySelector('.emby-scrollbuttons') : null; - - if (isShowingAll) { - // Switch back to scroll view - itemsContainer.style.cssText = originalItemsContainerStyle; - if (scrollButtons) scrollButtons.style.display = ''; - showAllButton.textContent = 'Expand'; - showAllButton.title = 'Show all items in a grid layout'; - isShowingAll = false; - } else { - // Switch to grid view - itemsContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 12px; white-space: normal; transform: none !important; transition: none !important;'; - if (scrollButtons) scrollButtons.style.display = 'none'; - showAllButton.textContent = 'Collapse'; - showAllButton.title = 'Show items in scrollable layout'; - isShowingAll = true; - } - }); - // Assemble the section verticalSection.appendChild(sectionTitleContainer); + if (scrollButtons) { + verticalSection.appendChild(scrollButtons); + } verticalSection.appendChild(scroller); + if (registerScrollButtons && scrollButtons) { + registerScrollSectionScrollButtons(verticalSection); + } + return verticalSection; + } + + function createSectionScrollableContainer( + items, + title, + viewMoreUrl = null, + overflowCard = false, + cardFormat = null, + sectionConfig = null, + ) { + return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat, sectionConfig); + } + + /** + * Creates a skeleton card element for loading states + * @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', 'square', 'logo', 'clear art', 'disc', 'banner', etc. + * @param {boolean} overflowCard - Use overflow card classes + * @param {Object|null} sectionConfig - Section config used to infer footer line count + * @returns {HTMLElement} - Skeleton card element + */ + function createSkeletonCard(cardFormat = null, overflowCard = false, sectionConfig = null) { + const card = document.createElement('div'); + + // Determine card classes based on format (must match createJellyfinCardElement) + let cardClass, padderClass; + cardFormat = cardFormat?.toLowerCase() || 'portrait'; + + if ( + cardFormat === 'backdrop' || + cardFormat === 'thumb' || + cardFormat === 'series thumb' || + cardFormat === 'logo' || + cardFormat === 'clear art' + ) { + cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard'; + padderClass = 'cardPadder-backdrop'; + } else if (cardFormat === 'square' || cardFormat === 'disc') { + cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard'; + padderClass = 'cardPadder-square'; + } else if (cardFormat === 'banner') { + cardClass = overflowCard ? 'overflowBannerCard' : 'bannerCard'; + padderClass = 'cardPadder-banner'; + } else { + // portrait / poster / series poster (default) + cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard'; + padderClass = 'cardPadder-portrait'; + } + + card.className = `card ${cardClass} skeleton-card`; + card.setAttribute('data-skeleton', 'true'); + + const cardBox = document.createElement('div'); + cardBox.className = `cardBox cardBox-bottompadded`; + + const cardScalable = document.createElement('div'); + cardScalable.className = 'cardScalable'; + + const cardPadder = document.createElement('div'); + cardPadder.className = `cardPadder ${padderClass} skeleton-padder`; + cardPadder.style.cssText = 'background: rgba(255,255,255,0.12);'; + + cardScalable.appendChild(cardPadder); + cardBox.appendChild(cardScalable); + + const footerLineCount = getSkeletonFooterLineCount(sectionConfig); + if (footerLineCount > 0) { + const cardTextStack = document.createElement('div'); + cardTextStack.className = 'cardTextStack'; + + const lineWidths = ['70%', '55%', '40%']; + for (let lineIndex = 0; lineIndex < footerLineCount; lineIndex++) { + const isFirst = lineIndex === 0; + const cardText = document.createElement('div'); + cardText.className = isFirst ? 'cardText cardTextCentered cardText-first' : 'cardText cardTextCentered cardText-secondary'; + + const bdi = document.createElement('bdi'); + const skeletonLine = document.createElement('span'); + skeletonLine.className = 'skeleton-text-line'; + skeletonLine.style.width = lineWidths[lineIndex] || '40%'; + bdi.appendChild(skeletonLine); + cardText.appendChild(bdi); + cardTextStack.appendChild(cardText); + } + + cardBox.appendChild(cardTextStack); + } + + card.appendChild(cardBox); + + return card; + } + + /** + * Creates a skeleton spotlight section for progressive enhancement. + * Layout/size are derived from options so the skeleton matches the final spotlight (Border/Borderless, Normal/Large/Full). + * @param {string} title - Title for the spotlight section + * @param {Object} options - Options for the spotlight carousel (viewMoreUrl, spotlightLayout, spotlightSize, fullScreen) + * @returns {HTMLElement} - Skeleton spotlight container + */ + function createSkeletonSpotlightSection(title, options = {}) { + const { viewMoreUrl = null, discoveryPending = false, caption = null } = options; + const { layout, size } = resolveSpotlightPresentation(options); + const captionText = caption && String(caption).trim(); + const useSkeletonTitle = discoveryPending === true; + + // Create main container (same structure as real spotlight) + const container = document.createElement('div'); + container.className = 'spotlight-section padded-left'; + container.dataset.layout = layout; + container.dataset.size = size; + + // Create banner container + const bannerContainer = document.createElement('div'); + bannerContainer.className = 'spotlight-banner-container'; + + // Header wraps title (+ nav when present) to match live spotlight DOM. + // Title container hosts section name + refresh/configure controls. + // Always create it so controls can attach even when the section has no name. + { + const headerContainer = document.createElement('div'); + headerContainer.className = 'spotlight-header-container'; + + const sectionTitleContainer = document.createElement('div'); + sectionTitleContainer.className = 'spotlight-section-title-container'; + if (useSkeletonTitle || title) { + let sectionTitleEl; + if (useSkeletonTitle) { + sectionTitleEl = document.createElement('div'); + sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground'; + const skeletonTitle = document.createElement('span'); + skeletonTitle.className = 'skeleton-text-line skeleton-section-title'; + skeletonTitle.setAttribute('aria-hidden', 'true'); + sectionTitleEl.appendChild(skeletonTitle); + } else if (viewMoreUrl) { + const titleLink = document.createElement('a'); + titleLink.className = 'emby-tab-button emby-tab-button-active emby-button-foreground'; + titleLink.textContent = title; + titleLink.title = 'See All'; + titleLink.style.textDecoration = 'none'; + if (typeof viewMoreUrl === 'function') { + titleLink.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + viewMoreUrl(); + }); + } else { + titleLink.href = viewMoreUrl; + titleLink.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } + sectionTitleEl = titleLink; + } else { + sectionTitleEl = document.createElement('div'); + sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground'; + sectionTitleEl.textContent = title; + } + const sectionTitleWrapper = document.createElement('div'); + sectionTitleWrapper.className = `spotlight-section-title ${!useSkeletonTitle && viewMoreUrl ? "" : "spotlight-title-link "}headerTabs sectionTabs`; + sectionTitleWrapper.appendChild(sectionTitleEl); + sectionTitleContainer.appendChild(sectionTitleWrapper); + + if (useSkeletonTitle && captionText) { + const captionEl = document.createElement('span'); + captionEl.className = 'sectionTitle sectionCaption'; + const skeletonCaption = document.createElement('span'); + skeletonCaption.className = 'skeleton-text-line skeleton-section-caption'; + skeletonCaption.setAttribute('aria-hidden', 'true'); + captionEl.appendChild(skeletonCaption); + sectionTitleContainer.appendChild(captionEl); + } + } + headerContainer.appendChild(sectionTitleContainer); + bannerContainer.appendChild(headerContainer); + } + + // Create skeleton item (single full-container placeholder) + const itemsContainer = document.createElement('div'); + itemsContainer.className = 'spotlight-items-container'; + + const skeletonItem = document.createElement('div'); + skeletonItem.className = 'spotlight-item skeleton-spotlight-item'; + skeletonItem.setAttribute('data-index', '0'); + skeletonItem.setAttribute('data-active', 'true'); + skeletonItem.style.cssText = 'background: rgba(255,255,255,0.12);'; + + itemsContainer.appendChild(skeletonItem); + bannerContainer.appendChild(itemsContainer); + ensureSkeletonShimmerSibling(itemsContainer); + container.appendChild(bannerContainer); + + container.classList.add('skeleton-section'); + observeSkeletonShimmer(container); + + return container; + } + + /** + * Creates a scrollable container with skeleton cards for progressive enhancement + * @param {string} title - Title for the scrollable container + * @param {string|Function} viewMoreUrl - Optional URL to make title clickable + * @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', or 'square' + * @param {boolean} overflowCard - Use overflow card classes + * @returns {HTMLElement} - Container with skeleton cards + */ + function createProgressivelyEnhancedScrollableContainer( + title, + viewMoreUrl = null, + cardFormat = null, + overflowCard = false, + sectionConfig = null, + ) { + const tvLayout = isTvLayout(); + + // Create the main vertical section container (same structure as createScrollableContainer) + const verticalSection = document.createElement('div'); + verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container'; + + // Persist the card format if provided (ensures consistency for random/updates) + if (cardFormat) { + verticalSection.setAttribute('data-card-format', cardFormat); + } + + // If the query is using the /Genres or /Studios paths, set the row hue to a random color + if ( + sectionConfig?.queries?.some( + (query) => typeof query.path === 'string' && (query.path.includes('/Genres') || query.path.includes('/Studios')), + ) + ) { + verticalSection.style.setProperty('--row-hue', `${Math.floor(Math.random() * 360)}deg`); + } + + // Create section title + const sectionTitleContainer = document.createElement('div'); + sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left'; + + const discoveryPending = sectionConfig?.discoveryPending === true; + appendSectionTitleContent(sectionTitleContainer, { + title, + caption: getSectionCaption(sectionConfig), + captionUrl: getSectionCaptionUrl(sectionConfig), + viewMoreUrl: discoveryPending ? null : viewMoreUrl, + discoveryPending, + }); + + ensureSectionControlsMount(sectionTitleContainer); + + const scroller = createScrollerElement(); + const itemsContainer = createItemsSliderElement(true); + + const itemsLayout = resolveItemsLayout(sectionConfig); + const skeletonCount = getSectionSkeletonCount(sectionConfig, cardFormat, itemsLayout); + for (let i = 0; i < skeletonCount; i++) { + const skeletonCard = createSkeletonCard(cardFormat, overflowCard, sectionConfig); + skeletonCard.setAttribute('data-index', i); + itemsContainer.appendChild(skeletonCard); + } + + scroller.appendChild(itemsContainer); + ensureSkeletonShimmerSibling(itemsContainer); + + verticalSection.appendChild(sectionTitleContainer); + if (tvLayout) { + attachTvFocusScroll(scroller); + } else { + const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller); + if (scrollButtons) { + verticalSection.appendChild(scrollButtons); + } + } + verticalSection.appendChild(scroller); + + verticalSection.classList.add('skeleton-section'); + observeSkeletonShimmer(verticalSection); + return verticalSection; } + /** + * Measures the width of an element + * @param {HTMLElement} el - Element to measure + * @returns {number} - Width of the element + */ + function measure(el) { + if (!el) { + return 0; + } + + let measureRoot = document.querySelector('#measure-root'); + if (!measureRoot) { + measureRoot = document.createElement('div'); + measureRoot.id = 'measure-root'; + measureRoot.style.cssText = + 'position: absolute; visibility: hidden; contain: layout style paint; white-space: nowrap; pointer-events: none;'; + document.body.appendChild(measureRoot); + } + + const clonedEl = el.cloneNode(true); + measureRoot.appendChild(clonedEl); + const width = clonedEl.getBoundingClientRect().width; + measureRoot.removeChild(clonedEl); + return width; + } + + /** + * Checks the state of a promise + * @param {Promise} p - Promise to check + * @returns {string} - State of the promise: 'pending', 'fulfilled', or 'rejected' + */ + function promiseState(p) { + const t = {}; + return Promise.race([p, t]).then( + (v) => (v === t ? 'pending' : 'fulfilled'), + () => 'rejected', + ); + } + + // Section-level skeleton shimmer (keyframes live in cardBuilder.css) + let skeletonShimmerObserver = null; + /** @type {WeakMap} */ + const skeletonShimmerRafBySection = new WeakMap(); + + function prefersReducedSkeletonMotion() { + return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); + } + + function ensureSkeletonShimmerObserver() { + if (skeletonShimmerObserver || typeof IntersectionObserver === 'undefined') return skeletonShimmerObserver; + skeletonShimmerObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const el = entry.target; + if (!el?.classList?.contains('skeleton-section')) { + unobserveSkeletonShimmer(el); + return; + } + const pendingRaf = skeletonShimmerRafBySection.get(el); + if (pendingRaf) { + cancelAnimationFrame(pendingRaf); + skeletonShimmerRafBySection.delete(el); + } + if (entry.isIntersecting && !prefersReducedSkeletonMotion()) { + const raf = requestAnimationFrame(() => { + skeletonShimmerRafBySection.delete(el); + if (el.isConnected && el.classList.contains('skeleton-section')) { + el.classList.add('skeleton-shimmer-active'); + } + }); + skeletonShimmerRafBySection.set(el, raf); + } else { + el.classList.remove('skeleton-shimmer-active'); + } + }); + }, + { root: null, rootMargin: '100px 0px', threshold: 0.01 }, + ); + return skeletonShimmerObserver; + } + + /** + * Insert a .skeleton-shimmer sibling after the items host (inside scroller / banner). + * @param {HTMLElement|null|undefined} itemsHost .itemsContainer or .spotlight-items-container + * @returns {HTMLElement|null} + */ + function ensureSkeletonShimmerSibling(itemsHost) { + if (!itemsHost?.parentElement) return null; + const parent = itemsHost.parentElement; + let shimmer = null; + if (itemsHost.nextElementSibling?.classList?.contains('skeleton-shimmer')) { + shimmer = itemsHost.nextElementSibling; + } else { + shimmer = parent.querySelector(':scope > .skeleton-shimmer'); + } + if (!shimmer) { + shimmer = document.createElement('div'); + shimmer.className = 'skeleton-shimmer'; + shimmer.setAttribute('aria-hidden', 'true'); + itemsHost.insertAdjacentElement('afterend', shimmer); + } else if (shimmer.previousElementSibling !== itemsHost) { + itemsHost.insertAdjacentElement('afterend', shimmer); + } + return shimmer; + } + + function removeSkeletonShimmerOverlays(root) { + if (!root?.querySelectorAll) return; + root.querySelectorAll('.skeleton-shimmer').forEach((el) => el.remove()); + } + + function observeSkeletonShimmer(sectionElement) { + if (!sectionElement?.classList) return; + sectionElement.classList.add('skeleton-section'); + sectionElement.classList.remove('skeleton-shimmer-active'); + const itemsHost = + sectionElement.querySelector('.spotlight-items-container') || + sectionElement.querySelector('.itemsContainer'); + ensureSkeletonShimmerSibling(itemsHost); + const observer = ensureSkeletonShimmerObserver(); + if (observer) observer.observe(sectionElement); + } + + function unobserveSkeletonShimmer(sectionElement) { + if (!sectionElement) return; + const pendingRaf = skeletonShimmerRafBySection.get(sectionElement); + if (pendingRaf) { + cancelAnimationFrame(pendingRaf); + skeletonShimmerRafBySection.delete(sectionElement); + } + sectionElement.classList?.remove('skeleton-shimmer-active', 'skeleton-section'); + removeSkeletonShimmerOverlays(sectionElement); + if (skeletonShimmerObserver) { + try { + skeletonShimmerObserver.unobserve(sectionElement); + } catch (_) { + /* ignore */ + } + } + } + + function stripCopiedSkeletonSectionClasses(el) { + el?.classList?.remove('skeleton-section', 'skeleton-shimmer-active'); + removeSkeletonShimmerOverlays(el); + } + + // Add skeleton loading animation CSS if not already present + if (!document.getElementById('skeleton-animation-style')) { + const style = document.createElement('style'); + style.id = 'skeleton-animation-style'; + style.textContent = ` + @keyframes skeleton-shimmer-sweep { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } + } + `; + document.head.appendChild(style); + } + + // Smart Lazy Image Loading with Global Observers + let lazyImageObserver = null; + /** @type {WeakMap} */ + const lazyObserversByScroller = new WeakMap(); + // Run updateScrollButtonStateForSection once when a section becomes visible (so --max-scroll is computed after layout) + let scrollSectionVisibilityObserver = null; + + function getLazyScrollerRoot(el) { + if (!el || typeof el.closest !== 'function') return null; + return el.closest('.emby-scroller, .custom-scroller, [data-horizontal="true"]'); + } + + function clearLazyImagePaint(cardImageContainer) { + if (!cardImageContainer) return; + cardImageContainer.style.backgroundImage = ''; + cardImageContainer.style.webkitMaskImage = ''; + cardImageContainer.style.maskImage = ''; + cardImageContainer.style.webkitMaskRepeat = ''; + cardImageContainer.style.maskRepeat = ''; + cardImageContainer.style.webkitMaskPosition = ''; + cardImageContainer.style.maskPosition = ''; + cardImageContainer.style.webkitMaskSize = ''; + cardImageContainer.style.maskSize = ''; + cardImageContainer.classList.remove('lazy-masked-svg'); + } + + function ensureLazyUnloadPlaceholder(cardImageContainer) { + const canvas = cardImageContainer.previousElementSibling; + const hasBlurhashCanvas = canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas'); + const blurhash = cardImageContainer.getAttribute('data-blurhash'); + + if (state.useBlurhash && hasBlurhashCanvas && blurhash) { + // Unload restore: always prefer blurhash when available (ignore reduced-motion / saveData) + if (!canvas.hasAttribute('data-blurhash-pending') && canvas.classList.contains('lazy-hidden')) { + // Re-show existing painted blurhash, or re-queue decode + const hasPixels = canvas.hasAttribute('data-blurhash-painted'); + if (!hasPixels) { + canvas.removeAttribute('data-blurhash-painted'); + canvas.setAttribute('data-blurhash-pending', blurhash); + observeBlurhashCanvas(canvas); + } + } + canvas.classList.remove('lazy-hidden'); + cardImageContainer.classList.add('lazy-hidden'); + return; + } + + if (hasBlurhashCanvas) { + canvas.classList.add('lazy-hidden'); + } + cardImageContainer.classList.remove('lazy-hidden'); + let icon = cardImageContainer.querySelector('.cardImageIcon'); + if (!icon) { + icon = document.createElement('span'); + icon.className = 'cardImageIcon material-icons'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = 'movie'; + cardImageContainer.appendChild(icon); + } + if (!/\bdefaultCardBackground\d\b/.test(cardImageContainer.className)) { + cardImageContainer.classList.add('defaultCardBackground1'); + } + } + + function applyLazyImageToContainer(cardImageContainer, imageUrl, isSvg) { + if (!cardImageContainer?.isConnected) return; + if (cardImageContainer.getAttribute('data-src') !== imageUrl) return; + // Cancelled / unloaded before apply drained + if (!cardImageContainer.hasAttribute('data-loading')) return; + // Style writes first, then one class/attr batch so paint absorbs a single invalidation. + if (isSvg) { + const maskUrl = `url("${imageUrl}")`; + cardImageContainer.style.webkitMaskImage = maskUrl; + cardImageContainer.style.maskImage = maskUrl; + cardImageContainer.style.webkitMaskRepeat = 'no-repeat'; + cardImageContainer.style.maskRepeat = 'no-repeat'; + cardImageContainer.style.webkitMaskPosition = 'center'; + cardImageContainer.style.maskPosition = 'center'; + cardImageContainer.style.webkitMaskSize = 'contain'; + cardImageContainer.style.maskSize = 'contain'; + cardImageContainer.style.backgroundColor = 'white'; + } else { + cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; + } + cardImageContainer.classList.remove('lazy', 'lazy-hidden'); + if (isSvg) { + cardImageContainer.classList.add('lazy-loaded', 'lazy-masked-svg'); + } else { + cardImageContainer.classList.add('lazy-loaded'); + } + cardImageContainer.removeAttribute('data-loading'); + const icon = cardImageContainer.querySelector('.cardImageIcon'); + if (icon) icon.remove(); + const canvas = cardImageContainer.previousElementSibling; + if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) { + canvas.classList.add('lazy-hidden'); + canvas.removeAttribute('data-blurhash-pending'); + } + } + + function unloadLazyImage(cardImageContainer) { + if (!cardImageContainer?.classList?.contains('lazy-loaded')) return; + clearLazyImagePaint(cardImageContainer); + cardImageContainer.classList.remove('lazy-loaded'); + cardImageContainer.classList.add('lazy'); + cardImageContainer.removeAttribute('data-loading'); + ensureLazyUnloadPlaceholder(cardImageContainer); + } + + function startLazyImageLoad(cardImageContainer) { + const imageUrl = cardImageContainer.getAttribute('data-src'); + if (!imageUrl) return; + if (cardImageContainer.hasAttribute('data-loading')) return; + if (cardImageContainer.classList.contains('lazy-loaded')) return; + + cardImageContainer.setAttribute('data-loading', 'true'); + const img = new Image(); + const finishError = () => { + if (cardImageContainer.getAttribute('data-src') !== imageUrl) return; + cardImageContainer.removeAttribute('data-loading'); + cardImageContainer.classList.remove('lazy'); + cardImageContainer.classList.add('lazy-loaded'); + cardImageContainer.classList.remove('lazy-hidden'); + const canvas = cardImageContainer.previousElementSibling; + if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) { + canvas.classList.add('lazy-hidden'); + canvas.removeAttribute('data-blurhash-pending'); + } + }; + img.onload = () => { + if (cardImageContainer.getAttribute('data-src') !== imageUrl) return; + if (!cardImageContainer.isConnected) return; + if (!cardImageContainer.hasAttribute('data-loading')) return; + const isSvg = /\.svg(?:[?#]|$)/i.test(imageUrl); + requestAnimationFrame(() => { + if (cardImageContainer.getAttribute('data-src') !== imageUrl) return; + if (!cardImageContainer.isConnected) return; + if (!cardImageContainer.hasAttribute('data-loading')) return; + applyLazyImageToContainer(cardImageContainer, imageUrl, isSvg); + }); + }; + img.onerror = finishError; + img.src = imageUrl; + } + + function handleLazyImageIntersection(entries, observer) { + const mobile = isMobileLayout(); + entries.forEach((entry) => { + const cardImageContainer = entry.target; + if (entry.isIntersecting) { + startLazyImageLoad(cardImageContainer); + if (!mobile) { + observer.unobserve(cardImageContainer); + } + return; + } + if (mobile && cardImageContainer.classList.contains('lazy-loaded')) { + unloadLazyImage(cardImageContainer); + } + }); + } + + function createLazyImageObserver(root) { + return new IntersectionObserver(handleLazyImageIntersection, { + root: root || null, + threshold: 0, + rootMargin: '50%', + }); + } + + /** + * Initialize the global IntersectionObserver for lazy loading images + * Watches when elements enter the viewport and loads their images + */ + function initLazyImageObserver() { + if (lazyImageObserver) return; // Already initialized + lazyImageObserver = createLazyImageObserver(null); + } + + function observeLazyImage(cardImageContainer) { + if (!cardImageContainer || !cardImageContainer.hasAttribute('data-src')) return; + initLazyImageObserver(); + if (!lazyImageObserver) return; + + if (isMobileLayout()) { + const scroller = getLazyScrollerRoot(cardImageContainer); + if (scroller) { + let observer = lazyObserversByScroller.get(scroller); + if (!observer) { + observer = createLazyImageObserver(scroller); + lazyObserversByScroller.set(scroller, observer); + } + observer.observe(cardImageContainer); + return; + } + } + + lazyImageObserver.observe(cardImageContainer); + } + + // --- Push lazy path (parallel native sections) — independent of progressive MO/kill-switch --- + let pushLazyImageObserver = null; + /** @type {WeakMap} */ + const pushLazyObserversByScroller = new WeakMap(); + let pushBlurhashFillObserver = null; + + function initPushLazyImageObserver() { + if (pushLazyImageObserver) return; + if (typeof IntersectionObserver === 'undefined') return; + pushLazyImageObserver = createLazyImageObserver(null); + } + + function observeLazyImagePush(cardImageContainer) { + if (!cardImageContainer || !cardImageContainer.hasAttribute('data-src')) return; + initPushLazyImageObserver(); + if (!pushLazyImageObserver) { + // No IO: load immediately + startLazyImageLoad(cardImageContainer); + return; + } + + if (isMobileLayout()) { + const scroller = getLazyScrollerRoot(cardImageContainer); + if (scroller) { + let observer = pushLazyObserversByScroller.get(scroller); + if (!observer) { + observer = createLazyImageObserver(scroller); + pushLazyObserversByScroller.set(scroller, observer); + } + observer.observe(cardImageContainer); + return; + } + } + + pushLazyImageObserver.observe(cardImageContainer); + } + + function initPushBlurhashFillObserver() { + if (pushBlurhashFillObserver) return; + if (typeof IntersectionObserver === 'undefined') return; + pushBlurhashFillObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + const canvas = entry.target; + pushBlurhashFillObserver.unobserve(canvas); + fillBlurhashCanvas(canvas); + }); + }, + { + threshold: 0, + rootMargin: getBlurhashFillRootMargin(), + }, + ); + } + + function observeBlurhashCanvasPush(canvas) { + if (!canvas || !canvas.hasAttribute('data-blurhash-pending')) return; + if (shouldSkipBlurhashDecode()) { + canvas.removeAttribute('data-blurhash-pending'); + canvas.classList.add('lazy-hidden'); + return; + } + initPushBlurhashFillObserver(); + if (pushBlurhashFillObserver) { + pushBlurhashFillObserver.observe(canvas); + } else { + fillBlurhashCanvas(canvas); + } + } + + function observePendingBlurhashInNodePush(node) { + if (!node || node.nodeType !== 1) return; + if (node.matches && node.matches('canvas.blurhash-canvas[data-blurhash-pending]')) { + observeBlurhashCanvasPush(node); + } + if (node.querySelectorAll) { + node.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvasPush); + } + } + + /** + * Push-register lazy images + blurhash under an attached root. + * Call sites should pass each section's .itemsContainer (never .homeSectionsContainer). + * @param {ParentNode|Element|null|undefined} root + */ + function registerLazyImagesIn(root) { + if (!root) return; + if (root.nodeType === 1 && root.matches?.('.cardImageContainer[data-src]')) { + observeLazyImagePush(root); + } + if (root.querySelectorAll) { + root.querySelectorAll('.cardImageContainer[data-src]').forEach(observeLazyImagePush); + } + observePendingBlurhashInNodePush(root); + } + + /** Register lazy images for a section via its .itemsContainer only. */ + function registerLazyImagesInSection(sectionElement) { + if (!sectionElement?.querySelector) return; + const itemsContainer = sectionElement.querySelector('.itemsContainer'); + if (itemsContainer) registerLazyImagesIn(itemsContainer); + } + + /** + * Apply scroll button state for a section from in-memory scrollX / maxScroll. + */ + function applyScrollButtonState(verticalSection) { + const scroller = verticalSection.querySelector('.emby-scroller'); + const scrollButtons = verticalSection.querySelector('.emby-scrollbuttons'); + const leftButton = scrollButtons && scrollButtons.querySelector('button[data-direction="left"]'); + const rightButton = scrollButtons && scrollButtons.querySelector('button[data-direction="right"]'); + if (!scroller || !scrollButtons || !leftButton || !rightButton) return; + const scrollX = getScrollerPosition(scroller); + + let maxScroll = 0; + const metrics = getScrollerMetrics(scroller); + if (metrics && metrics.maxScroll != null && Number.isFinite(metrics.maxScroll)) { + maxScroll = metrics.maxScroll; + } else { + maxScroll = getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller)); + } + leftButton.disabled = scrollX <= 20; + rightButton.disabled = scrollX >= maxScroll - 1; + scrollButtons.classList.toggle('hide', leftButton.disabled && rightButton.disabled); + } + + /** + * Compute max scroll from dimensions and apply button state. Called once when section is visible so layout is ready. + */ + function updateScrollButtonStateForSection(verticalSection) { + const scroller = verticalSection.querySelector('.emby-scroller'); + if (!scroller) return; + const metrics = getScrollerMetrics(scroller); + if (metrics) metrics.maxScroll = null; + getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller)); + applyScrollButtonState(verticalSection); + } + + /** + * Ensure scroll section visibility observer exists. When a section becomes visible, runs updateScrollButtonStateForSection once then unobserves. + */ + function getScrollSectionVisibilityObserver() { + if (scrollSectionVisibilityObserver) return scrollSectionVisibilityObserver; + scrollSectionVisibilityObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + const verticalSection = entry.target; + scrollSectionVisibilityObserver.unobserve(verticalSection); + const run = () => updateScrollButtonStateForSection(verticalSection); + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(run, { timeout: 200 }); + } else { + requestAnimationFrame(() => requestAnimationFrame(run)); + } + }); + }, + { root: null, threshold: 0.01 }, + ); + return scrollSectionVisibilityObserver; + } + + /** + * Register a scrollable section: when it becomes visible, set max scroll once and apply initial button state. + */ + function registerScrollSectionScrollButtons(verticalSection) { + const itemsContainer = verticalSection.querySelector('.itemsContainer'); + const scroller = verticalSection.querySelector('.emby-scroller'); + const first = itemsContainer && itemsContainer.firstElementChild; + const last = itemsContainer && itemsContainer.lastElementChild; + if (!first || !last) { + if (scroller) { + setMaxScrollPosition(scroller, 0); + applyScrollButtonState(verticalSection); + } + return; + } + getScrollSectionVisibilityObserver().observe(verticalSection); + } + + /** + * Initialize push lazy loading (IntersectionObserver only — no body MutationObserver). + */ + function initSmartLazyLoading() { + if (typeof IntersectionObserver === 'undefined') { + return; + } + + initLazyImageObserver(); + initBlurhashFillObserver(); + initPushLazyImageObserver(); + initPushBlurhashFillObserver(); + + // One-time scan for nodes already present before first push register + document.querySelectorAll('.cardImageContainer[data-src]').forEach(observeLazyImagePush); + document.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvasPush); + } + + // Initialize smart lazy loading on module load + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initSmartLazyLoading); + } else { + initSmartLazyLoading(); + } + + // Progressive section enhancement (deferred via IntersectionObserver when enhanceOnVisible) + let sectionEnhanceObserver = null; + let sectionEnhanceObserverRootMargin = null; + const sectionEnhancePending = new WeakMap(); + + /** FIFO: at most one section enhance apply per animation frame. */ + const enhanceFifoQueue = []; + let enhanceFifoRafScheduled = false; + + function enqueueSectionEnhance(task) { + if (typeof task !== 'function') return; + enhanceFifoQueue.push(task); + if (enhanceFifoRafScheduled) return; + enhanceFifoRafScheduled = true; + const drain = () => { + const next = enhanceFifoQueue.shift(); + if (next) { + try { + next(); + } catch (err) { + console.warn('[KefinTweaks CardBuilder] enhance FIFO task failed:', err); + } + } + if (enhanceFifoQueue.length) { + requestAnimationFrame(drain); + } else { + enhanceFifoRafScheduled = false; + } + }; + requestAnimationFrame(drain); + } + + function applyProgressiveEnhancementQueued(sectionElement, section, result, options) { + enqueueSectionEnhance(() => { + if (!sectionElement?.isConnected) return; + applyProgressiveEnhancement(sectionElement, section, result, options); + }); + } + + /** + * Read the currently active spotlight slide from a live section. + * @param {HTMLElement} sectionEl + * @returns {{ slide: HTMLElement, index: number, id: string } | null} + */ + function getActiveSpotlightSlide(sectionEl) { + if (!sectionEl) return null; + const slide = sectionEl.querySelector('.spotlight-item[data-active="true"], .spotlight-item[data-active]'); + if (!slide || slide.classList.contains('skeleton-spotlight-item')) return null; + const index = parseInt(slide.getAttribute('data-index'), 10); + const id = slide.getAttribute('data-id'); + if (!id || Number.isNaN(index) || index < 0) return null; + return { slide, index, id }; + } + + /** + * Build the item list for a keep-current-slide spotlight refresh. + * Dedupes activeId from results when present; inserts at i or appends as N+1 when beyond. + * @param {Array} newItems + * @param {{ index: number, id: string }} activeMeta + * @param {Array} dataItems - prior/cached items used to resolve the active item object + * @returns {{ items: Array, preserveIndex: number }} + */ + function buildSpotlightRefreshItems(newItems, activeMeta, dataItems) { + const source = Array.isArray(newItems) ? newItems.slice() : []; + const i = activeMeta.index; + const activeId = String(activeMeta.id); + const N = source.length; + + const findById = (list) => (list || []).find((d) => d && String(d.Id) === activeId); + let activeItem = findById(dataItems) || findById(source); + if (!activeItem) { + return { + items: source, + preserveIndex: Math.min(i, Math.max(0, N - 1)), + canPreserve: false, + }; + } + + // Prefer cached object so the kept slide matches item data + activeItem = findById(dataItems) || activeItem; + + const wasInResults = source.some((d) => d && String(d.Id) === activeId); + let working = wasInResults ? source.filter((d) => !(d && String(d.Id) === activeId)) : source; + + if (wasInResults) { + if (i <= working.length) { + working.splice(i, 0, activeItem); + return { items: working, preserveIndex: i, canPreserve: true }; + } + working.push(activeItem); + return { items: working, preserveIndex: working.length - 1, canPreserve: true }; + } + + if (i < N) { + working.splice(i, 0, activeItem); + return { items: working, preserveIndex: i, canPreserve: true }; + } + working.push(activeItem); + return { items: working, preserveIndex: working.length - 1, canPreserve: true }; + } + + /** + * Replace the rebuilt slide at preserveIndex with the detached active slide DOM. + * @param {HTMLElement} newSection + * @param {HTMLElement} activeSlide + * @param {number} preserveIndex + */ + function transplantActiveSpotlightSlide(newSection, activeSlide, preserveIndex) { + if (!newSection || !activeSlide) return; + const container = newSection.querySelector('.spotlight-items-container'); + if (!container) return; + + activeSlide.setAttribute('data-index', String(preserveIndex)); + activeSlide.setAttribute('data-active', 'true'); + activeSlide.removeAttribute('data-fade-out'); + activeSlide.removeAttribute('data-entering'); + + const built = container.querySelector(`.spotlight-item[data-index="${preserveIndex}"]`); + if (built) { + built.replaceWith(activeSlide); + } else { + container.appendChild(activeSlide); + } + } + + /** + * Rebuild a spotlight section from fresh items, optionally preserving the active slide. + * @param {HTMLElement} sectionElement + * @param {Array} items + * @param {Object} options + * @param {string} options.name + * @param {string} [options.viewMoreUrl] + * @param {Object} [options.spotlightConfig] + * @param {Array} [options.dataItems] + * @param {boolean} [options.keepCurrentSlide] + * @param {Object} [options.sectionConfig] + * @returns {{ content: HTMLElement, items: Array }} + */ + /** + * Rebuild a spotlight section from fresh items in-place (keeps section shell). + * Enhance/SWR (keepCurrentSlide): leave active slide DOM alone; swap inactive only. + * Manual refresh (!keepCurrentSlide): overwrite active + inactive slides. + * Skeleton sections still get a full createSpotlightSection + replaceWith for wiring. + * @returns {{ content: HTMLElement, items: Array }} + */ + function refreshSpotlight(sectionElement, items, options = {}) { + const { name, viewMoreUrl = null, spotlightConfig = {}, dataItems = [], keepCurrentSlide = false, sectionConfig = null } = options; + + let itemsForBuild = Array.isArray(items) ? items.slice() : []; + let preserveIndex = 0; + let activeSlide = null; + let doPreserve = false; + + const isSkeleton = !!sectionElement?.querySelector?.('.skeleton-spotlight-item'); + const liveItemsContainer = sectionElement?.querySelector?.('.spotlight-items-container'); + + if (keepCurrentSlide && !isSkeleton) { + const activeMeta = getActiveSpotlightSlide(sectionElement); + if (activeMeta) { + const built = buildSpotlightRefreshItems(itemsForBuild, activeMeta, dataItems); + if (built.canPreserve) { + itemsForBuild = built.items; + preserveIndex = built.preserveIndex; + activeSlide = activeMeta.slide; + doPreserve = true; + } + } + } + + // Skeleton or missing live container: full rebuild + replaceWith (needs interactive wiring) + if (isSkeleton || !liveItemsContainer) { + const content = createSpotlightSection(itemsForBuild, name, { + viewMoreUrl, + ...spotlightConfig, + ...(doPreserve ? { initialIndex: preserveIndex } : {}), + }); + + content.style.cssText = sectionElement.style.cssText; + content.className = sectionElement.className; + Array.from(sectionElement.attributes).forEach((attr) => { + content.setAttribute(attr.name, attr.value); + }); + + if (sectionConfig) { + attachSectionControlButtons(sectionConfig, content, itemsForBuild); + } + + stripCopiedSkeletonSectionClasses(content); + unobserveSkeletonShimmer(sectionElement); + sectionElement.replaceWith(content); + ensureSectionRevealObservation(content); + return { content, items: itemsForBuild }; + } + + // In-place: harvest slides from an inert build; never transplant the active slide + const temp = createSpotlightSection(itemsForBuild, name, { + viewMoreUrl, + ...spotlightConfig, + initialIndex: doPreserve + ? preserveIndex + : parseInt(liveItemsContainer.querySelector('.spotlight-item[data-active]')?.getAttribute('data-index'), 10) || 0, + inert: true, + }); + const tempItems = temp.querySelector('.spotlight-items-container'); + + if (doPreserve && activeSlide && tempItems) { + const keepId = activeSlide.getAttribute('data-id'); + Array.from(liveItemsContainer.children).forEach((slide) => { + if (slide !== activeSlide) slide.remove(); + }); + Array.from(tempItems.children).forEach((slide) => { + if (keepId && slide.getAttribute('data-id') === keepId) { + slide.remove(); + return; + } + liveItemsContainer.appendChild(slide); + }); + activeSlide.setAttribute('data-index', String(preserveIndex)); + activeSlide.setAttribute('data-active', 'true'); + activeSlide.removeAttribute('data-fade-out'); + activeSlide.removeAttribute('data-entering'); + } else if (tempItems) { + liveItemsContainer.replaceChildren(...Array.from(tempItems.children)); + } + + // Update title text/link on the same root (spotlight title lives under banner) + const titleEl = sectionElement.querySelector('.spotlight-section-title .emby-tab-button'); + if (titleEl && name != null) titleEl.textContent = name; + updateSectionViewMoreLink(sectionElement, viewMoreUrl); + + if (typeof sectionElement._kefinSpotlightApplyItems === 'function') { + sectionElement._kefinSpotlightApplyItems(itemsForBuild, { + currentIndex: doPreserve ? preserveIndex : undefined, + }); + } + if (typeof sectionElement._kefinSpotlightPruneWindow === 'function') { + sectionElement._kefinSpotlightPruneWindow(); + } + + if (sectionConfig) { + attachSectionControlButtons(sectionConfig, sectionElement, itemsForBuild); + } + + ensureSectionRevealObservation(sectionElement); + return { content: sectionElement, items: itemsForBuild }; + } + + function getPaintedCardIds(sectionElement) { + const cards = sectionElement.querySelectorAll('.itemsContainer > .card[data-id]:not(.skeleton-card)'); + if (cards.length) { + return Array.from(cards) + .map((card) => card.getAttribute('data-id')) + .filter(Boolean); + } + const buttons = sectionElement.querySelectorAll('.itemsContainer > .homeLibraryButton[data-id]'); + return Array.from(buttons) + .map((button) => button.getAttribute('data-id')) + .filter(Boolean); + } + + function classifyIdSequence(staleIds, freshIds) { + if (!staleIds.length || !freshIds.length) return { type: 'replace' }; + if (staleIds.length === freshIds.length && staleIds.every((id, i) => id === freshIds[i])) { + return { type: 'perfect' }; + } + + const staleSet = new Set(staleIds); + const freshSet = new Set(freshIds); + const sharedStale = staleIds.filter((id) => freshSet.has(id)); + const sharedFresh = freshIds.filter((id) => staleSet.has(id)); + if (!sharedStale.length || sharedStale.length !== sharedFresh.length) return { type: 'replace' }; + if (sharedStale.some((id, i) => id !== sharedFresh[i])) return { type: 'replace' }; + return { type: 'reconcile' }; + } + + function runAfterTransition(element, durationMs, callback) { + let done = false; + const finish = () => { + if (done) return; + done = true; + element.removeEventListener('transitionend', onEnd); + callback(); + }; + const onEnd = (event) => { + if (event.target !== element) return; + finish(); + }; + element.addEventListener('transitionend', onEnd); + setTimeout(finish, durationMs); + } + + function getItemProgress(item) { + if (item.Type === 'Program') { + const isOnNow = Date.now() >= new Date(item.StartDate).getTime() && Date.now() <= new Date(item.EndDate).getTime() + if (isOnNow) { + const progress = (Date.now() - new Date(item.StartDate).getTime()) / (new Date(item.EndDate).getTime() - new Date(item.StartDate).getTime()); + const progressPercentage = Math.round(progress * 100); + return progressPercentage; + } + } + if (item.UserData?.PlayedPercentage) { + return item.UserData?.PlayedPercentage; + } + return 0; + } + + function patchCardProgressBar(card, item) { + const imageContainer = card.querySelector('.cardImageContainer'); + if (!imageContainer) return; + + const percent = getItemProgress(item); + const existingBar = imageContainer.querySelector('.itemProgressBarForeground'); + const existingFooter = existingBar?.closest('.innerCardFooter'); + + if (percent && percent > 0) { + if (existingBar) { + if (existingBar.style.width !== `${percent}%`) { + existingBar.style.width = `${percent}%`; + } + return; + } + const innerCardFooter = document.createElement('div'); + innerCardFooter.className = 'innerCardFooter fullInnerCardFooter innerCardFooterClear'; + const itemProgressBar = document.createElement('div'); + itemProgressBar.className = 'itemProgressBar'; + const itemProgressBarForeground = document.createElement('div'); + itemProgressBarForeground.className = 'itemProgressBarForeground'; + itemProgressBarForeground.style.width = `${percent}%`; + itemProgressBar.appendChild(itemProgressBarForeground); + innerCardFooter.appendChild(itemProgressBar); + imageContainer.appendChild(innerCardFooter); + return; + } + + if (existingFooter && existingFooter.querySelector('.itemProgressBar')) { + existingFooter.remove(); + } + } + + function patchCardsUserData(sectionElement, items) { + items.forEach((item) => { + if (!item?.Id) return; + updateCardResumeAttributes(item.Id, item.UserData || {}); + const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(item.Id) : String(item.Id).replace(/["\\]/g, '\\$&'); + const card = sectionElement.querySelector(`.itemsContainer > .card[data-id="${escapedId}"]`); + if (card) patchCardProgressBar(card, item); + }); + } + + function getCardOuterWidth(card) { + if (!card) return 0; + const style = window.getComputedStyle(card); + return card.getBoundingClientRect().width + (parseFloat(style.marginLeft) || 0) + (parseFloat(style.marginRight) || 0); + } + + function getScrollerPosition(scroller) { + if (!scroller) return 0; + if (scroller.classList.contains('scrollX')) return scroller.scrollLeft || 0; + return getCurrentPosition(scroller); + } + + function setScrollerPosition(scroller, position, animate) { + if (!scroller) return; + const metrics = getScrollerMetrics(scroller); + if (metrics) metrics.maxScroll = null; + + if (scroller.classList.contains('scrollX')) { + const maxPosition = getMaxPositionFromPadding( + scroller, + readScrollerPadding(scroller), + readSampleCardPadding(scroller), + ); + const scrollPosition = Math.min(Math.max(position, 0), maxPosition); + const clamped = scrollPosition > 20 ? scrollPosition : 0; + // Clear any desktop slidee transform so it cannot fight native scrollLeft + const slidee = getScrollSlidee(scroller); + if (slidee) { + if (slidee.style.transform) slidee.style.transform = ''; + if (slidee.style.transition) slidee.style.transition = ''; + } + if (scroller.style.transform) scroller.style.transform = ''; + scroller.scrollTo({ left: clamped, behavior: animate ? 'smooth' : 'auto' }); + setStoredScrollPosition(scroller, clamped); + } else { + const slidee = getScrollSlidee(scroller); + setSlideePosition(scroller, position, { animate: !!animate, freshMax: true }); + + const section = scroller.closest('.emby-scroller-container'); + if (section) { + if (animate && slidee) { + const onTransitionEnd = (e) => { + if (e && e.target !== slidee) return; + slidee.removeEventListener('transitionend', onTransitionEnd); + applyScrollButtonState(section); + }; + slidee.addEventListener('transitionend', onTransitionEnd); + requestAnimationFrame(() => applyScrollButtonState(section)); + } else { + applyScrollButtonState(section); + } + } + return; + } + + const section = scroller.closest('.emby-scroller-container'); + if (section) { + if (animate) { + requestAnimationFrame(() => applyScrollButtonState(section)); + } else { + applyScrollButtonState(section); + } + } + } + + /** + * Scroll a Kefin custom scroller so card at index is left-aligned (last card → max). + * @param {HTMLElement} scroller + * @param {number} index + * @param {{ animate?: boolean }} [options] + */ + function scrollToCardIndex(scroller, index, options = {}) { + if (!scroller) return; + const animate = options.animate === true; + const itemsContainer = scroller.querySelector('.itemsContainer'); + if (!itemsContainer) return; + const cards = Array.from(itemsContainer.querySelectorAll(':scope > .card:not(.skeleton-card)')); + if (!cards.length) return; + + const clampedIndex = Math.max(0, Math.min(index, cards.length - 1)); + const metrics = getScrollerMetrics(scroller); + if (metrics) { + metrics.maxScroll = null; + metrics.pad = null; + metrics.cardPad = null; + } + + if (clampedIndex === cards.length - 1) { + setScrollerPosition( + scroller, + getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller)), + animate, + ); + return; + } + + const pad = readScrollerPadding(scroller); + const cardPad = readSampleCardPadding(scroller); + const targetLeft = getCardContentOffsetX(scroller, cards[clampedIndex]) - pad.left + cardPad.left; + setScrollerPosition(scroller, targetLeft, animate); + + const section = scroller.closest('.emby-scroller-container'); + if (section) applyScrollButtonState(section); + } + + function reindexSectionCards(itemsContainer) { + getRealLayoutCards(itemsContainer).forEach((card, index) => { + card.setAttribute('data-index', String(index)); + }); + } + + function clearCardReconcileStyles(card) { + card.classList.remove('cardbuilder-card-insert', 'cardbuilder-card-collapse', 'is-in'); + card.style.width = ''; + card.style.minWidth = ''; + card.style.maxWidth = ''; + card.style.marginLeft = ''; + card.style.marginRight = ''; + card.style.overflow = ''; + card.style.opacity = ''; + card.style.paddingLeft = ''; + card.style.paddingRight = ''; + } + + function captureCardLayout(card) { + const style = window.getComputedStyle(card); + return { + card, + width: card.getBoundingClientRect().width, + marginLeft: style.marginLeft, + marginRight: style.marginRight, + }; + } + + function applyCardCollapsedSize(card) { + card.style.width = '0px'; + card.style.minWidth = '0px'; + card.style.maxWidth = '0px'; + card.style.marginLeft = '0px'; + card.style.marginRight = '0px'; + card.style.overflow = 'hidden'; + card.style.opacity = '0'; + } + + function applyCardExpandedSize(layout) { + const { card, width, marginLeft, marginRight } = layout; + card.style.width = `${width}px`; + card.style.minWidth = `${width}px`; + card.style.maxWidth = `${width}px`; + card.style.marginLeft = marginLeft; + card.style.marginRight = marginRight; + card.style.overflow = 'hidden'; + card.style.opacity = '1'; + } + + function reconcileRowItems(sectionElement, items, sectionConfig, onComplete) { + console.log(`[KefinTweaks CardBuilder] Reconciling row items for section: ${sectionConfig.name} with ${items.length} items`); + const itemsContainer = sectionElement.querySelector('.itemsContainer'); + const scroller = sectionElement.querySelector('.emby-scroller'); + if (!itemsContainer) return false; + + const overflowCard = sectionConfig.overflowCard; + const cardFormat = sectionElement.getAttribute('data-card-format') || sectionConfig.cardFormat; + + const paintedCards = getRealLayoutCards(itemsContainer).filter((card) => { + return card.getAttribute('data-id') && !card.classList.contains('skeleton-card'); + }); + const cardById = new Map(); + paintedCards.forEach((card) => { + const id = card.getAttribute('data-id'); + if (id && !cardById.has(id)) cardById.set(id, card); + }); + + const freshIds = sectionConfig?.useParentCard + ? items.map((item) => item?.ParentId || item?.SeriesId || item?.Id).filter(Boolean) + : items.map((item) => item?.Id).filter(Boolean); + const freshSet = new Set(freshIds); + const outgoing = paintedCards.filter((card) => !freshSet.has(card.getAttribute('data-id'))); + const inserted = []; + const prefixInserted = []; + + let nextNode = paintedCards[0] || null; + let seenKept = false; + + items.forEach((item, index) => { + if (!item?.Id) return; + const existing = cardById.get(item.Id); + if (existing) { + seenKept = true; + nextNode = existing.nextElementSibling; + while (nextNode && nextNode.classList.contains('skeleton-card')) { + nextNode = nextNode.nextElementSibling; + } + return; + } + + let imageParams = null; + if (sectionConfig?.sectionCssClass && sectionConfig?.sectionCssClass.includes('rows-2') && index === 0) { + imageParams = getDoubledAspectRatioImageParams(cardFormat); + } + + const card = createCardElement( + item, + overflowCard, + cardFormat, + item.cardFooter, + !!sectionConfig?.useParentCard, + sectionConfig?.borderStyle || null, + imageParams, + ); + if (nextNode) itemsContainer.insertBefore(card, nextNode); + else itemsContainer.appendChild(card); + inserted.push(card); + if (!seenKept) prefixInserted.push(card); + }); + + ensureCardBorders(sectionElement); + registerLazyImagesIn(itemsContainer); + + const previousPosition = getScrollerPosition(scroller); + let prefixWidth = 0; + prefixInserted.forEach((card) => { + prefixWidth += getCardOuterWidth(card); + }); + + const midInserted = inserted.filter((card) => !prefixInserted.includes(card)); + const midLayouts = midInserted.map(captureCardLayout); + const outgoingLayouts = outgoing.map(captureCardLayout); + const canSlidePrefix = !!(scroller && prefixWidth > 0); + const prefixLayouts = canSlidePrefix ? [] : prefixInserted.map(captureCardLayout); + + midLayouts.forEach(({ card }) => { + card.classList.add('cardbuilder-card-insert'); + applyCardCollapsedSize(card); + }); + prefixLayouts.forEach(({ card }) => { + card.classList.add('cardbuilder-card-insert'); + applyCardCollapsedSize(card); + }); + outgoingLayouts.forEach((layout) => { + layout.card.classList.add('cardbuilder-card-collapse'); + applyCardExpandedSize(layout); + }); + if (canSlidePrefix) { + prefixInserted.forEach((card) => { + card.classList.add('cardbuilder-card-insert'); + card.style.opacity = '0'; + }); + setScrollerPosition(scroller, previousPosition + prefixWidth, false); + } + + itemsContainer.offsetHeight; + itemsContainer.classList.add('cardbuilder-row-animating'); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + outgoingLayouts.forEach(({ card }) => applyCardCollapsedSize(card)); + midLayouts.forEach((layout) => { + layout.card.classList.add('is-in'); + applyCardExpandedSize(layout); + }); + prefixLayouts.forEach((layout) => { + layout.card.classList.add('is-in'); + applyCardExpandedSize(layout); + }); + if (canSlidePrefix) { + prefixInserted.forEach((card) => { + card.classList.add('is-in'); + card.style.opacity = '1'; + }); + setScrollerPosition(scroller, previousPosition, true); + } + + const animTarget = outgoing[0] || midInserted[0] || prefixInserted[0] || scroller || itemsContainer; + runAfterTransition(animTarget, 450, () => { + outgoing.forEach((card) => card.remove()); + inserted.forEach(clearCardReconcileStyles); + itemsContainer.classList.remove('cardbuilder-row-animating'); + if (scroller) { + scroller.style.transition = ''; + const slidee = getScrollSlidee(scroller); + if (slidee) slidee.style.transition = ''; + } + reindexSectionCards(itemsContainer); + patchCardsUserData(sectionElement, items); + ensureCardBorders(sectionElement); + if (typeof onComplete === 'function') { + onComplete(); + } + }); + }); + }); + + return true; + } + + function replaceItemsContainerContents(sectionElement, items, sectionConfig) { + const itemsContainer = sectionElement.querySelector('.itemsContainer'); + if (!itemsContainer || !sectionConfig) return sectionElement; + + const overflowCard = sectionConfig.overflowCard; + const cardFormat = sectionElement.getAttribute('data-card-format') || sectionConfig.cardFormat; + const normalizedFormat = (cardFormat || '').toLowerCase(); + const layoutFromDom = itemsContainer.getAttribute('data-layout'); + const layout = + layoutFromDom === 'grid' || layoutFromDom === 'row' + ? layoutFromDom + : resolveItemsLayout(sectionConfig); + const gapless = itemsContainer.getAttribute('data-gapless') === 'true' || resolveUseGaplessCards(sectionConfig); + + const fragment = document.createDocumentFragment(); + if (normalizedFormat === 'button' || isButtonCardFormat(cardFormat)) { + items.forEach((item, index) => { + const button = createLibraryButtonElement(item); + button.setAttribute('data-index', String(index)); + fragment.appendChild(button); + }); + } else { + const useParentCard = !!sectionConfig?.useParentCard; + const borderStyle = sectionConfig?.borderStyle || null; + + items.forEach((item, index) => { + let imageParams = null; + if (sectionConfig?.sectionCssClass && sectionConfig?.sectionCssClass.includes('rows-2') && index === 0) { + imageParams = getDoubledAspectRatioImageParams(cardFormat); + } + + const card = createCardElement( + item, + overflowCard, + cardFormat, + item.cardFooter, + useParentCard, + borderStyle, + imageParams, + ); + card.setAttribute('data-index', String(index)); + fragment.appendChild(card); + }); + } + + replaceChildren(itemsContainer, fragment); + + applyItemsLayoutState(sectionElement, layout, gapless); + ensureCardBorders(sectionElement); + attachSectionControlButtons(sectionConfig, sectionElement, items); + registerLazyImagesIn(itemsContainer); + + const scroller = sectionElement.querySelector('.emby-scroller'); + invalidateScrollerMetrics(scroller); + if (scroller && isTvLayout()) { + attachTvFocusScroll(scroller); + const focusedCard = document.activeElement?.closest?.('.card'); + if (focusedCard && scroller.contains(focusedCard)) { + requestAnimationFrame(() => toCenterCard(scroller, focusedCard, { animate: false })); + } + } + + return sectionElement; + } + + function fadeReplaceRowItems(sectionElement, items, sectionConfig, onComplete) { + const itemsContainer = sectionElement.querySelector('.itemsContainer'); + if (!itemsContainer) return false; + + const finish = () => { + replaceItemsContainerContents(sectionElement, items, sectionConfig); + requestAnimationFrame(() => itemsContainer.classList.remove('is-hidden')); + if (typeof onComplete === 'function') { + onComplete(); + } + }; + + if (isMobileLayout()) { + finish(); + return true; + } + + itemsContainer.classList.add('cardbuilder-items-fade'); + itemsContainer.classList.add('is-hidden'); + runAfterTransition(itemsContainer, 320, finish); + return true; + } + + function replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially) { + let finalCardFormat = sectionConfig.cardFormat; + const skeletonCardFormat = sectionElement.getAttribute('data-card-format'); + if (skeletonCardFormat) { + finalCardFormat = skeletonCardFormat; + } + + if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { + const refreshed = refreshSpotlight(sectionElement, items, { + name: getSectionTitleForMultiQuery(sectionConfig) || sectionConfig.name, + viewMoreUrl: getActiveViewMoreUrl(sectionConfig) || sectionConfig.viewMoreUrl, + spotlightConfig: sectionConfig.spotlightConfig, + dataItems: Array.isArray(dataItems) ? dataItems : [], + keepCurrentSlide: true, + sectionConfig, + }); + ensureCardBorders(refreshed.content); + ensureSectionRevealObservation(refreshed.content); + return refreshed.content; + } + + // Prefer in-place itemsContainer swap (sticky is=emby-itemscontainer) + if (sectionElement.querySelector('.itemsContainer')) { + rebuildSectionTitleInPlace(sectionElement, sectionConfig); + replaceItemsContainerContents(sectionElement, items, { + ...sectionConfig, + cardFormat: finalCardFormat, + }); + ensureSectionRevealObservation(sectionElement); + return sectionElement; + } + + const content = createScrollableContainer( + items, + sectionConfig.name, + sectionConfig.viewMoreUrl, + sectionConfig.overflowCard, + finalCardFormat, + sectionConfig, + false, + ); + content.style.cssText = sectionElement.style.cssText; + content.className = sectionElement.className; + Array.from(sectionElement.attributes).forEach((attr) => { + content.setAttribute(attr.name, attr.value); + }); + + const oldItemsContainer = sectionElement.querySelector('.itemsContainer'); + const layoutFromDom = oldItemsContainer?.getAttribute('data-layout'); + const layout = layoutFromDom === 'grid' || layoutFromDom === 'row' ? layoutFromDom : resolveItemsLayout(sectionConfig); + const gapless = oldItemsContainer + ? oldItemsContainer.getAttribute('data-gapless') === 'true' + : resolveUseGaplessCards(sectionConfig); + attachSectionControlButtons(sectionConfig, content, items); + invalidateScrollerMetrics(sectionElement.querySelector('.emby-scroller')); + stripCopiedSkeletonSectionClasses(content); + unobserveSkeletonShimmer(sectionElement); + sectionElement.replaceWith(content); + applyItemsLayoutState(content, layout, gapless); + ensureCardBorders(content); + ensureSectionRevealObservation(content); + return content; + } + + function fadeReplaceSpotlight(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially) { + sectionElement.classList.add('cardbuilder-items-fade'); + sectionElement.classList.add('is-hidden'); + runAfterTransition(sectionElement, 320, () => { + const content = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially); + markSectionEnhanced(content, sectionConfig); + content.classList.add('cardbuilder-items-fade', 'is-hidden'); + requestAnimationFrame(() => content.classList.remove('is-hidden')); + }); + } + + function markSectionEnhanced(sectionElement, sectionConfig, { updateScrollButtons = false } = {}) { + if (!sectionElement) return; + unobserveSkeletonShimmer(sectionElement); + sectionElement.dataset.enhanced = 'true'; + if (typeof sectionConfig?._onSectionEnhanced === 'function') { + sectionConfig._onSectionEnhanced(sectionElement, sectionConfig); + } + if (updateScrollButtons) { + const scroller = sectionElement.querySelector?.('.emby-scroller'); + if (scroller) invalidateScrollerMetrics(scroller); + if (isTvLayout()) { + if (scroller) { + attachTvFocusScroll(scroller); + const focusedCard = document.activeElement?.closest?.('.card'); + if (focusedCard && scroller.contains(focusedCard)) { + requestAnimationFrame(() => toCenterCard(scroller, focusedCard, { animate: false })); + } + } + } else { + registerScrollSectionScrollButtons(sectionElement); + } + } + } + + function getDismissEmptySectionTimerMs() { + const raw = + window.KefinTweaksConfig?.homeScreenConfig?.HOME_SETTINGS?.dismissEmptySectionTimer ?? + window.KefinHomeConfig2?.HOME_SETTINGS?.dismissEmptySectionTimer ?? + 0; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; + } + + /** + * Show "No items found" at locked section height, then fade and remove. + * Used when progressive enhance resolves to an empty item list and the timer is > 0. + */ + function dismissEmptyProgressiveSection(sectionElement, { holdMs = 1000 } = {}) { + if (!sectionElement || !sectionElement.isConnected) return; + if (sectionElement.dataset.emptyDismissing === 'true') return; + + sectionElement.dataset.emptyDismissing = 'true'; + const lockedHeight = Math.max(sectionElement.offsetHeight, 0); + if (lockedHeight > 0) { + sectionElement.style.minHeight = `${lockedHeight}px`; + } + + const isSpotlight = + sectionElement.classList.contains('spotlight-section') || + !!sectionElement.querySelector('.spotlight-items-container, .skeleton-spotlight-item'); + + const message = document.createElement('div'); + message.className = 'cardbuilder-empty-message'; + message.textContent = 'No items found'; + + if (isSpotlight) { + const itemsContainer = sectionElement.querySelector('.spotlight-items-container'); + if (itemsContainer) { + itemsContainer.innerHTML = ''; + itemsContainer.appendChild(message); + } else { + const body = sectionElement.querySelector('.spotlight-banner-container') || sectionElement; + Array.from(body.children).forEach((child) => { + if ( + !child.classList.contains('spotlight-header-container') && + !child.classList.contains('spotlight-section-title-container') && + !child.classList.contains('sectionTitleContainer') + ) { + child.remove(); + } + }); + body.appendChild(message); + } + } else { + const itemsContainer = sectionElement.querySelector('.itemsContainer'); + if (itemsContainer) { + itemsContainer.innerHTML = ''; + itemsContainer.appendChild(message); + } else { + sectionElement.appendChild(message); + } + } + + const hold = Math.max(0, Number(holdMs) || 0); + setTimeout(() => { + if (!sectionElement.isConnected) return; + sectionElement.classList.add('cardbuilder-items-fade'); + sectionElement.classList.add('is-hidden'); + runAfterTransition(sectionElement, 320, () => { + sectionElement.remove(); + notifyDiscoveryLeadFillAfterSectionRemove(); + }); + }, hold); + } + + function hasSectionDeferredData(result) { + if (!result) return false; + if (typeof result.ensureData === 'function') return true; + if (result.isStalePromise) return true; + return !!Object.getOwnPropertyDescriptor(result, 'dataPromise'); + } + + function getSectionDataPromise(result) { + if (!result) return null; + if (typeof result.ensureData === 'function') return result.ensureData(); + const desc = Object.getOwnPropertyDescriptor(result, 'dataPromise'); + if (desc?.get) return desc.get.call(result); + return result.dataPromise || null; + } + + function applyProgressiveEnhancement( + sectionElement, + section, + result, + { revealSectionsSequentially = false, updateScrollButtons = false } = {}, + ) { + if (!sectionElement?.isConnected || !section?.config) return; + + setSectionRefreshing(sectionElement, false); + sectionElement.dataset.enhanceApplied = 'true'; + + const sectionConfig = section.config; + let items = result?.Items ?? result ?? []; + if (!Array.isArray(items)) items = []; + + const dataItems = section.result?.data?.Items ?? section.result?.data ?? []; + const isSpotlight = sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight'; + const isSkeleton = !!sectionElement.querySelector('.skeleton-card, .skeleton-spotlight-item'); + const hasSkeletonTitle = !!sectionElement.querySelector('.skeleton-section-title'); + const enhanceMarkOpts = { updateScrollButtons: !!updateScrollButtons }; + + if (items.length === 0) { + const holdMs = getDismissEmptySectionTimerMs(); + if (holdMs > 0) { + dismissEmptyProgressiveSection(sectionElement, { holdMs }); + } else { + sectionElement.remove(); + notifyDiscoveryLeadFillAfterSectionRemove(); + } + return; + } + + // Skeleton spotlight needs full createSpotlightSection wiring via replaceWith + if (isSkeleton && isSpotlight) { + const content = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially); + markSectionEnhanced(content, sectionConfig, enhanceMarkOpts); + return; + } + + // Pending→resolved title (skeleton title text) + cards: rebuild title in place, swap cards + if (isSkeleton && hasSkeletonTitle && !isSpotlight) { + rebuildSectionTitleInPlace(sectionElement, sectionConfig); + replaceItemsContainerContents(sectionElement, items, sectionConfig); + markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts); + return; + } + + if (!isSkeleton && isSpotlight) { + const staleIds = (Array.isArray(dataItems) ? dataItems : []).map((item) => item?.Id).filter(Boolean); + const paintedIds = staleIds.length + ? staleIds + : Array.from(sectionElement.querySelectorAll('.spotlight-item[data-id]:not(.skeleton-spotlight-item)')) + .map((el) => el.getAttribute('data-id')) + .filter(Boolean); + const freshIds = sectionConfig?.useParentCard + ? items.map((item) => item?.ParentId || item?.SeriesId || item?.Id).filter(Boolean) + : items.map((item) => item?.Id).filter(Boolean); + const match = classifyIdSequence(paintedIds, freshIds); + if (match.type === 'perfect') { + markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts); + return; + } + // In-place inactive slides only (keepCurrentSlide); no full-section replaceWith + const spotlightContent = replaceSectionWithFreshCards( + sectionElement, + items, + sectionConfig, + dataItems, + revealSectionsSequentially, + ); + markSectionEnhanced(spotlightContent, sectionConfig, enhanceMarkOpts); + return; + } + + if (!isSkeleton && !isSpotlight) { + const paintedIds = getPaintedCardIds(sectionElement); + const freshIds = sectionConfig?.useParentCard + ? items.map((item) => item?.ParentId || item?.SeriesId || item?.Id).filter(Boolean) + : items.map((item) => item?.Id).filter(Boolean); + const match = classifyIdSequence(paintedIds, freshIds); + + if (match.type === 'perfect') { + patchCardsUserData(sectionElement, items); + markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts); + return; + } + + const itemsContainer = sectionElement.querySelector('.itemsContainer'); + const layoutFromDom = itemsContainer?.getAttribute('data-layout'); + const layout = layoutFromDom === 'grid' || layoutFromDom === 'row' ? layoutFromDom : resolveItemsLayout(sectionConfig); + + if (match.type === 'reconcile' && layout === 'row') { + reconcileRowItems(sectionElement, items, sectionConfig, () => { + markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts); + }); + return; + } + + const cardFormat = sectionElement.getAttribute('data-card-format') || sectionConfig.cardFormat; + if (isButtonCardFormat(cardFormat) && match.type === 'replace') { + replaceItemsContainerContents(sectionElement, items, sectionConfig); + markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts); + return; + } + + fadeReplaceRowItems(sectionElement, items, sectionConfig, () => { + markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts); + }); + return; + } + + // Skeleton cards without skeleton title: swap itemsContainer contents in place + replaceItemsContainerContents(sectionElement, items, sectionConfig); + markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts); + } + + function parseEnhanceRootMargin(rootMargin) { + const raw = String(rootMargin || '30% 0px 30% 0px') + .trim() + .split(/\s+/); + const top = raw[0] || '0px'; + const right = raw[1] || top; + const bottom = raw[2] || top; + const left = raw[3] || right; + const toPx = (token, axisSize) => { + const t = String(token || '0').trim(); + if (t.endsWith('%')) { + const pct = parseFloat(t) || 0; + return (pct / 100) * axisSize; + } + return parseFloat(t) || 0; + }; + return { + top: toPx(top, window.innerHeight || 0), + right: toPx(right, window.innerWidth || 0), + bottom: toPx(bottom, window.innerHeight || 0), + left: toPx(left, window.innerWidth || 0), + }; + } + + function isSectionWithinEnhanceMargin(el, rootMargin) { + if (!el?.isConnected || typeof el.getBoundingClientRect !== 'function') return false; + const rect = el.getBoundingClientRect(); + const m = parseEnhanceRootMargin(rootMargin || sectionEnhanceObserverRootMargin || '30% 0px 30% 0px'); + const vw = window.innerWidth || 0; + const vh = window.innerHeight || 0; + return rect.bottom >= -m.top && rect.top <= vh + m.bottom && rect.right >= -m.left && rect.left <= vw + m.right; + } + + function setupSectionProgressiveEnhancement(sectionElement, section, options = {}) { + const { revealSectionsSequentially = false, deferUntilVisible = false } = options; + if (!sectionElement || !section?.result) return; + if (!hasSectionDeferredData(section.result)) return; + if (sectionElement.dataset.enhanceApplied === 'true') return; + if (sectionElement.dataset.enhanceScheduled === 'true') return; + + sectionElement.dataset.enhanceScheduled = 'true'; + + const dataPromise = getSectionDataPromise(section.result); + if (!dataPromise) return; + + const enhanceOptions = { revealSectionsSequentially, deferUntilVisible }; + + dataPromise + .then((result) => { + if (!sectionElement.isConnected) return; + + const refreshPromise = section.result?.refreshPromise; + const hasPendingRefresh = !!refreshPromise; + const sectionConfig = section.config; + const isSpotlight = !!(sectionConfig?.spotlight || sectionConfig?.renderMode === 'Spotlight'); + // Non-spotlight Random: keep skeleton until fresh refresh — avoid stale paint → fadeReplace + const skipStalePaint = hasPendingRefresh && sectionHasRandomQuerySort(sectionConfig) && !isSpotlight; + + let items = result?.Items ?? result ?? []; + if (!Array.isArray(items)) items = []; + + const applyOrDefer = (payload, { updateScrollButtons = false } = {}) => { + const applyOpts = { + ...enhanceOptions, + updateScrollButtons: !!updateScrollButtons, + }; + if (!deferUntilVisible) { + applyProgressiveEnhancementQueued(sectionElement, section, payload, applyOpts); + return; + } + const margin = sectionEnhanceObserverRootMargin || '30% 0px 30% 0px'; + const inMargin = isSectionWithinEnhanceMargin(sectionElement, margin); + if (inMargin) { + applyProgressiveEnhancementQueued(sectionElement, section, payload, applyOpts); + } else { + // Data ready but section left the enhance margin — wait until it re-enters + delete sectionElement.dataset.enhanceScheduled; + sectionEnhancePending.set(sectionElement, { + section, + options: applyOpts, + pendingResult: payload, + }); + observeSectionForEnhance(sectionElement); + } + }; + + if (!skipStalePaint) { + if (items.length === 0) { + // Empty sections: dismiss immediately even if scrolled away + applyProgressiveEnhancementQueued(sectionElement, section, result, { + ...enhanceOptions, + updateScrollButtons: !hasPendingRefresh, + }); + return; + } + applyOrDefer(result, { updateScrollButtons: !hasPendingRefresh }); + } + // skipStalePaint: do not paint stale Random cache — wait for refreshPromise below + + if (!hasPendingRefresh) return; + + refreshPromise + .then((fresh) => { + if (!sectionElement.isConnected) return; + // Prefer live DOM apply; if still pending observe, update pending snapshot + const pending = sectionEnhancePending.get(sectionElement); + if (pending && pending.pendingResult !== undefined) { + pending.pendingResult = fresh; + pending.options = { + ...(pending.options || enhanceOptions), + updateScrollButtons: true, + }; + return; + } + if (skipStalePaint) { + applyOrDefer(fresh, { updateScrollButtons: true }); + return; + } + // Refresh through same FIFO + margin gate as initial enhance + applyOrDefer(fresh, { updateScrollButtons: true }); + }) + .catch((err) => { + console.warn('[KefinTweaks CardBuilder] Background refresh failed:', section?.config?.id, err); + if (!sectionElement.isConnected) return; + // Random non-spotlight skipped stale paint — fall back so we are not stuck on skeletons + if (skipStalePaint) { + applyOrDefer(result, { updateScrollButtons: true }); + } + }); + }) + .catch((err) => { + console.error('[KefinTweaks CardBuilder] Progressive enhance failed:', section?.config?.id, err); + if (!sectionElement.isConnected) return; + setSectionRefreshing(sectionElement, false); + delete sectionElement.dataset.enhanceScheduled; + applyProgressiveEnhancementQueued(sectionElement, section, { Items: [] }, enhanceOptions); + }); + } + + function initializeSectionEnhanceObserver(rootMargin = '30% 0px 30% 0px') { + if (typeof IntersectionObserver === 'undefined') { + return; + } + + if (sectionEnhanceObserver && sectionEnhanceObserverRootMargin === rootMargin) { + return; + } + + if (sectionEnhanceObserver) { + sectionEnhanceObserver.disconnect(); + } + + sectionEnhanceObserverRootMargin = rootMargin; + sectionEnhanceObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + + const target = entry.target; + sectionEnhanceObserver.unobserve(target); + + const pending = sectionEnhancePending.get(target); + if (!pending) return; + + sectionEnhancePending.delete(target); + + if (pending.pendingResult !== undefined) { + applyProgressiveEnhancementQueued(target, pending.section, pending.pendingResult, pending.options || {}); + return; + } + + setupSectionProgressiveEnhancement(target, pending.section, pending.options); + }); + }, + { + threshold: 0, + rootMargin, + }, + ); + } + + function observeSectionForEnhance(sectionElement) { + if (!sectionElement || !sectionEnhancePending.has(sectionElement)) return; + + const sectionId = sectionElement.getAttribute('data-section-id'); + console.log('[KefinTweaks CardBuilder] observeSectionForEnhance', { + sectionId, + hasObserver: !!sectionEnhanceObserver, + pendingCount: sectionEnhancePending.has(sectionElement) ? 1 : 0, + }); + + initializeSectionEnhanceObserver(sectionEnhanceObserverRootMargin || '30% 0px 30% 0px'); + + if (sectionEnhanceObserver) { + sectionEnhanceObserver.observe(sectionElement); + return; + } + + // No IO (unsupported): apply pending immediately — do NOT call + // setupSectionProgressiveEnhancement again (that re-enters applyOrDefer → infinite loop). + const pending = sectionEnhancePending.get(sectionElement); + sectionEnhancePending.delete(sectionElement); + if (!pending) return; + console.warn('[KefinTweaks CardBuilder] enhance observer unavailable; applying pending immediately', sectionId); + if (pending.pendingResult !== undefined) { + applyProgressiveEnhancementQueued(sectionElement, pending.section, pending.pendingResult, pending.options || {}); + } else { + applyProgressiveEnhancementQueued(sectionElement, pending.section, { Items: [] }, pending.options || {}); + } + } + + function scheduleSectionProgressiveEnhancement(sectionElement, section, options) { + const { enhanceOnVisible = false, enhanceRootMargin = '30% 0px 30% 0px', revealSectionsSequentially = false } = options; + + if (!hasSectionDeferredData(section?.result)) { + return; + } + + const enhanceOptions = { + revealSectionsSequentially, + deferUntilVisible: !!enhanceOnVisible, + }; + + if (!enhanceOnVisible) { + setupSectionProgressiveEnhancement(sectionElement, section, enhanceOptions); + return; + } + + if (typeof IntersectionObserver === 'undefined') { + setupSectionProgressiveEnhancement(sectionElement, section, enhanceOptions); + return; + } + + sectionEnhanceObserverRootMargin = enhanceRootMargin; + sectionEnhancePending.set(sectionElement, { section, options: enhanceOptions }); + observeSectionForEnhance(sectionElement); + } + + // Progressive Section Reveal with IntersectionObserver + let sectionRevealObserver = null; + + /** + * Initialize the global IntersectionObserver for section reveal animations + * Watches when sections enter the viewport and triggers fade-in animations + */ + function initializeSectionRevealObserver() { + if (sectionRevealObserver) return; // Already initialized + + if (typeof IntersectionObserver === 'undefined') { + return; // Browser doesn't support IntersectionObserver + } + + const observerOptions = { + threshold: 0.1, // Trigger when 10% of section is visible + rootMargin: '0px 0px -50px 0px', // Trigger slightly before fully in view + }; + + sectionRevealObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + // Add in-viewport class to trigger animation + entry.target.classList.add('in-viewport'); + // Unobserve after animation is triggered (one-time animation) + sectionRevealObserver.unobserve(entry.target); + } + }); + }, observerOptions); + } + + /** + * After replaceWith, the new node keeps cardbuilder-section-reveal but is no longer + * observed — re-attach so in-viewport can still be applied (or stays if already set). + */ + function ensureSectionRevealObservation(sectionElement) { + if (!sectionElement?.classList?.contains('cardbuilder-section-reveal')) return; + if (sectionElement.classList.contains('in-viewport')) return; + observeSectionForReveal(sectionElement); + } + + /** + * Observe a section element for viewport entry to trigger reveal animation + * @param {HTMLElement} sectionElement - The section element to observe + */ + function observeSectionForReveal(sectionElement) { + if (!sectionElement) return; + + // Initialize observer if not already done + if (!sectionRevealObserver) { + initializeSectionRevealObserver(); + } + + // Only observe if observer was successfully created + if (sectionRevealObserver) { + sectionRevealObserver.observe(sectionElement); + } + } + + async function initialize() { + // Match loader default: watchlist enabled unless scripts.watchlist === false + const scripts = window.KefinTweaksConfig?.scripts || {}; + state.watchlist = scripts.watchlist !== false; + + if (window.userHelper?.waitForLogin) { + await window.userHelper.waitForLogin(); + } + startCardUserDataListener(); + startCardLibraryChangedListener(); + startCardEditorCloseLiveSync(); + try { + const userId = typeof ApiClient !== 'undefined' && ApiClient.getCurrentUserId ? ApiClient.getCurrentUserId() : null; + if (userId) { + const raw = localStorage.getItem(`${userId}-blurhash`); + // Jellyfin enableBlurhash: default on when unset; only explicit 'false' disables + state.useBlurhash = raw !== 'false'; + } else { + state.useBlurhash = true; + } + } catch (e) { + state.useBlurhash = true; + } + } + + initialize(); + // Expose the cardBuilder to the global window object window.cardBuilder = cardBuilder; - + console.log('[KefinTweaks CardBuilder] Module loaded and available at window.cardBuilder'); })(); diff --git a/scripts/collections.js b/scripts/collections.js index 550d495..79ecb6f 100644 --- a/scripts/collections.js +++ b/scripts/collections.js @@ -595,6 +595,44 @@ activePage.dataset.collectionSortingAdded = 'true'; } + /** + * Update collection page by adding the played item count to the page + * @param {Object} item - Collection item (BoxSet) + */ + async function updateCollectionPage(item) { + const activePage = document.querySelector('.libraryPage:not(.hide)'); + if (!activePage) { + return; + } + + if (activePage.dataset.kefinCollectionPage === 'true') { + return; + } + + const unplayedItemCount = item.UserData?.UnplayedItemCount || 0; + const totalItemCount = item.RecursiveItemCount || item.ChildCount || 0; + const playedItemCount = totalItemCount - unplayedItemCount; + + const isVideoCollection = document.querySelector('.libraryPage:not(.hide) .collectionItems:has(.card[data-mediatype="Video"])') !== null; + const progressText = isVideoCollection ? 'watched' : 'complete'; + const incompleteText = isVideoCollection ? 'unwatched' : 'incomplete'; + + const itemMiscInfoPrimary = document.querySelector('.libraryPage:not(.hide) .itemMiscInfo-primary'); + if (itemMiscInfoPrimary && unplayedItemCount > 0) { + const mediaInfoItem = document.createElement('div'); + mediaInfoItem.className = 'mediaInfoItem'; + mediaInfoItem.textContent = `${playedItemCount} ${progressText}`; + itemMiscInfoPrimary.appendChild(mediaInfoItem); + + const mediaInfoItemIncomplete = document.createElement('div'); + mediaInfoItemIncomplete.className = 'mediaInfoItem'; + mediaInfoItemIncomplete.textContent = `${unplayedItemCount} ${incompleteText}`; + itemMiscInfoPrimary.appendChild(mediaInfoItemIncomplete); + } + + activePage.dataset.kefinCollectionPage = 'true'; + } + /** * Initialize collections hook */ @@ -618,8 +656,9 @@ // Small delay to ensure details DOM is ready setTimeout(async () => { - if (item && item.Id) { - await addCollectionSorting(item); + if (item && item.Id && item.Type === 'BoxSet') { + addCollectionSorting(item); + updateCollectionPage(item); } }, 100); }, diff --git a/scripts/configuration.css b/scripts/configuration.css index e6c4235..ce13b0d 100644 --- a/scripts/configuration.css +++ b/scripts/configuration.css @@ -1,184 +1,368 @@ .kefin-config-button { - -webkit-tap-highlight-color: transparent; - background-color: transparent; - cursor: pointer; - user-select: none; - vertical-align: middle; - appearance: none; - color: inherit; - display: flex; - -webkit-box-flex: 1; - flex-grow: 1; - -webkit-box-pack: start; - justify-content: flex-start; - -webkit-box-align: center; - align-items: center; - position: relative; - min-width: 0px; - box-sizing: border-box; - text-align: left; - padding-top: 8px; - padding-bottom: 8px; - padding-left: 16px; - padding-right: 16px; - outline: 0px; - border-width: 0px; - border-style: initial; - border-color: initial; - border-image: initial; - margin: 0px; - border-radius: 0px; - text-decoration: none; - transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); + -webkit-tap-highlight-color: transparent; + background-color: transparent; + cursor: pointer; + user-select: none; + vertical-align: middle; + appearance: none; + color: inherit; + display: flex; + -webkit-box-flex: 1; + flex-grow: 1; + -webkit-box-pack: start; + justify-content: flex-start; + -webkit-box-align: center; + align-items: center; + position: relative; + min-width: 0px; + box-sizing: border-box; + text-align: left; + padding-top: 8px; + padding-bottom: 8px; + padding-left: 16px; + padding-right: 16px; + outline: 0px; + border-width: 0px; + border-style: initial; + border-color: initial; + border-image: initial; + margin: 0px; + border-radius: 0px; + text-decoration: none; + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); } .kefin-config-button:hover { - background-color: var(--jf-palette-action-hover); + background-color: var(--jf-palette-action-hover); +} + +[data-kefintweaks-dashboard-config-button] span.material-icons.MuiSvgIcon-root { + display: block; + color: transparent; + background: var(--kefinTweaksLogo); + background-size: contain; + background-position: center; + background-repeat: no-repeat; + width: 100%; + height: 36px; +} + +[data-kefintweaks-user-menu-config-button] span.material-icons.MuiSvgIcon-root { + display: block; + color: transparent; + background: var(--kefinTweaksLogo); + background-size: contain; + background-position: center; + background-repeat: no-repeat; + width: 24px; + height: 24px; +} + +[data-kefintweaks-config-button] span.material-icons.listItemIcon { + display: block; + color: transparent; + background: var(--kefinTweaksLogo) !important; + background-size: contain !important; + background-position: center !important; + background-repeat: no-repeat !important; + width: 100%; + height: 36px; } .customMenuOptions { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; } .customMenuOptions > * { - order: 1; + order: 1; +} + +/* Generic toggle card / switch (shared across config modals) */ +.kefin-toggle-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75em; + padding: 0.85em 1em; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); +} + +.kefin-toggle-card-text { + flex: 1; + min-width: 0; +} + +.kefin-toggle-card-label { + font-weight: 500; +} + +.kefin-toggle-card-desc { + font-size: 0.8125rem; + margin-top: 0.25em; + line-height: 1.45; +} + +.kefin-toggle-switch { + position: relative; + width: 52px; + height: 28px; + border-radius: 14px; + border: none; + background: rgba(158, 158, 158, 0.5); + cursor: pointer; + flex-shrink: 0; + padding: 0; + transition: background-color 0.3s ease; +} + +.kefin-toggle-switch[data-enabled="true"] { + background: rgba(0, 164, 220, 0.8); +} + +.kefin-toggle-switch::after { + content: "close"; + font-family: "Material Icons"; + font-size: 14px; + line-height: 22px; + text-align: center; + color: rgba(0, 0, 0, 0.55); + position: absolute; + width: 22px; + height: 22px; + border-radius: 50%; + background: white; + top: 3px; + left: 3px; + transition: left 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.kefin-toggle-switch[data-enabled="true"]::after { + content: "check"; + left: calc(100% - 25px); } .navMenuOption[data-name="configure"] { - order: 9999; - margin-top: 3em !important; - position: relative; + order: 9999; + margin-top: 3em !important; + position: relative; } -.navMenuOption[data-name="configure"]>.material-icons { - position: relative; +.navMenuOption[data-name="configure"] > .material-icons { + position: relative; } -.navMenuOption[data-name="configure"]>.material-icons::after { - content: 'KefinTweaks'; - position: absolute; - top: -2.5em; - left: -1.25em; - font-family: 'Noto Sans'; - font-size: 14.88px; +.navMenuOption[data-name="configure"] > .material-icons::after { + content: "KefinTweaks"; + position: absolute; + top: -2.5em; + left: -1.25em; + font-family: "Noto Sans"; + font-size: 14.88px; } .listItemContent { - width: 100%; + width: 100%; } #kefinTweaksConfigModal .listItem { - align-items: start; - overflow: visible; + align-items: start; + overflow: visible; +} + +[dir="ltr"] + .kefin-config-wrapper + .listItem.feature-card:not(.actionSheetMenuItem, .childrenItemsContainer .listItem, .activityLogListWidget .listItem) { + padding: 0.75em !important; +} + +/* Only editable feature cards use the pointer; toggles/buttons keep pointer via their own rules */ +.kefin-config-wrapper .listItem.feature-card { + cursor: default; +} + +.kefin-config-wrapper .listItem.feature-card.feature-card--editable { + cursor: pointer; +} + +.kefin-config-wrapper .listItem.feature-card .checkboxContainer, +.kefin-config-wrapper .listItem.feature-card .emby-checkbox-label, +.kefin-config-wrapper .listItem.feature-card .emby-button { + cursor: pointer; } #customSections_list .listItem { - overflow: visible; + 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; + 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; + color: rgba(255, 255, 255, 0.87) !important; + background: transparent !important; } .autocomplete-suggestion:hover { - background: rgba(255,255,255,0.1) !important; + background: rgba(255, 255, 255, 0.1) !important; } -#kefinTweaksConfigModal .checkboxContainer, #kefinTweaksConfigModal .checkboxListContainer { - margin-bottom: 0 !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; + 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; + 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'; + 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); + opacity: 1; + transform: translate(-50%, 6px); } details[open] summary.listItemBodyText::after { - content: '▲'; + content: "▲"; } summary.listItemBodyText::after { - content: '▼'; - position: absolute; - bottom: 0px; - left: 0; - right: 0; - text-align: center; - font-size: 0.8rem; + content: "▼"; + position: absolute; + bottom: 0px; + left: 0; + right: 0; + text-align: center; + font-size: 0.8rem; } summary.listItemBodyText { - position: relative; + position: relative; } .kefin-config-button:hover { - background-color: rgba(255, 255, 255, 0.08); + background-color: rgba(255, 255, 255, 0.08); } .preview-section-container .custom-scroller-container { - max-width: calc(100% - 4rem); + max-width: calc(100% - 4rem); } .navMenuOption[data-name="configure"]:hover:not(.navMenuOption-selected) { - transform: none !important; - margin-top: 3em !important; + transform: none !important; + margin-top: 3em !important; } -div#homeScreen_recentlyReleased_container .listItem:has(#homeScreen_recentlyReleased_movies_sortOrder), +/* div#homeScreen_recentlyReleased_container .listItem:has(#homeScreen_recentlyReleased_movies_sortOrder), div#homeScreen_recentlyReleased_container .listItem:has(#homeScreen_recentlyReleased_episodes_sortOrder) { display: none; -} \ No newline at end of file +} */ +.emby-scroller-container[data-section-id].hideIndicators .cardIndicators { + display: none; +} + +.manage-config-tabs .manage-tab-btn.active { + background-color: rgba(82, 181, 255, 0.25); + outline: 1px solid rgba(82, 181, 255, 0.5); +} + +[data-modal-id="kefinTweaksManageModal"] [data-name="kefin-modal-content"] { + display: flex; + flex-direction: column; + min-height: 0; +} + +.manage-config-shell { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.manage-tab-panels { + position: relative; + flex: 1 1 auto; + min-height: 28rem; + height: 100%; +} + +.manage-tab-panel { + position: absolute; + inset: 0; + overflow: auto; + display: none; +} + +.manage-tab-panel.is-active { + display: block; +} + +.manage-io-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + box-sizing: border-box; +} + +.manage-io-textarea { + width: 100%; + flex: 1 1 auto; + min-height: 16rem; + font-family: monospace; + font-size: 0.9em; + line-height: 1.5; + resize: none; + box-sizing: border-box; +} diff --git a/scripts/configuration.js b/scripts/configuration.js index 1a47b9c..7594bee 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -22,9 +22,9 @@ { 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]' } + { key: 'starringActorRecentlyWatched', label: 'Starring [Person]', defaultName: 'Starring {Person}', extras: { caption: 'because you recently watched {Title}' } }, + { key: 'directedByDirectorRecentlyWatched', label: 'Directed by [Person]', defaultName: 'Directed by {Person}', extras: { caption: 'because you recently watched {Title}' } }, + { key: 'writtenByWriterRecentlyWatched', label: 'Written by [Person]', defaultName: 'Written by {Person}', extras: { caption: 'because you recently watched {Title}' } } ]; const SUPPORTED_CUSTOM_SECTION_PARAMS = { @@ -145,55 +145,67 @@ }); } + // Migrate legacy homeScreen config to new homeScreenConfig format + async function migrateLegacyHomeScreenConfig() { + return await window.migrateHomeScreenConfig(); + } + // Get KefinTweaks configuration from JS Injector or load defaults async function getKefinTweaksConfig() { try { + await migrateLegacyHomeScreenConfig(); // First, try to get config from JS Injector - const pluginId = await findJavaScriptInjectorPlugin(); +/* const pluginId = await findJavaScriptInjectorPlugin(); const injectorConfig = await getJavaScriptInjectorConfig(pluginId); // Find KefinTweaks-Config script const kefinTweaksScript = injectorConfig.CustomJavaScripts?.find( script => script.Name === 'KefinTweaks-Config' - ); + ); */ - if (kefinTweaksScript && kefinTweaksScript.Script) { +/* if (kefinTweaksScript && kefinTweaksScript.Script) { // Extract config from script content // The script should contain: window.KefinTweaksConfig = {...}; const scriptMatch = kefinTweaksScript.Script.match(/window\.KefinTweaksConfig\s*=\s*({[\s\S]*});/); if (scriptMatch && scriptMatch[1]) { try { - const config = JSON.parse(scriptMatch[1]); + let config = JSON.parse(scriptMatch[1]); // Store enabled state globally for use in modal (defaults to true if not set) window.KefinTweaksConfigEnabled = config.enabled !== false; console.log('[KefinTweaks Configuration] Loaded config from JS Injector:', config); + + // Run migration if needed + config = await migrateLegacyHomeScreenConfig(); + return config; } catch (parseError) { console.error('[KefinTweaks Configuration] Error parsing config from script:', parseError); } } - } + } */ // If no config found in JS Injector, load defaults - console.log('[KefinTweaks Configuration] No config found in JS Injector, loading defaults'); - const rawDefaultConfig = await loadDefaultConfig(); - const defaultConfig = JSON.parse(JSON.stringify(rawDefaultConfig)); +/* console.log('[KefinTweaks Configuration] No config found in JS Injector, loading defaults'); + const rawDefaultConfig = await loadDefaultConfig(); + const defaultConfig = JSON.parse(JSON.stringify(rawDefaultConfig)); - const currentConfigSource = currentLoadedConfig || window.KefinTweaksCurrentConfig || window.KefinTweaksConfig || {}; - const currentKefinRoot = currentConfigSource.kefinTweaksRoot || defaultConfig.kefinTweaksRoot; - defaultConfig.kefinTweaksRoot = currentKefinRoot; - // Ensure enabled field is set (defaults to true if not present) - if (defaultConfig.enabled === undefined) { - defaultConfig.enabled = true; - } - // Store enabled state globally for use in modal - window.KefinTweaksConfigEnabled = defaultConfig.enabled !== false; - // Note: scriptRoot is no longer used - scripts are loaded from kefinTweaksRoot + '/scripts/' + const currentConfigSource = currentLoadedConfig || window.KefinTweaksCurrentConfig || window.KefinTweaksConfig || {}; + const currentKefinRoot = currentConfigSource.kefinTweaksRoot || defaultConfig.kefinTweaksRoot; + defaultConfig.kefinTweaksRoot = currentKefinRoot; + // Ensure enabled field is set (defaults to true if not present) + if (defaultConfig.enabled === undefined) { + defaultConfig.enabled = true; + } + // Store enabled state globally for use in modal + window.KefinTweaksConfigEnabled = defaultConfig.enabled !== false; - // Save defaults to JS Injector for future use - await KefinTweaksUtils.saveConfigToJavaScriptInjector(defaultConfig); + // Run migration if needed + const migratedConfig = await migrateLegacyHomeScreenConfig(defaultConfig); - return defaultConfig; + // Save migrated config to JS Injector for future use + await saveConfigToJavaScriptInjector(migratedConfig); */ + + return window.KefinTweaksConfig; } catch (error) { console.error('[KefinTweaks Configuration] Error getting config, falling back to defaults:', error); // Fallback to defaults if anything fails @@ -206,7 +218,6 @@ kefinTweaksRoot: '', scripts: {}, homeScreen: {}, - exclusiveElsewhere: {}, search: {}, skins: [], defaultSkin: null, @@ -217,6 +228,432 @@ } } + // Import/Export Logic + async function showExportDialog(config) { + const customSections = config.homeScreen?.customSections || []; + const seasonalSeasons = config.homeScreen?.seasonal?.seasons || []; + + if (customSections.length === 0 && seasonalSeasons.length === 0) { + alert('No custom sections or seasonal configurations found to export.'); + return; + } + + const content = document.createElement('div'); + content.innerHTML = ` +
+
Select sections to export:
+ + ${customSections.length > 0 ? ` +
+
+

Custom Sections

+
+ + +
+
+
+ ${customSections.map((section, i) => ` + + `).join('')} +
+
` : ''} + + ${seasonalSeasons.length > 0 ? ` +
+
+

Seasonal Seasons

+
+ + +
+
+
+ ${seasonalSeasons.map((season, i) => ` + + `).join('')} +
+
` : ''} + + +
+ `; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.innerHTML = ` + + + `; + + window.ModalSystem.create({ + id: 'kefinTweaksExportModal', + title: 'Export Custom Sections', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modalInstance) => { + const generateBtn = modalInstance.dialogFooter.querySelector('#generateExportBtn'); + generateBtn.addEventListener('click', () => { + const selectedCustomIndices = Array.from(content.querySelectorAll('.export-custom-check:checked')).map(c => parseInt(c.dataset.index)); + const selectedSeasonalIndices = Array.from(content.querySelectorAll('.export-seasonal-check:checked')).map(c => parseInt(c.dataset.index)); + + if (selectedCustomIndices.length === 0 && selectedSeasonalIndices.length === 0) { + alert('Please select at least one item to export.'); + return; + } + + const exportData = { + meta: { + version: '1.0', + date: new Date().toISOString(), + source: 'KefinTweaks' + }, + customSections: selectedCustomIndices.map(i => customSections[i]), + seasonalSeasons: selectedSeasonalIndices.map(i => seasonalSeasons[i]) + }; + + const json = JSON.stringify(exportData, null, 2); + const outputContainer = content.querySelector('#exportOutputContainer'); + const output = content.querySelector('#exportOutput'); + + output.value = json; + outputContainer.style.display = 'block'; + generateBtn.style.display = 'none'; + + content.querySelector('#copyExportBtn').addEventListener('click', () => { + output.select(); + document.execCommand('copy'); + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Copied to clipboard'); + } + }); + + content.querySelector('#downloadExportBtn').addEventListener('click', () => { + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `kefin-tweaks-export-${new Date().toISOString().slice(0,10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }); + }); + } + }); + } + + + async function showCommunityImportDialog(config, saveConfigCallback) { + // Ensure community config is available + if (!window.KefinCommunityConfig) { + alert('Community configuration not loaded. Please ensure homeScreenConfig-community.js is loaded.'); + return; + } + + const communityCollections = window.KefinCommunityConfig.collections || []; + + const content = document.createElement('div'); + content.innerHTML = ` +
+
Select Home Screen Sections to import:
+
These sections are created and submitted by community members like you!
+
Submit your own Home Screen Sections here.
+ +
+ +
+ + + + +
+ `; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.innerHTML = ` + + + `; + + let selectedCollection = null; + + window.ModalSystem.create({ + id: 'kefinTweaksCommunityImportModal', + title: 'Import Community Sections', + content: content, + footer: footer, + closeOnBackdrop: false, + closeOnEscape: true, + onOpen: (modalInstance) => { + const select = content.querySelector('#communityCollectionSelect'); + const descContainer = content.querySelector('#collectionDescription'); + const selectionContainer = content.querySelector('#importSelectionContainer'); + const selectionList = content.querySelector('#importSelectionList'); + const confirmBtn = modalInstance.dialogFooter.querySelector('#confirmCommunityImportBtn'); + + select.addEventListener('change', () => { + const index = select.value; + if (index === '') { + selectedCollection = null; + descContainer.style.display = 'none'; + selectionContainer.style.display = 'none'; + confirmBtn.style.display = 'none'; + return; + } + + selectedCollection = communityCollections[parseInt(index)]; + + // Show description + descContainer.textContent = selectedCollection.description || 'No description.'; + descContainer.style.display = 'block'; + + // Render sections list + let html = ''; + if (selectedCollection.sections && selectedCollection.sections.length > 0) { + html += ` +
+ ${selectedCollection.sections.map((s, i) => ` + + `).join('')} +
`; + } else { + html = '
No sections found in this collection.
'; + } + + selectionList.innerHTML = html; + selectionContainer.style.display = 'block'; + confirmBtn.style.display = 'inline-block'; + }); + + confirmBtn.addEventListener('click', async () => { + if (!selectedCollection) return; + + const selectedIndices = Array.from(content.querySelectorAll('.import-community-check:checked')).map(c => parseInt(c.dataset.index)); + + if (selectedIndices.length === 0) { + alert('Please select at least one section to import.'); + return; + } + + // Import logic + if (!config.homeScreen) config.homeScreen = {}; + if (!config.homeScreen.customSections) config.homeScreen.customSections = []; + + let importedCount = 0; + selectedIndices.forEach(i => { + const section = JSON.parse(JSON.stringify(selectedCollection.sections[i])); // Deep copy + // Regenerate ID to avoid collision + if (section.id) section.id = `${section.id}_community_${Date.now()}_${Math.floor(Math.random()*1000)}`; + config.homeScreen.customSections.push(section); + importedCount++; + }); + + if (importedCount > 0) { + await saveConfigCallback(config); + alert(`Successfully imported ${importedCount} sections. The page will reload.`); + window.location.reload(); + } else { + window.ModalSystem.close('kefinTweaksCommunityImportModal'); + } + }); + } + }); + } + + async function showImportDialog(config, saveConfigCallback) { + const content = document.createElement('div'); + content.innerHTML = ` +
+
Paste JSON content or a URL (Raw GitHub, Gist, Pastebin):
+ + + +
+ `; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.innerHTML = ` + + + + `; + + let parsedData = null; + + window.ModalSystem.create({ + id: 'kefinTweaksImportModal', + title: 'Import Custom Sections', + content: content, + footer: footer, + closeOnBackdrop: false, + closeOnEscape: true, + onOpen: (modalInstance) => { + const loadBtn = modalInstance.dialogFooter.querySelector('#loadImportBtn'); + const confirmBtn = modalInstance.dialogFooter.querySelector('#confirmImportBtn'); + const input = content.querySelector('#importInput'); + const selectionContainer = content.querySelector('#importSelectionContainer'); + const selectionList = content.querySelector('#importSelectionList'); + + loadBtn.addEventListener('click', async () => { + const rawInput = input.value.trim(); + if (!rawInput) return; + + loadBtn.disabled = true; + loadBtn.textContent = 'Loading...'; + + try { + let jsonStr = rawInput; + // Check if URL + if (rawInput.startsWith('http://') || rawInput.startsWith('https://')) { + const res = await fetch(rawInput); + if (!res.ok) throw new Error(`Failed to fetch URL: ${res.status}`); + jsonStr = await res.text(); + } + + parsedData = JSON.parse(jsonStr); + if (!parsedData.customSections && !parsedData.seasonalSeasons && !Array.isArray(parsedData)) { + throw new Error('Invalid format: No sections found.'); + } + + // Handle legacy/array-only format if necessary (assume customSections) + if (Array.isArray(parsedData)) { + parsedData = { customSections: parsedData }; + } + + // Render selection + let html = ''; + if (parsedData.customSections && parsedData.customSections.length > 0) { + html += ` +
+

Custom Sections

+
+ ${parsedData.customSections.map((s, i) => ` + + `).join('')} +
+
`; + } + if (parsedData.seasonalSeasons && parsedData.seasonalSeasons.length > 0) { + html += ` +
+

Seasonal Seasons

+
+ ${parsedData.seasonalSeasons.map((s, i) => ` + + `).join('')} +
+
`; + } + + if (!html) throw new Error('No valid sections found in data.'); + + selectionList.innerHTML = html; + selectionContainer.style.display = 'block'; + loadBtn.style.display = 'none'; + confirmBtn.style.display = 'inline-block'; + input.disabled = true; + + } catch (err) { + alert('Error loading data: ' + err.message); + loadBtn.disabled = false; + loadBtn.textContent = 'Load'; + } + }); + + confirmBtn.addEventListener('click', async () => { + if (!parsedData) return; + + const selectedCustomIndices = Array.from(content.querySelectorAll('.import-custom-check:checked')).map(c => parseInt(c.dataset.index)); + const selectedSeasonalIndices = Array.from(content.querySelectorAll('.import-seasonal-check:checked')).map(c => parseInt(c.dataset.index)); + + // Import logic + if (!config.homeScreen) config.homeScreen = {}; + + let importedCount = 0; + + // Import Custom Sections + if (selectedCustomIndices.length > 0 && parsedData.customSections) { + if (!config.homeScreen.customSections) config.homeScreen.customSections = []; + + selectedCustomIndices.forEach(i => { + const section = parsedData.customSections[i]; + // Regenerate ID to avoid collision + if (section.id) section.id = `${section.id}_imported_${Date.now()}_${Math.floor(Math.random()*1000)}`; + config.homeScreen.customSections.push(section); + importedCount++; + }); + } + + // Import Seasonal Seasons + if (selectedSeasonalIndices.length > 0 && parsedData.seasonalSeasons) { + if (!config.homeScreen.seasonal) config.homeScreen.seasonal = {}; + if (!config.homeScreen.seasonal.seasons) config.homeScreen.seasonal.seasons = []; + + selectedSeasonalIndices.forEach(i => { + const season = parsedData.seasonalSeasons[i]; + // Regenerate IDs for internal sections if needed? + if (season.sections) { + season.sections.forEach(s => { + if (s.id) s.id = `${s.id}_imported_${Date.now()}_${Math.floor(Math.random()*1000)}`; + }); + } + config.homeScreen.seasonal.seasons.push(season); + importedCount++; + }); + } + + if (importedCount > 0) { + await saveConfigCallback(config); + alert(`Successfully imported ${importedCount} items. The page will reload.`); + window.location.reload(); + } else { + window.ModalSystem.close('kefinTweaksImportModal'); + } + }); + } + }); + } + // Inject responsive CSS for the configuration modal function injectConfigModalCSS() { const styleId = 'kefintweaks-config-modal-css'; @@ -225,7 +662,7 @@ const style = document.createElement('style'); style.id = styleId; style.textContent = ` - #${MODAL_ID} .modal-content-wrapper { + :not(.layout-mobile) #${MODAL_ID} .modal-content-wrapper { max-width: 90vw; width: 1400px; max-height: 90vh; @@ -434,48 +871,11 @@ // Build the configuration page content function buildConfigPageContent(config, libraries = []) { const scripts = config.scripts || {}; - const homeScreen = config.homeScreen || {}; - const exclusiveElsewhere = config.exclusiveElsewhere || {}; - const search = config.search || {}; - const flattenSingleSeasonShows = config.flattenSingleSeasonShows || {}; - const customMenuLinks = config.customMenuLinks || []; - // Reconstruct full skin list (enabled + disabled) for configuration - // We can't use window.KefinTweaksSkinConfig directly because it filters out disabled skins - const defaultSkins = window.KefinTweaksDefaultSkinsConfig?.skins || []; - const adminSkins = config.skins || []; - - const mergedSkins = []; - - // 1. Add all default skins first - defaultSkins.forEach(skin => { - mergedSkins.push({ ...skin, enabled: true }); - }); - - // 2. Override/Add with admin skins - adminSkins.forEach(adminSkin => { - const existingIndex = mergedSkins.findIndex(s => s.name === adminSkin.name); - if (existingIndex >= 0) { - // Override existing default skin - mergedSkins[existingIndex] = { ...mergedSkins[existingIndex], ...adminSkin }; - } else { - // Add new admin skin - mergedSkins.push({ ...adminSkin }); - } - }); - - const skins = mergedSkins; - const themes = config.themes || []; - // Get all skin names for autocomplete - const allSkinNames = skins.map(skin => skin.name).filter(Boolean); - - // Get skin source info for visual distinctions - const skinSources = window.KefinTweaksSkinManager?.getAllSkinSources?.() || {}; - - const isEnabled = window.KefinTweaksConfigEnabled !== false; + const isEnabled = window.KefinTweaksConfig?.enabled !== false; return ` -
+
-
+

Feature Configuration

@@ -501,211 +904,6 @@
-
-
-
-

Home Screen Configuration

-
Configure custom home screen sections and discovery features
-
-
-
- ${buildHomeScreenConfig(homeScreen, libraries)} -
-
- -
-
-
-

Exclusive Elsewhere Configuration

-
Configure exclusive elsewhere branding behavior
-
-
- ${buildExclusiveElsewhereConfig(exclusiveElsewhere)} -
- - - -
-
-
-

Episodes On Series Page Configuration

-
Configure episodes display on series pages
-
-
- ${buildFlattenShowsConfig(flattenSingleSeasonShows)} -
- -
-
-
-

Skin Configuration

-
Configure default skin and available skins for all users
-
-
-
-
-
Default Skin
- -
Select a default skin for all users
-
-
-
-
-

Enable/Disable Skins

-
Disabled skins will not appear in the appearance dropdowns for users
-
- All themes are created with love by community members like you. Don't forget to thank them for their work and time if you appreciate their creations. - KefinTweaks has not contributed to the creation of any of the skins below and is only attempting to facilitate their accessibility. -
-
- ${buildSkinToggles(skins, skinSources)} -
-
-
- ${(() => { - // Check if there are any overridden default skins - const hasOverriddenSkins = Object.values(skinSources).some(source => source === 'overridden'); - if (hasOverriddenSkins) { - return ` -
-
-
Some default skins have been overridden by custom configurations. Click the button below to remove custom skins that override defaults.
- -
-
- `; - } - return ''; - })()} -
-
-

Optional CSS Modules

-
Configure default enabled/disabled state for optional CSS modules. These settings will be used when users haven't specified their own preferences.
-
Select Skin
- -
- ${buildOptionalIncludesEditor('global', config, skins)} -
-
-
-
- - chevron_right - Skins JSON - -
-
-
Add additional skins that will be available to all users. Each skin can have multiple CSS files for different server versions. You can override the Default skins from KefinTweaks by specifying a custom configuration with that Skin Name in the configuration JSON below. Any skins that appear in this JSON and are named the same as the KefinTweaks default skins will override the default functionality. Default skins are not shown here as they are managed separately.
- -
- View Example Format -
[
-  {
-    "name": "Custom Skin",
-    "author": "username",
-    "url": [
-      {
-        "majorServerVersions": [10, 11],
-        "urls": ["https://cdn.jsdelivr.net/gh/username/custom-theme.css"]
-      }
-    ],
-    "colorSchemes": []
-  },
-  {
-    "name": "Custom Skin 2",
-    "author": "username 2",
-    "url": [
-      {
-        "majorServerVersions": [10],
-        "urls": ["https://cdn.jsdelivr.net/gh/username/custom-theme2.css"]
-      }
-    ],
-    "colorSchemes": []
-  }
-]
-
-
-
-
-
- -
-
-
-

Theme Configuration

-
Configure additional themes available to all users
-
-
-
-
-
Themes JSON
-
Add additional themes that will be available to all users. Each theme should have a name and URL pointing to a CSS file.
- -
- View Example Format -
[
-  {
-    "name": "Custom Dark Theme",
-    "url": "https://cdn.jsdelivr.net/gh/username/custom-dark-theme.css"
-  },
-  {
-    "name": "Custom Light Theme",
-    "url": "https://cdn.jsdelivr.net/gh/username/custom-light-theme.css"
-  }
-]
-
-
-
-
- - `; } @@ -975,6 +1173,9 @@ // Build script toggle switches function buildScriptToggles(scripts) { + // Features that have configuration options (not just toggles) + const featuresWithConfig = new Set(['homeScreen', 'search', 'flattenSingleSeasonShows', 'skinManager', 'customMenuLinks', 'seriesInfo', 'versionPreferences', 'thumbnailScrubber', 'watchTogether', 'watchlist', 'userManager']); + const scriptNames = [ { 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.' }, @@ -984,26 +1185,56 @@ { 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: 'hamburgerMenu', label: 'Desktop Hamburger Menu', desc: 'Shows an Open Menu button on desktop (900px+) that opens a left navigation drawer like the mobile layout, including libraries and custom menu links.' }, + { key: 'games', label: 'Games', desc: 'Add a Games link to the side menu for Movie LUT and other library games' }, { 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 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: 'Episodes On Series Page', desc: 'Displays episodes directly on series page. For single-season shows, shows all episodes. For multi-season shows, shows episodes from the season with Next Up episode (or Season 1 if no Next Up).' }, { key: 'seriesInfo', label: 'Series Info+', desc: 'Adds Season and Episode counts to the Series and Season pages. Also adds an "Ends at" time similar to the Episode page.' }, + { key: 'versionPreferences', label: 'Item Version Preferences', desc: 'Manage how different versions and editions of your items are displayed. Reorders and sanitizes the media source dropdown on item details pages by prioritized version and edition terms.' }, { 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: 'dashboardButtonFix', label: 'Dashboard Button Fix', desc: 'Improved back button behavior on dashboard - prevents navigating back to the new tab browser page' }, + { key: 'thumbnailScrubber', label: 'Thumbnail Scrubber', desc: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card similar to Youtube' }, + { key: 'watchTogether', label: 'Watch Together', desc: 'Link additional Jellyfin accounts to a Watch Together group, then sync watched / in-progress playstate to selected accounts on this device.' }, + { key: 'userManager', label: 'User Manager', desc: 'Create reusable user policy templates and apply them when adding users or bulk-editing existing accounts. Import/export comes later.' } ]; - return scriptNames.map(script => { - const isEnabled = scripts[script.key] !== false; // Default to true if not set + const defsByName = new Map( + (window.KefinTweaks?.getScripts?.() || []).map((def) => [def.name, def]) + ); + // Prefer cache warmed by await getJellyfinMajorVersion() before opening the modal + const major = window.KefinTweaks?._jellyfinMajorVersion ?? null; + + const visibleScripts = scriptNames.filter((script) => { + const def = defsByName.get(script.key); + if (!def?.versions?.length) return true; + if (major == null) return false; + return def.versions.includes(major); + }); + + return visibleScripts.map(script => { + // Match injector defaults: most scripts on unless set false; opt-in scripts require true + const defaultDisabled = script.key === 'thumbnailScrubber' || script.key === 'watchTogether' || script.key === 'userManager'; + const isEnabled = defaultDisabled + ? scripts[script.key] === true + : scripts[script.key] !== false; + const hasConfig = featuresWithConfig.has(script.key); + const configKey = script.key === 'flattenSingleSeasonShows' ? 'seriesEpisodes' : script.key; + return ` -
+
${script.label}
- ${buildJellyfinCheckbox(`script_${script.key}`, isEnabled, '', { 'data-script-key': script.key })} +
+ ${hasConfig ? `` : ''} + ${buildJellyfinCheckbox(`script_${script.key}`, isEnabled, '', { 'data-script-key': script.key })} +
${script.desc}
@@ -1012,6 +1243,54 @@ }).join(''); } + function openFeatureConfigurationModal(event, featureKey) { + // Check if the click target is the toggle switch or edit button + const target = event.target; + + // Check if clicking on the toggle switch (checkbox, label, or container) + const isCheckbox = target.closest('.checkboxContainer') || + target.closest('.emby-checkbox-label') || + target.closest('.emby-checkbox') || + target.getAttribute('is') === 'emby-checkbox' || + target.hasAttribute('data-embycheckbox'); + + // Don't open modal if clicking on toggle switch or edit button + if (isCheckbox) { + return; + } + + // Get the feature key from the card if not provided + if (!featureKey) { + const featureCard = event.target.closest('.feature-card'); + if (featureCard) { + featureKey = featureCard.getAttribute('data-feature-key'); + } + } + + if (!featureKey) { + console.warn('[KefinTweaks Configuration] Could not determine feature key'); + return; + } + + // Map feature key to config key (for flattenSingleSeasonShows -> seriesEpisodes) + const configKey = featureKey === 'flattenSingleSeasonShows' ? 'seriesEpisodes' : featureKey; + + // Open the appropriate configuration modal + if (window.KefinTweaksFeatureConfigs && window.KefinTweaksFeatureConfigs[configKey]) { + const configModule = window.KefinTweaksFeatureConfigs[configKey]; + if (configModule.openConfigModal) { + configModule.openConfigModal(); + } else { + console.warn(`[KefinTweaks Configuration] No openConfigModal function found for ${configKey}`); + } + } else { + console.warn(`[KefinTweaks Configuration] No configuration module found for ${configKey}`); + } + } + + // Expose function globally so it can be called from inline onclick handlers + window.openFeatureConfigurationModal = openFeatureConfigurationModal; + // Helper function to build Include Item Types badge field function buildIncludeItemTypesField(prefix, sectionIndex, sanitizedPrefix, currentTypes, sourceType) { // All available item types @@ -1427,6 +1706,9 @@ if (definition.extras?.minimumItems !== undefined) { defaults.minimumItems = definition.extras.minimumItems; } + if (definition.extras?.caption !== undefined) { + defaults.caption = definition.extras.caption; + } let normalized = { ...defaults }; @@ -1714,7 +1996,7 @@ recentlyAddedInLibraryHtml = libraries.map(library => { const libConfig = recentlyAddedInLibrary[library.Id] || {}; const prefix = `homeScreen_recentlyAddedInLibrary_${library.Id}`; - const defaultName = `Recently Added in ${library.Name}`; + const defaultName = `Recently Added ${library.Name}`; return `
@@ -1986,7 +2268,7 @@
-
Infinite Scroll / Load More Button
+
Infinite Scroll / Discover More
${buildJellyfinCheckbox('homeScreen_discovery_infiniteScroll', discovery.infiniteScroll !== false, 'Infinite Scroll')}
@@ -2085,6 +2367,28 @@
+ + +
+ + import_export + Import / Export Custom Sections + +
+
Share your custom home screen sections or import configurations from others.
+
+ + + +
+
+
`; } @@ -2106,8 +2410,8 @@ return `
-
Enable Jellyseerr
-
Enable Jellyseerr integration for request functionality
+
Enable Seerr results from JellyfinEnhanced
+
If you are using Seerr from Jellyfin Enhanced, you can enable this to add an additional search toggle for Requests
${buildJellyfinCheckbox('search_enableJellyseerr', search.enableJellyseerr === true, 'Enabled')}
@@ -2171,8 +2475,14 @@ currentLoadedConfig = config; window.KefinTweaksCurrentConfig = config; - // Fetch libraries for configuration - const libraries = await fetchLibraries(); + // Ensure Jellyfin major is cached so version-gated feature cards filter correctly + try { + if (typeof window.KefinTweaks?.getJellyfinMajorVersion === 'function') { + await window.KefinTweaks.getJellyfinMajorVersion(); + } + } catch (e) { + console.warn('[KefinTweaks Configuration] Could not resolve Jellyfin major version:', e); + } // Build the content (without title, as ModalSystem adds it) const content = document.createElement('div'); @@ -2180,31 +2490,19 @@ content.id = MODAL_ID; content.innerHTML = `
- ${buildConfigPageContent(config, libraries)} + ${buildConfigPageContent(config)}
`; - // Create footer with save, export, and import buttons + // Create footer with save (reset actions live in Manage) const footer = document.createElement('div'); footer.style.display = 'flex'; footer.style.gap = '0.75em'; footer.style.alignItems = 'center'; footer.innerHTML = ` - - - - - `; // Create modal using ModalSystem @@ -2221,14 +2519,10 @@ footer: footer, closeOnBackdrop: true, closeOnEscape: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '1400px', maxHeight: '90vh' } + : undefined, onOpen: (modalInstance) => { - // Update modal dialog styling for large size - if (modalInstance && modalInstance.dialog) { - modalInstance.dialog.style.maxWidth = '90vw'; - modalInstance.dialog.style.width = '1400px'; - modalInstance.dialog.style.maxHeight = '90vh'; - } - // Add event handlers setupConfigModalHandlers(modalInstance, config); } @@ -2383,6 +2677,34 @@ addCustomSection(); } }); + + // Export custom sections + modalInstance.dialogContent.addEventListener('click', (e) => { + const exportBtn = e.target.closest('.export-custom-sections-btn'); + if (exportBtn) { + showExportDialog(config); + } + }); + + // Import custom sections + modalInstance.dialogContent.addEventListener('click', (e) => { + const importBtn = e.target.closest('.import-custom-sections-btn'); + if (importBtn) { + showImportDialog(config, async (newConfig) => { + await KefinTweaksUtils.saveConfigToJavaScriptInjector(newConfig); + }); + } + }); + + // Import community sections + modalInstance.dialogContent.addEventListener('click', (e) => { + const importBtn = e.target.closest('.import-community-sections-btn'); + if (importBtn) { + showCommunityImportDialog(config, async (newConfig) => { + await KefinTweaksUtils.saveConfigToJavaScriptInjector(newConfig); + }); + } + }); // Delete section modalInstance.dialogContent.addEventListener('click', (e) => { @@ -3490,12 +3812,10 @@ } }); } - - // Hide suggestions when clicking outside - document.addEventListener('click', (e) => { - if (!badgeContainer.contains(e.target) && !suggestionsDiv.contains(e.target)) { - suggestionsDiv.style.display = 'none'; - } + + // Hide suggestions when focus leaves the input + input.addEventListener('blur', () => { + suggestionsDiv.style.display = 'none'; }); } @@ -3782,7 +4102,7 @@ const token = window.ApiClient.accessToken(); const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=BoxSet,CollectionFolder&Recursive=true&Fields=ItemCounts`, { - headers: { 'Authorization': apiHelper.getAuthHeader() } + headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { @@ -3902,7 +4222,7 @@ const token = window.ApiClient.accessToken(); const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ItemCounts`, { - headers: { 'Authorization': apiHelper.getAuthHeader() } + headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { @@ -4022,7 +4342,7 @@ const token = window.ApiClient.accessToken(); const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=BoxSet,CollectionFolder&Recursive=true&Fields=ItemCounts`, { - headers: { 'Authorization': apiHelper.getAuthHeader() } + headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { @@ -4160,7 +4480,7 @@ const token = window.ApiClient.accessToken(); const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ItemCounts`, { - headers: { 'Authorization': apiHelper.getAuthHeader() } + headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { @@ -4435,26 +4755,10 @@ }); } - const resetBtn = modalInstance.dialogFooter?.querySelector('#resetConfigBtn'); - if (resetBtn) { - resetBtn.addEventListener('click', () => handleResetConfig(modalInstance)); - } - - const resetAllUsersBtn = modalInstance.dialogFooter?.querySelector('#resetAllUsersBtn'); - if (resetAllUsersBtn) { - resetAllUsersBtn.addEventListener('click', () => handleResetAllUsersConfig(modalInstance)); - } - - // Export button handler - const exportBtn = modalInstance.dialogFooter?.querySelector('#exportConfigBtn'); - if (exportBtn) { - exportBtn.addEventListener('click', () => handleExportConfig(config)); - } - - // Import button handler - const importBtn = modalInstance.dialogFooter?.querySelector('#importConfigBtn'); - if (importBtn) { - importBtn.addEventListener('click', () => handleImportConfig(modalInstance)); + // Manage button handler (Backups / Export / Import / Resets) + const manageBtn = modalInstance.dialog?.querySelector('#manageConfigBtn'); + if (manageBtn) { + manageBtn.addEventListener('click', () => openManageConfigModal(config, modalInstance)); } // Remove duplicate defaults button handler @@ -4505,24 +4809,760 @@ } } - // Export configuration to clipboard + function toastMsg(message, timeout) { + if (window.KefinTweaksToaster?.toast) { + window.KefinTweaksToaster.toast(message, timeout); + } else { + alert(message); + } + } + + function getExportClipboardFlags() { + const isHttps = + (typeof ApiClient !== 'undefined' && + ApiClient._serverAddress && + ApiClient._serverAddress.startsWith('https://')) || + (typeof location !== 'undefined' && location.protocol === 'https:'); + const supportsClipboard = !!(navigator.clipboard && navigator.clipboard.writeText); + return { canUseClipboard: isHttps && supportsClipboard }; + } + + function buildManageExportPanelHtml(canUseClipboard) { + return ` +
+
+ Your current configuration is shown below as JSON. Select and copy this text to back up or share your configuration. + ${canUseClipboard ? '' : ' Clipboard access requires an HTTPS connection, so manual copying is required.'} +
+ + ${canUseClipboard ? ` +
+ +
` : ''} +
+ `; + } + + function buildManageImportPanelHtml() { + return ` +
+
+ Paste your configuration JSON below. This will completely replace your current configuration. +
+ +
+ +
+
+ `; + } + + function backupsSettingsDescription(scheduledEnabled, keepCount) { + const x = Math.max(1, Math.floor(Number(keepCount) || 5)); + if (scheduledEnabled) { + return `KefinTweaks will backup your configuration once per day and keep up to ${x} previous scheduled backups. Backups are only created from an admin account that is logged in.`; + } + return 'KefinTweaks will never automatically backup your configuration unless necessary during a migration task or other operations.'; + } + + function buildManageBackupsPanelHtml(settings) { + const scheduledEnabled = settings.scheduledEnabled === true; + const keepCount = Math.max(1, Math.floor(Number(settings.keepCount) || 5)); + const toggleHtml = window.KefinTweaksUI?.buildToggleCard + ? window.KefinTweaksUI.buildToggleCard( + 'manageScheduledBackups', + scheduledEnabled, + 'Scheduled Backups', + backupsSettingsDescription(scheduledEnabled, keepCount) + ) + : ` + +
${backupsSettingsDescription(scheduledEnabled, keepCount)}
+ `; + + return ` +
+
${toggleHtml}
+
+ + +
KefinTweaks will overwrite the oldest scheduled Backup once you get to this limit.
+
+
+

Manage Backups

+
+
Loading backups…
+
+
+
+ `; + } + + function renderBackupsTableRows(backups) { + if (!backups.length) { + return `
No backups found.
`; + } + const rows = backups.map((b) => ` + + + + + +
${escapeHtmlAttr(b.name)}
+
+ ${escapeHtmlAttr(b.kindLabel || 'Manual')} · ${escapeHtmlAttr(b.dateLabel || 'Unknown date')} +
+ + + + + + + `).join(''); + return ` +
+ + + + +
+
+ + + + + + + + + ${rows} +
+ + BackupActions
+
+ `; + } + + function escapeHtmlAttr(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + async function refreshManageBackupsTable(host) { + if (!host) return; + host.innerHTML = `
Loading backups…
`; + const listFn = window.KefinTweaksUtils?.listKefinConfigBackups; + if (!listFn) { + host.innerHTML = `
Backup helpers unavailable.
`; + return; + } + const backups = await listFn(); + host.innerHTML = renderBackupsTableRows(backups); + } + + function confirmDeleteBackup(name) { + return confirmDeleteBackups([name]); + } + + function confirmCreateBackup() { + return new Promise((resolve) => { + if (!window.ModalSystem) { + const label = window.prompt('Optional backup name (leave blank for default):', '') || ''; + resolve(window.confirm('Create a configuration backup now?') + ? { create: true, label: String(label).trim() } + : { create: false }); + return; + } + window.ModalSystem.create({ + id: 'manageCreateBackupConfirm', + title: 'Create Backup', + content: ` +
+ Create a backup of your current KefinTweaks configuration? +
+ + +
+ When set, the backup is named KefinTweaks-Config-Backup-[Name]-Timestamp. Leave blank for the default name. +
+ `, + footer: ` + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (mi) => { + const input = mi.dialogContent?.querySelector('#manageCreateBackupName'); + setTimeout(() => input?.focus(), 100); + mi.dialogFooter?.querySelector('#manageCreateBackupConfirmBtn')?.addEventListener('click', () => { + const label = (input?.value || '').trim(); + mi.close(); + resolve({ create: true, label }); + }); + mi.dialogFooter?.querySelector('#manageCreateBackupCancelBtn')?.addEventListener('click', () => { + mi.close(); + resolve({ create: false }); + }); + } + }); + }); + } + + function confirmDeleteBackups(names) { + const list = (Array.isArray(names) ? names : []).filter(Boolean); + return new Promise((resolve) => { + if (!list.length) { + resolve(false); + return; + } + const title = list.length === 1 ? 'Delete Backup' : `Delete ${list.length} Backups`; + const body = list.length === 1 + ? `
Remove backup ${escapeHtmlAttr(list[0])}? This cannot be undone.
` + : `
Remove ${list.length} selected backups? This cannot be undone.
+
    + ${list.map((n) => `
  • ${escapeHtmlAttr(n)}
  • `).join('')} +
`; + + if (!window.ModalSystem) { + resolve(window.confirm(list.length === 1 + ? `Delete backup "${list[0]}"?` + : `Delete ${list.length} selected backups?`)); + return; + } + window.ModalSystem.create({ + id: 'manageDeleteBackupConfirm', + title, + content: body, + footer: ` + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (mi) => { + mi.dialogFooter?.querySelector('#manageDeleteBackupConfirmBtn')?.addEventListener('click', () => { + mi.close(); + resolve(true); + }); + mi.dialogFooter?.querySelector('#manageDeleteBackupCancelBtn')?.addEventListener('click', () => { + mi.close(); + resolve(false); + }); + } + }); + }); + } + + function confirmRestoreBackup(name) { + return new Promise((resolve) => { + if (!window.ModalSystem) { + const backupFirst = window.confirm(`Backup current config before restoring "${name}"?`); + resolve(window.confirm(`Restore "${name}"?`) ? { restore: true, backupCurrentFirst: backupFirst } : { restore: false }); + return; + } + window.ModalSystem.create({ + id: 'manageRestoreBackupConfirm', + title: 'Restore Backup', + content: ` +
+ Restore configuration from ${escapeHtmlAttr(name)}? This will overwrite your current active configuration. +
+ `, + footer: ` + + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (mi) => { + mi.dialogFooter?.querySelector('#manageRestoreWithBackupBtn')?.addEventListener('click', () => { + mi.close(); + resolve({ restore: true, backupCurrentFirst: true }); + }); + mi.dialogFooter?.querySelector('#manageRestoreOnlyBtn')?.addEventListener('click', () => { + mi.close(); + resolve({ restore: true, backupCurrentFirst: false }); + }); + mi.dialogFooter?.querySelector('#manageRestoreCancelBtn')?.addEventListener('click', () => { + mi.close(); + resolve({ restore: false }); + }); + } + }); + }); + } + + async function persistBackupsSettings(config, settings) { + config.backups = { + scheduledEnabled: settings.scheduledEnabled === true, + keepCount: Math.max(1, Math.floor(Number(settings.keepCount) || 5)), + lastScheduledAt: settings.lastScheduledAt || config.backups?.lastScheduledAt || null + }; + if (window.KefinTweaksConfig) { + window.KefinTweaksConfig.backups = { ...config.backups }; + } + await KefinTweaksUtils.saveConfigToJavaScriptInjector(config); + if (config.backups.scheduledEnabled) { + await window.KefinTweaksUtils.maybeRunScheduledBackup?.(config); + } + } + + function bindManageBackupsPanel(root, config) { + const settings = window.KefinTweaksUtils?.normalizeBackupsSettings + ? window.KefinTweaksUtils.normalizeBackupsSettings(config) + : { scheduledEnabled: false, keepCount: 5, lastScheduledAt: null }; + + const scheduledCb = root.querySelector('#manageScheduledBackups'); + const keepInput = root.querySelector('#manageBackupKeepCount'); + const descEl = root.querySelector('#manageScheduledBackupsDesc') + || root.querySelector('.kefin-toggle-card-desc'); + const tableHost = root.querySelector('#manageBackupsTableHost'); + + if (window.KefinTweaksUI?.bindToggleCards) { + window.KefinTweaksUI.bindToggleCards(root); + } + + const syncDesc = () => { + if (!descEl) return; + const enabled = scheduledCb?.checked === true; + const keep = Math.max(1, Math.floor(Number(keepInput?.value) || 5)); + descEl.textContent = backupsSettingsDescription(enabled, keep); + }; + + scheduledCb?.addEventListener('change', async () => { + syncDesc(); + try { + await persistBackupsSettings(config, { + ...settings, + scheduledEnabled: scheduledCb.checked === true, + keepCount: Math.max(1, Math.floor(Number(keepInput?.value) || 5)) + }); + toastMsg(`Scheduled backups ${scheduledCb.checked ? 'enabled' : 'disabled'}`); + await refreshManageBackupsTable(tableHost); + } catch (e) { + console.error('[KefinTweaks Configuration] Error saving backup settings:', e); + toastMsg('Failed to save backup settings', '5'); + } + }); + + keepInput?.addEventListener('change', async () => { + const keep = Math.max(1, Math.floor(Number(keepInput.value) || 5)); + keepInput.value = String(keep); + syncDesc(); + try { + await persistBackupsSettings(config, { + ...settings, + scheduledEnabled: scheduledCb?.checked === true, + keepCount: keep + }); + await window.KefinTweaksUtils.pruneKefinConfigBackups?.(keep); + await refreshManageBackupsTable(tableHost); + toastMsg(`Keeping up to ${keep} backups`); + } catch (e) { + console.error('[KefinTweaks Configuration] Error saving keep count:', e); + toastMsg('Failed to save backup keep count', '5'); + } + }); + + tableHost?.addEventListener('click', async (e) => { + const createBtn = e.target.closest?.('#manageBackupsCreateBtn'); + const selectAllBtn = e.target.closest?.('#manageBackupsSelectAllBtn'); + const deselectAllBtn = e.target.closest?.('#manageBackupsDeselectAllBtn'); + const deleteSelectedBtn = e.target.closest?.('#manageBackupsDeleteSelectedBtn'); + + const syncSelectionUi = () => { + const selected = tableHost.querySelectorAll('.manage-backup-row-check:checked'); + const all = tableHost.querySelectorAll('.manage-backup-row-check'); + const btn = tableHost.querySelector('#manageBackupsDeleteSelectedBtn'); + if (btn) btn.disabled = selected.length === 0; + const header = tableHost.querySelector('#manageBackupsSelectAllHeader'); + if (header && all.length) { + header.checked = selected.length === all.length; + header.indeterminate = selected.length > 0 && selected.length < all.length; + } + }; + + const setAllChecked = (checked) => { + tableHost.querySelectorAll('.manage-backup-row-check').forEach((cb) => { + cb.checked = checked; + }); + const header = tableHost.querySelector('#manageBackupsSelectAllHeader'); + if (header) { + header.checked = checked; + header.indeterminate = false; + } + syncSelectionUi(); + }; + + if (createBtn) { + const choice = await confirmCreateBackup(); + if (!choice?.create) return; + const result = await window.KefinTweaksUtils?.createKefinConfigBackup?.(config, { + reason: 'manual', + kind: 'manual', + label: choice.label || '' + }); + if (result?.ok) { + toastMsg(`Backup created: ${result.name}`); + await refreshManageBackupsTable(tableHost); + } else { + toastMsg('Failed to create backup', '5'); + } + return; + } + if (selectAllBtn) { + setAllChecked(true); + return; + } + if (deselectAllBtn) { + setAllChecked(false); + return; + } + if (deleteSelectedBtn) { + const names = Array.from(tableHost.querySelectorAll('.manage-backup-row-check:checked')) + .map((cb) => cb.getAttribute('data-backup-name')) + .filter(Boolean); + if (!names.length) return; + const confirmed = await confirmDeleteBackups(names); + if (!confirmed) return; + const result = await window.KefinTweaksUtils.deleteKefinConfigBackups?.(names); + if (result?.ok) { + toastMsg(`Deleted ${result.removed} backup${result.removed === 1 ? '' : 's'}`); + await refreshManageBackupsTable(tableHost); + } else { + toastMsg('Failed to delete selected backups', '5'); + } + return; + } + + // Ignore row-checkbox clicks here (handled by change); don't treat as row actions + if (e.target?.classList?.contains('manage-backup-row-check') + || e.target?.id === 'manageBackupsSelectAllHeader') { + return; + } + + const restoreBtn = e.target.closest?.('.manage-backup-restore-btn'); + const deleteBtn = e.target.closest?.('.manage-backup-delete-btn'); + const row = e.target.closest?.('tr[data-backup-name]'); + const name = row?.getAttribute('data-backup-name'); + if (!name) return; + + if (restoreBtn) { + const choice = await confirmRestoreBackup(name); + if (!choice.restore) return; + const ok = await window.KefinTweaksUtils.restoreKefinConfigBackup?.(name, { + backupCurrentFirst: choice.backupCurrentFirst === true + }); + if (ok) { + toastMsg('Configuration restored. Reloading…'); + setTimeout(() => window.location.reload(), 600); + } else { + toastMsg('Failed to restore backup', '5'); + } + return; + } + + if (deleteBtn) { + const confirmed = await confirmDeleteBackup(name); + if (!confirmed) return; + const result = await window.KefinTweaksUtils.deleteKefinConfigBackups?.([name]); + if (result?.ok) { + toastMsg('Backup deleted'); + await refreshManageBackupsTable(tableHost); + } else { + toastMsg('Failed to delete backup', '5'); + } + } + }); + + tableHost?.addEventListener('change', (e) => { + const target = e.target; + if (!target) return; + + if (target.id === 'manageBackupsSelectAllHeader') { + const checked = target.checked === true; + tableHost.querySelectorAll('.manage-backup-row-check').forEach((cb) => { + cb.checked = checked; + }); + target.indeterminate = false; + } else if (!target.classList?.contains('manage-backup-row-check')) { + return; + } + + const selected = tableHost.querySelectorAll('.manage-backup-row-check:checked'); + const all = tableHost.querySelectorAll('.manage-backup-row-check'); + const btn = tableHost.querySelector('#manageBackupsDeleteSelectedBtn'); + if (btn) btn.disabled = selected.length === 0; + const header = tableHost.querySelector('#manageBackupsSelectAllHeader'); + if (header && all.length && target.id !== 'manageBackupsSelectAllHeader') { + header.checked = selected.length === all.length; + header.indeterminate = selected.length > 0 && selected.length < all.length; + } + }); + + refreshManageBackupsTable(tableHost); + } + + function openManageConfigModal(config, parentModalInstance) { + if (!window.ModalSystem) { + alert('Modal system not available'); + return; + } + + const { canUseClipboard } = getExportClipboardFlags(); + const settings = window.KefinTweaksUtils?.normalizeBackupsSettings + ? window.KefinTweaksUtils.normalizeBackupsSettings(config) + : { scheduledEnabled: false, keepCount: 5, lastScheduledAt: null }; + const configJson = JSON.stringify(config, null, 2); + + window.ModalSystem.create({ + id: 'kefinTweaksManageModal', + title: 'Manage', + dialogStyle: { + width: 'min(720px, 95vw)', + height: 'min(780px, 90vh)', + maxHeight: '90vh' + }, + content: ` +
+
+ + + + + +
+
+
+ ${buildManageBackupsPanelHtml(settings)} +
+
+ ${buildManageExportPanelHtml(canUseClipboard)} +
+
+ ${buildManageImportPanelHtml()} +
+
+
+ `, + footer: ` + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (manageModal) => { + const content = manageModal.dialogContent; + manageModal.dialogFooter?.querySelector('#closeManageConfigBtn')?.addEventListener('click', () => manageModal.close()); + + const setTab = (tab) => { + content.querySelectorAll('.manage-tab-btn').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.manageTab === tab); + }); + content.querySelectorAll('.manage-tab-panel').forEach((panel) => { + panel.classList.toggle('is-active', panel.dataset.managePanel === tab); + }); + }; + content.querySelectorAll('.manage-tab-btn').forEach((btn) => { + btn.addEventListener('click', () => setTab(btn.dataset.manageTab)); + }); + + content.querySelector('#manageRestoreDefaultsBtn')?.addEventListener('click', async () => { + const didReset = await handleResetConfig(parentModalInstance || manageModal); + if (didReset) { + manageModal.close(); + } + }); + content.querySelector('#manageResetAllUsersBtn')?.addEventListener('click', () => { + handleResetAllUsersConfig(); + }); + + // Export panel + const exportTa = content.querySelector('#manageExportConfigTextarea'); + if (exportTa) { + exportTa.value = configJson; + setTimeout(() => { + exportTa.focus(); + exportTa.select(); + }, 100); + } + content.querySelector('#manageCopyExportConfigBtn')?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(configJson); + toastMsg('Configuration copied to clipboard!'); + } catch (copyError) { + console.error('[KefinTweaks Configuration] Error copying config:', copyError); + toastMsg('Error copying configuration to clipboard', '5'); + } + }); + + // Import panel + content.querySelector('#manageConfirmImportBtn')?.addEventListener('click', async () => { + const jsonText = content.querySelector('#manageImportConfigTextarea')?.value.trim() || ''; + if (!jsonText) { + toastMsg('Please paste configuration JSON', '3'); + return; + } + let importedConfig; + try { + importedConfig = JSON.parse(jsonText); + } catch (parseError) { + toastMsg('Invalid JSON format', '5'); + return; + } + if (!importedConfig || typeof importedConfig !== 'object' || Array.isArray(importedConfig)) { + toastMsg('Configuration must be a JSON object', '5'); + return; + } + const confirmed = await showImportConfirmation(); + if (!confirmed) return; + manageModal.close(); + await applyImportedConfig(importedConfig, parentModalInstance || manageModal); + }); + + bindManageBackupsPanel(content.querySelector('[data-manage-panel="backups"]') || content, config); + } + }); + } + + // Export configuration via modal (with optional clipboard copy on HTTPS) async function handleExportConfig(config) { try { const configJson = JSON.stringify(config, null, 2); - await navigator.clipboard.writeText(configJson); - - if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { - window.KefinTweaksToaster.toast('Configuration exported to clipboard!'); + + // Prefer modal-based export so it works even on unsecured servers + if (window.ModalSystem) { + const { canUseClipboard } = getExportClipboardFlags(); + + window.ModalSystem.create({ + id: 'exportConfigModal', + title: 'Export Configuration', + content: ` +
+ Your current configuration is shown below as JSON. Select and copy this text to back up or share your configuration. + ${canUseClipboard ? '' : ' Clipboard access requires an HTTPS connection, so manual copying is required.'} +
+ + `, + footer: ` + ${ + canUseClipboard + ? ` + + ` + : '' + } + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modalInstance) => { + const textarea = modalInstance.dialogContent.querySelector('#exportConfigTextarea'); + if (textarea) { + textarea.value = configJson; + // Select text to make manual copying easier + setTimeout(() => { + textarea.focus(); + textarea.select(); + }, 100); + } + + const closeBtn = modalInstance.dialogFooter?.querySelector('#closeExportConfigBtn'); + if (closeBtn) { + closeBtn.addEventListener('click', () => modalInstance.close()); + } + + if (canUseClipboard) { + const copyBtn = modalInstance.dialogFooter?.querySelector('#copyExportConfigBtn'); + if (copyBtn) { + copyBtn.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(configJson); + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Configuration copied to clipboard!'); + } else { + alert('Configuration copied to clipboard!'); + } + } catch (copyError) { + console.error( + '[KefinTweaks Configuration] Error copying config to clipboard:', + copyError + ); + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast( + 'Error copying configuration to clipboard', + '5' + ); + } else { + alert('Error copying configuration to clipboard'); + } + } + }); + } + } + } + }); + } else if (navigator.clipboard && navigator.clipboard.writeText) { + // Fallback: no modal system, but clipboard is available + await navigator.clipboard.writeText(configJson); + + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Configuration exported to clipboard!'); + } else { + alert('Configuration exported to clipboard!'); + } } else { - alert('Configuration exported to clipboard!'); + // Last-resort fallback: log to console + console.log('[KefinTweaks Configuration] Exported configuration:', configJson); + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast( + 'Unable to open export modal or access clipboard; configuration logged to console', + '3' + ); + } else { + alert( + 'Unable to open export modal or access clipboard. Check the browser console for the exported configuration.' + ); + } } } catch (error) { console.error('[KefinTweaks Configuration] Error exporting config:', error); - if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { - window.KefinTweaksToaster.toast('Error exporting configuration to clipboard', '5'); + window.KefinTweaksToaster.toast('Error exporting configuration', '5'); } else { - alert('Error exporting configuration to clipboard'); + alert('Error exporting configuration'); } } } @@ -4683,73 +5723,55 @@ // Apply imported configuration async function applyImportedConfig(importedConfig, modalInstance) { - // Save to JavaScript Injector + // Update in-memory config before reopen (getKefinTweaksConfig reads window.KefinTweaksConfig) + window.KefinTweaksConfig = importedConfig; + window.KefinTweaksCurrentConfig = importedConfig; + currentLoadedConfig = importedConfig; + window.KefinTweaksConfigEnabled = importedConfig.enabled !== false; + await KefinTweaksUtils.saveConfigToJavaScriptInjector(importedConfig); - - // Reload the modal with new config + modalInstance.close(); - - // Show success message + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('Configuration imported successfully! Reloading...'); } - - // Reopen modal with new config after a short delay + setTimeout(() => { openConfigurationModal(); }, 500); } - // Find JavaScript Injector plugin + const JS_INJECTOR_ALIASES = ['JavaScript Injector', 'JS Injector']; + + // Find JavaScript Injector plugin (delegates to shared utils cache) async function findJavaScriptInjectorPlugin() { try { - if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { - throw new Error('ApiClient not available'); + if (!window.KefinTweaksUtils?.resolvePluginId) { + throw new Error('KefinTweaksUtils.resolvePluginId is not available'); } - const plugins = await apiHelper.getPlugins(); - console.log('[KefinTweaks Configuration] Active Plugins:', plugins); - - // Handle both array and object with Items property - const pluginsList = Array.isArray(plugins) ? plugins : (plugins.Items || []); - - // Find JavaScript Injector plugin - const injectorPlugin = pluginsList.find(plugin => - plugin.Name === 'JavaScript Injector' || plugin.Name === 'JS Injector' - ); - - if (!injectorPlugin) { + const pluginId = await window.KefinTweaksUtils.resolvePluginId(JS_INJECTOR_ALIASES); + if (!pluginId) { throw new Error('JavaScript Injector plugin not found. Please ensure it is installed.'); } - console.log('[KefinTweaks Configuration] Found JavaScript Injector plugin:', injectorPlugin); - return injectorPlugin.Id; + console.log('[KefinTweaks Configuration] Found JavaScript Injector plugin:', pluginId); + return pluginId; } catch (error) { console.error('[KefinTweaks Configuration] Error finding JavaScript Injector plugin:', error); throw error; } } - // Get current JavaScript Injector configuration + // Get current JavaScript Injector configuration (delegates to shared utils) async function getJavaScriptInjectorConfig(pluginId) { try { - if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { - throw new Error('ApiClient not available'); + if (!window.KefinTweaksUtils?.getPluginConfiguration) { + throw new Error('KefinTweaksUtils.getPluginConfiguration is not available'); } - const server = ApiClient._serverAddress; - - const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { - headers: { - 'Authorization': apiHelper.getAuthHeader() - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const config = await response.json(); + const config = await window.KefinTweaksUtils.getPluginConfiguration(pluginId || JS_INJECTOR_ALIASES); console.log('[KefinTweaks Configuration] Current JS Injector config:', config); return config; } catch (error) { @@ -4758,6 +5780,34 @@ } } + // Save configuration to JavaScript Injector plugin (delegates to shared utils) + async function saveConfigToJavaScriptInjector(config) { + try { + if (!window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) { + throw new Error('KefinTweaksUtils.saveConfigToJavaScriptInjector is not available'); + } + + const success = await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(config); + if (!success) { + throw new Error('Failed to save configuration to JavaScript Injector'); + } + + console.log('[KefinTweaks Configuration] Configuration saved successfully'); + return true; + } catch (error) { + console.error('[KefinTweaks Configuration] Error saving configuration:', error); + throw error; + } + } + + // Expose injector helpers for feature-config fallbacks + window.KefinTweaksConfiguration = Object.assign(window.KefinTweaksConfiguration || {}, { + findJavaScriptInjectorPlugin, + getJavaScriptInjectorConfig, + saveConfigToJavaScriptInjector, + openConfigurationModal + }); + function getSectionConfigFromUI(rootElement, prefix, sectionIndex) { const scope = rootElement || document; const sectionItem = scope.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`); @@ -4988,18 +6038,8 @@ // Collect all form values // Preserve existing kefinTweaksRoot from current config (no longer editable in UI) - const currentConfig = window.KefinTweaksCurrentConfig || {}; - const config = { - kefinTweaksRoot: currentConfig.kefinTweaksRoot || '', - scripts: {}, - homeScreen: {}, - exclusiveElsewhere: {}, - search: {}, - defaultSkin: document.getElementById('defaultSkin')?.value || null, - skins: [], - themes: [], - customMenuLinks: [] - }; + const currentConfig = window.KefinTweaksCurrentConfig || window.KefinTweaksConfig || {}; + const config = currentConfig; config.enabled = modalInstance.dialogContent.querySelector('#kefinTweaksEnabled')?.checked !== false; // Remove scriptRoot if it exists (legacy field, no longer used) @@ -5013,312 +6053,6 @@ 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); - } - - // Check for IsPlayed configuration - const isPlayedEnabledCheckbox = document.getElementById(`${prefix}_isPlayed_enabled`); - if (isPlayedEnabledCheckbox) { - if (isPlayedEnabledCheckbox.checked) { - const isPlayedValueSelect = document.getElementById(`${prefix}_isPlayed_value`); - config.isPlayed = isPlayedValueSelect?.value === 'true'; - } else { - config.isPlayed = null; - } - } - - // Handle Min/Max Age In Days for Recently Released - if (prefix === 'homeScreen_recentlyReleased_movies') { - const minAge = document.getElementById(`${prefix}_minAgeInDays`)?.value; - const maxAge = document.getElementById(`${prefix}_maxAgeInDays`)?.value; - config.minAgeInDays = minAge !== '' ? parseInt(minAge, 10) : null; - config.maxAgeInDays = maxAge !== '' ? parseInt(maxAge, 10) : null; - } - if (prefix === 'homeScreen_recentlyReleased_episodes') { - const minAge = document.getElementById(`${prefix}_minAgeInDays`)?.value; - const maxAge = document.getElementById(`${prefix}_maxAgeInDays`)?.value; - config.minAgeInDays = minAge !== '' ? parseInt(minAge, 10) : null; - config.maxAgeInDays = maxAge !== '' ? parseInt(maxAge, 10) : null; - } - - 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 = { - 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: true, includeOrder: true, includeIsPlayed: true, includePremiereDays: true }) - }, - episodes: { - enabled: document.getElementById('homeScreen_recentlyReleased_episodes_enabled')?.checked !== false, - ...getSectionConfig('homeScreen_recentlyReleased_episodes', { includeName: true, defaultName: 'Recently Aired Episodes', includeSortOrder: true, includeOrder: true, includeIsPlayed: true, includePremiereDays: true }) - } - }, - recentlyAddedInLibrary: (() => { - const libraryConfigs = {}; - const libraryCheckboxes = modalInstance.dialogContent.querySelectorAll('.recently-added-library-enabled'); - libraryCheckboxes.forEach(checkbox => { - const libraryId = checkbox.getAttribute('data-library-id'); - if (libraryId) { - const prefix = `homeScreen_recentlyAddedInLibrary_${libraryId}`; - libraryConfigs[libraryId] = { - enabled: checkbox.checked !== false, - ...getSectionConfig(prefix, { - includeName: true, - defaultName: '', - includeSortOrder: false, - includeOrder: true - }) - }; - } - }); - return libraryConfigs; - })(), - 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, - enableSeasonalAnimations: document.getElementById('homeScreen_seasonal_enableSeasonalAnimations')?.checked !== false, - enableSeasonalBackground: document.getElementById('homeScreen_seasonal_enableSeasonalBackground')?.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'), - renderSpotlightAboveMatching: document.getElementById('homeScreen_discovery_renderSpotlightAboveMatching')?.checked === true, - 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 - config.exclusiveElsewhere = { - hideServerName: document.getElementById('exclusiveElsewhere_hideServerName')?.checked === true - }; - - // Collect search config - config.search = { - enableJellyseerr: document.getElementById('search_enableJellyseerr')?.checked === true - }; - - // Collect flattenSingleSeasonShows config - config.flattenSingleSeasonShows = { - hideSingleSeasonContainer: document.getElementById('flattenSingleSeasonShows_hideSingleSeasonContainer')?.checked === true - }; - - // Parse JSON fields - const skinsFromJson = parseJSONField('skinsJson', []); - config.themes = parseJSONField('themesJson', []); - config.customMenuLinks = parseJSONField('customMenuLinksJson', []); - - // Collect optional includes configuration - if (overrideOptionalIncludes) { - config.optionalIncludes = overrideOptionalIncludes; - } else { - // Fallback: Use existing config.optionalIncludes to prevent data loss - // DOM scraping caused partial overwrites because it only found visible checkboxes for the current category - console.warn('[KefinTweaks Configuration] handleSaveConfig called without overrideOptionalIncludes - preserving existing optionalIncludes'); - if (!config.optionalIncludes) { - config.optionalIncludes = []; - } - } - - // Collect skin enabled toggles and merge with JSON skins - // Only save admin-configured skins (not defaults) - // Get default skin names to filter them out - const defaultSkinNames = new Set((window.KefinTweaksDefaultSkinsConfig?.skins || []).map(s => s.name)); - - // Collect enabled states from toggles (for all skins, including defaults) - const skinEnabledStates = {}; - modalInstance.dialogContent.querySelectorAll('[data-skin-name]').forEach(checkbox => { - const skinName = checkbox.getAttribute('data-skin-name'); - skinEnabledStates[skinName] = checkbox.checked; - }); - - // Only save admin skins (from JSON textarea) - filter out any that match default names - // This ensures we don't accidentally save default skins - const customSkins = skinsFromJson - .filter(skin => !defaultSkinNames.has(skin.name)) // Exclude defaults - .map(skin => ({ - ...skin, - enabled: skinEnabledStates[skin.name] !== false, // Default to true if not set - hidden: false // Explicitly set for admin skins - })); - - // Handle disabled default skins - // We need to explicitly save them in config.skins with enabled: false to override the default - const disabledDefaultSkins = []; - defaultSkinNames.forEach(name => { - // If explicitly disabled in UI - if (skinEnabledStates[name] === false) { - disabledDefaultSkins.push({ - name: name, - enabled: false - }); - } - }); - - // Combine custom skins and disabled default skins - config.skins = [...customSkins, ...disabledDefaultSkins]; - - // Note: Enabled states for default skins are collected but not saved in config.skins - // Default skins will use their default enabled state from skinConfig.js - // If we need to save enabled states for defaults, we'd need a separate field (future enhancement) - - // Handle defaultSkin - if (config.defaultSkin === '') { - config.defaultSkin = null; - } - console.log('[KefinTweaks Configuration] Configuration collected:', config); // Save to JavaScript Injector plugin @@ -5347,16 +6081,19 @@ } } + /** + * @returns {Promise} true if defaults were restored + */ async function handleResetConfig(modalInstance) { const userIsAdmin = await isAdmin(); if (!userIsAdmin) { alert('You must be an administrator to reset configuration.'); - return; + return false; } 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; + return false; } try { @@ -5367,6 +6104,11 @@ defaultConfig.kefinTweaksRoot = existingConfig.kefinTweaksRoot; // Note: scriptRoot is no longer used - scripts are loaded from kefinTweaksRoot + '/scripts/' + window.KefinTweaksConfig = defaultConfig; + window.KefinTweaksCurrentConfig = defaultConfig; + currentLoadedConfig = defaultConfig; + window.KefinTweaksConfigEnabled = defaultConfig.enabled !== false; + await KefinTweaksUtils.saveConfigToJavaScriptInjector(defaultConfig); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { @@ -5379,6 +6121,7 @@ modalInstance.close(); } openConfigurationModal(); + return true; } catch (error) { console.error('[KefinTweaks Configuration] Error resetting config:', error); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { @@ -5386,25 +6129,27 @@ } else { alert(`Error resetting configuration: ${error.message}`); } + return false; } } - async function handleResetAllUsersConfig(modalInstance) { + /** + * Bump configVersion so clients clear kefinTweaks* localStorage on next load (see injector.js). + */ + async function handleResetAllUsersConfig() { const userIsAdmin = await isAdmin(); if (!userIsAdmin) { alert('You must be an administrator to reset configuration for all users.'); return; } - const confirmed = confirm('WARNING: This will force ALL users to reset their KefinTweaks settings to the default configuration on their next visit. This action cannot be undone. Are you sure you want to continue?'); + const confirmed = confirm('WARNING: This will force ALL users to reset their KefinTweaks local settings on their next visit. This action cannot be undone. Are you sure you want to continue?'); if (!confirmed) { return; } try { const config = await getKefinTweaksConfig(); - // Update the configVersion to current timestamp - // This will trigger the reset logic in injector.js for all clients config.configVersion = Date.now(); await KefinTweaksUtils.saveConfigToJavaScriptInjector(config); @@ -5439,8 +6184,174 @@ } } + function getKefinTweaksRoot() { + const raw = window.KefinTweaksConfig?.kefinTweaksRoot || ''; + return raw.endsWith('/') ? raw : `${raw}/`; + } + + function getKefinTweaksLogoCssValue() { + const root = getKefinTweaksRoot(); + if (!root) return 'none'; + return `url('${root}logo.png')`; + } + + function ensureKefinTweaksLogoVar() { + document.documentElement.style.setProperty( + '--kefinTweaksLogo', + getKefinTweaksLogoCssValue() + ); + } + + function closeAppUserMenu() { + const menu = document.getElementById('app-user-menu'); + if (!menu) return; + const backdrop = menu.querySelector('.MuiBackdrop-root, .MuiModal-backdrop'); + if (backdrop) { + backdrop.click(); + return; + } + document.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + keyCode: 27, + which: 27, + bubbles: true + })); + } + + /** + * Create KefinTweaks menu item for Jellyfin v12 #app-user-menu + * @returns {HTMLAnchorElement} + */ + function createAppUserMenuConfigButton() { + ensureKefinTweaksLogoVar(); + + const link = document.createElement('a'); + link.className = 'MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root MuiMenuItem-gutters css-mbeig7'; + link.setAttribute('tabindex', '-1'); + link.setAttribute('role', 'menuitem'); + link.href = '#'; + link.setAttribute('data-kefintweaks-user-menu-config-button', 'true'); + + const icon = document.createElement('div'); + icon.className = 'MuiListItemIcon-root'; + const iconSpan = document.createElement('span'); + iconSpan.className = 'material-icons MuiSvgIcon-root MuiSvgIcon-fontSizeMedium'; + iconSpan.setAttribute('aria-hidden', 'true'); + icon.appendChild(iconSpan); + + const text = document.createElement('div'); + text.className = 'MuiListItemText-root'; + const span = document.createElement('span'); + span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary'; + span.textContent = 'KefinTweaks'; + text.appendChild(span); + + const ripple = document.createElement('span'); + ripple.className = 'MuiTouchRipple-root'; + + link.appendChild(icon); + link.appendChild(text); + link.appendChild(ripple); + + link.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + closeAppUserMenu(); + openConfigurationModal(); + }); + + return link; + } + + /** + * Insert KefinTweaks into #app-user-menu before the first HR after Dashboard. + * @returns {boolean} true if button is present after this call + */ + function addConfigButtonToAppUserMenu() { + if (document.querySelector('[data-kefintweaks-user-menu-config-button]')) { + return true; + } + + const appUserMenu = document.getElementById('app-user-menu'); + if (!appUserMenu) { + return false; + } + + const menuList = appUserMenu.querySelector('ul[role="menu"]'); + if (!menuList) { + return false; + } + + const dashboardLink = menuList.querySelector('a[href="#/dashboard"]') + || menuList.querySelector('a[href*="#/dashboard"]'); + if (!dashboardLink) { + console.log('[KefinTweaks Configuration] #app-user-menu Dashboard link not found'); + return false; + } + + let divider = dashboardLink.nextElementSibling; + while (divider && !(divider.matches('hr') || divider.classList.contains('MuiDivider-root'))) { + divider = divider.nextElementSibling; + } + if (!divider) { + console.log('[KefinTweaks Configuration] #app-user-menu divider after Dashboard not found'); + return false; + } + + const button = createAppUserMenuConfigButton(); + divider.parentNode.insertBefore(button, divider); + console.log('[KefinTweaks Configuration] Configuration button added to #app-user-menu'); + return true; + } + + let appUserMenuConfigObserver = null; + + /** + * Permanent observer: keep re-injecting if #app-user-menu remounts and our button is lost. + */ + function setupAppUserMenuConfigButtonObserver() { + addConfigButtonToAppUserMenu(); + + if (appUserMenuConfigObserver) { + return; + } + + const utils = window.KefinTweaksUtils; + let scheduled = false; + const onMutations = () => { + if (document.querySelector('[data-kefintweaks-user-menu-config-button]')) { + return; + } + if (scheduled) return; + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + addConfigButtonToAppUserMenu(); + }); + }; + + if (utils?.onBodyMutation) { + appUserMenuConfigObserver = utils.onBodyMutation('config-appUserMenu', onMutations); + return; + } + + if (typeof MutationObserver === 'undefined' || !document.body) { + console.log('[KefinTweaks Configuration] Cannot observe #app-user-menu (MutationObserver/body unavailable)'); + return; + } + + appUserMenuConfigObserver = new MutationObserver(onMutations); + appUserMenuConfigObserver.observe(document.body, { + childList: true, + subtree: true + }); + } + // Create configuration button for Administration section function createConfigButton() { + ensureKefinTweaksLogoVar(); + const button = document.createElement('div'); button.style.display = 'block'; button.style.padding = '0'; @@ -5454,9 +6365,9 @@ button.innerHTML = `
- +
-
Configure KefinTweaks
+
KefinTweaks
`; @@ -5466,13 +6377,18 @@ // Create dashboard side menu button function createDashboardConfigButton() { + ensureKefinTweaksLogoVar(); + // Check if button already exists if (document.querySelector('[data-kefintweaks-dashboard-config-button]')) { return null; } + // Get an existing native button + const nativeButton = document.querySelector('.MuiButtonBase-root.MuiListItemButton-root.MuiListItemButton-gutters.MuiListItemButton-root.MuiListItemButton-gutters'); + const div = document.createElement('div'); - div.className = 'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters kefin-config-button'; + div.className = 'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters css-yknuxp'; div.setAttribute('tabindex', '0'); div.setAttribute('data-kefintweaks-dashboard-config-button', 'true'); div.onclick = () => { @@ -5481,24 +6397,24 @@ // Create icon with Material icon const icon = document.createElement('div'); - icon.className = 'MuiListItemIcon-root css-37qkju'; + icon.className = 'MuiListItemIcon-root css-37qkju css-5pks8q'; const iconSpan = document.createElement('span'); - iconSpan.className = 'material-icons build MuiSvgIcon-root MuiSvgIcon-fontSizeMedium'; + iconSpan.className = 'material-icons MuiSvgIcon-root MuiSvgIcon-fontSizeMedium'; iconSpan.style.marginRight = '12px'; iconSpan.setAttribute('aria-hidden', 'true'); icon.appendChild(iconSpan); // Create text const text = document.createElement('div'); - text.className = 'MuiListItemText-root css-f696uz'; + text.className = 'MuiListItemText-root css-f696uz css-t3p1pa'; const span = document.createElement('span'); - span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-hlxkyz'; + span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-hlxkyz css-pl8nxc'; span.textContent = 'KefinTweaks'; text.appendChild(span); // Create ripple effect span const ripple = document.createElement('span'); - ripple.className = 'MuiTouchRipple-root css-w0pj6f'; + ripple.className = 'MuiTouchRipple-root css-w0pj6f css-4mb1j7'; div.appendChild(icon); div.appendChild(text); @@ -5518,7 +6434,7 @@ const pluginsSection = document.querySelector('ul[aria-labelledby="plugins-subheader"]'); if (!pluginsSection) { console.log('[KefinTweaks Configuration] Plugins section not found, retrying...'); - setTimeout(addConfigButtonToDashboard, 500); + //setTimeout(addConfigButtonToDashboard, 500); return; } @@ -5536,6 +6452,7 @@ // Add configuration button to Administration section function addConfigButtonToAdminSection() { + return; // Check if button already exists (check for admin section button specifically) if (document.querySelector('.adminSection [data-kefintweaks-config-button]')) { return; @@ -5545,7 +6462,7 @@ const adminSection = document.querySelector('.adminSection.verticalSection'); if (!adminSection) { console.log('[KefinTweaks Configuration] Administration section not found, retrying...'); - setTimeout(addConfigButtonToAdminSection, 500); + //setTimeout(addConfigButtonToAdminSection, 500); return; } @@ -5611,43 +6528,24 @@ return; } - // Use a special URL that we can intercept - const configUrl = '#'; - - // Add the menu link + // Admin-only: user menu admin section + default side menu; open via JS action const success = await window.KefinTweaksUtils.addCustomMenuLink( - 'Configure', + 'KefinTweaks', 'build', - configUrl, + '', false, - '.adminMenuOptions' + { + userMenu: true, + isAdminLink: true, + action: 'KefinTweaksConfiguration.openConfigurationModal' + } ); if (success) { console.log('[KefinTweaks Configuration] Configuration link added to custom menu'); - - // Set up click handler to intercept navigation and open modal instead - setupConfigLinkClickHandler(); } } - // Set up click handler for configuration menu link - function setupConfigLinkClickHandler() { - // Find the config button - const configButton = document.querySelector('.navMenuOption[data-name="configure"]'); - if (!configButton) { - console.log('[KefinTweaks Configuration] Configuration button not found, retrying...'); - setTimeout(setupConfigLinkClickHandler, 1000); - return; - } - - // Add click handler to the config button - configButton.addEventListener('click', (e) => { - e.preventDefault(); - openConfigurationModal(); - }); - } - // Handle direct navigation to config URL function handleDirectConfigNavigation() { if (window.location.hash === '#kefintweaks-config') { @@ -5679,6 +6577,14 @@ return; } + // Run migration check during initialization + try { + const config = await getKefinTweaksConfig(); + // Migration is handled inside getKefinTweaksConfig + } catch (error) { + console.warn('[KefinTweaks Configuration] Error during initialization migration check:', error); + } + // Register view page handler registerViewPageHandler(); @@ -5696,6 +6602,8 @@ const userIsAdmin = await isAdmin(); if (userIsAdmin) { + // User-menu KefinTweaks entry is owned by addCustomMenuLink(userMenu + isAdminLink + action) + if (hash && (hash.includes('mypreferencesmenu') || hash.includes('userpreferences') || hash.includes('preferences'))) { setTimeout(() => { addConfigButtonToAdminSection(); diff --git a/scripts/customMenuLinks.js b/scripts/customMenuLinks.js index f3deed2..85e464a 100644 --- a/scripts/customMenuLinks.js +++ b/scripts/customMenuLinks.js @@ -1,25 +1,36 @@ // Custom Menu Links Script -// Loads custom menu links from configuration and adds them to the custom menu -// Uses utils.addCustomMenuLink to add each configured link +// Loads custom menu links from configuration and adds them via utils.addCustomMenuLink // Requires: utils.js module to be loaded before this script (function() { 'use strict'; - // Common logging function const LOG = (...args) => console.log('[KefinTweaks CustomMenuLinks]', ...args); const WARN = (...args) => console.warn('[KefinTweaks CustomMenuLinks]', ...args); const ERR = (...args) => console.error('[KefinTweaks CustomMenuLinks]', ...args); LOG('Script loaded - JS Injector mode'); - // Get custom menu links configuration function getCustomMenuLinksConfig() { return window.KefinTweaksConfig?.customMenuLinks || []; } + + function normalizeTopNavigation(value) { + const v = String(value || 'none').toLowerCase().trim(); + if (v === 'main' || v === 'right') return v; + return 'none'; + } - // Initialize custom menu links async function initializeCustomMenuLinks() { + if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) { + ERR('KefinTweaksUtils.addCustomMenuLink not available'); + return; + } + + if (typeof window.KefinTweaksUtils.ensureCustomMenuLinkStyles === 'function') { + window.KefinTweaksUtils.ensureCustomMenuLinkStyles(); + } + const customMenuLinks = getCustomMenuLinksConfig(); if (!Array.isArray(customMenuLinks) || customMenuLinks.length === 0) { @@ -29,27 +40,32 @@ LOG(`Loading ${customMenuLinks.length} custom menu links`); - // Check if utils is available - if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) { - ERR('KefinTweaksUtils.addCustomMenuLink not available'); - return; - } - - // Add each custom menu link - const addPromises = customMenuLinks.map(async (linkConfig, index) => { + // Sequential so drawer insert order matches config order + let successCount = 0; + for (let index = 0; index < customMenuLinks.length; index++) { + const linkConfig = customMenuLinks[index]; try { - // Validate link configuration - if (!linkConfig.name || !linkConfig.url) { - WARN(`Custom menu link at index ${index} is missing required properties (name, url)`); - return false; + const name = linkConfig?.name; + const url = linkConfig?.url != null ? String(linkConfig.url).trim() : ''; + const action = linkConfig?.action != null ? String(linkConfig.action).trim() : ''; + + if (!name || (!url && !action)) { + WARN(`Custom menu link at index ${index} is missing required properties (name, and url or action)`); + continue; } const { - name, - icon = 'link', // Default icon - url, - openInNewTab = false + icon = 'link', + openInNewTab = false, + collapsed = false, + sideMenu = true, + userMenu = false, + isAdminLink = false } = linkConfig; + + const topNavigation = normalizeTopNavigation(linkConfig.topNavigation); + const orderNum = Number(linkConfig.order); + const order = Number.isFinite(orderNum) ? orderNum : undefined; LOG(`Adding custom menu link: ${name}`); @@ -57,30 +73,32 @@ name, icon, url, - openInNewTab + openInNewTab, + { + collapsed: !!collapsed, + sideMenu: sideMenu !== false, + topNavigation, + userMenu: !!userMenu, + isAdminLink: !!isAdminLink, + action, + order + } ); if (success) { + successCount += 1; LOG(`Successfully added custom menu link: ${name}`); } else { WARN(`Failed to add custom menu link: ${name}`); } - - return success; } catch (error) { ERR(`Error adding custom menu link at index ${index}:`, error); - return false; } - }); - - // Wait for all links to be processed - const results = await Promise.all(addPromises); - const successCount = results.filter(Boolean).length; + } LOG(`Custom menu links initialization complete: ${successCount}/${customMenuLinks.length} links added successfully`); } - // Wait for utils to be available and then initialize function waitForUtilsAndInitialize() { if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) { LOG('Utils available, initializing custom menu links'); @@ -90,7 +108,6 @@ LOG('Waiting for KefinTweaksUtils to be available'); - // Poll for utils availability const checkInterval = setInterval(() => { if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) { clearInterval(checkInterval); @@ -99,15 +116,13 @@ } }, 100); - // Fallback timeout after 10 seconds setTimeout(() => { clearInterval(checkInterval); WARN('KefinTweaksUtils not available after 10 seconds'); }, 10000); } - // Start initialization waitForUtilsAndInitialize(); LOG('Custom menu links functionality initialized'); -})(); \ No newline at end of file +})(); diff --git a/scripts/dashboardButtonFix.js b/scripts/dashboardButtonFix.js index e0e8235..3c00381 100644 --- a/scripts/dashboardButtonFix.js +++ b/scripts/dashboardButtonFix.js @@ -4,34 +4,64 @@ // This script sends you to home.html in these scenarios instead of leaving the app (function () { - function attachHandler() { - const btn = document.querySelector('button.MuiButtonBase-root'); - if (!btn) return; + 'use strict'; - if (btn._backCheckAttached) return; + function attachHandler(btn) { + if (!btn || btn._backCheckAttached) return true; btn._backCheckAttached = true; btn.addEventListener('click', function (e) { if (window.history.state?.idx === 0) { - // Stop normal handlers e.stopImmediatePropagation(); e.preventDefault(); - console.log("[KefinTweaks DashboardButtonFix]: No history to go back. Redirecting to home page."); + console.log('[KefinTweaks DashboardButtonFix]: No history to go back. Redirecting to home page.'); - // Changes this to the page you'd like to return to by default const homeUrl = ApiClient.serverVersion().split('.')[1] > 10 ? 'home' : 'home.html'; Dashboard.navigate(`/${homeUrl}`); } }, true); - - observer.disconnect(); + return true; } - // Observe DOM in case button is recreated - const observer = new MutationObserver(attachHandler); - observer.observe(document.body, { childList: true, subtree: true }); + function tryAttach() { + const btn = document.querySelector('button.MuiButtonBase-root'); + return attachHandler(btn); + } - // Initial run - attachHandler(); + function armForDashboard() { + if (tryAttach()) return; + + const utils = window.KefinTweaksUtils; + if (utils?.waitForElement) { + utils.waitForElement('button.MuiButtonBase-root', { + timeout: 15000, + quiet: true, + predicate: (el) => !el._backCheckAttached + }).then((btn) => { + if (btn) attachHandler(btn); + }); + return; + } + if (utils?.pollUntil) { + utils.pollUntil(() => (tryAttach() ? true : null), { intervalMs: 200, timeoutMs: 15000 }); + return; + } + setTimeout(armForDashboard, 500); + } + + function start() { + const utils = window.KefinTweaksUtils; + if (!utils?.onViewPage) { + setTimeout(start, 500); + return; + } + utils.onViewPage(() => { + armForDashboard(); + }, { pages: ['dashboard', 'configurationpage'] }); + // Also try immediately (direct load) + armForDashboard(); + } + + start(); })(); diff --git a/scripts/defaultSkin.css b/scripts/defaultSkin.css index e8873e5..f94e7e4 100644 --- a/scripts/defaultSkin.css +++ b/scripts/defaultSkin.css @@ -1,38 +1,30 @@ .docspinner { - contain: layout style size; - top: 6em; - right: calc(1em + 7px); - left: unset; - position: fixed; - width: 3em; - height: 3em; - z-index: 9999999; - margin: 0; - pointer-events: none; + contain: layout style size; + top: 6em; + right: calc(1em + 7px); + left: unset; + position: fixed; + width: 3em; + height: 3em; + z-index: 9999999; + margin: 0; + pointer-events: none; } /* Item Details Page */ div#seriesScheduleSection { - order: 2; + order: 2; } -.detailPageContent>* { - order: 1; +.detailPageContent > * { + order: 1; } .commentsSection { - order: 3; -} - -@media (max-width: 768px) { - .headerRight .btnRecommendations, - .headerRight .btnAdmin, - .headerRight .headerAppearanceButton { - display: none; - } + order: 3; } .itemBackdrop { - pointer-events: none; -} \ No newline at end of file + pointer-events: none; +} diff --git a/scripts/deviceManager.js b/scripts/deviceManager.js index 95cf017..6b0e4a8 100644 --- a/scripts/deviceManager.js +++ b/scripts/deviceManager.js @@ -132,7 +132,7 @@ method: 'DELETE', headers: { 'Accept': 'application/json', - 'Authorization': apiHelper.getAuthHeader() + 'Authorization': window.apiHelper.getAuthHeader() } }); @@ -266,7 +266,7 @@ return; } - const activeSessions = activeDevicesContainer.querySelectorAll('.activeSession'); + const activeSessions = activeDevicesContainer.querySelectorAll('.activeSession, .MuiStack-root > .MuiPaper-root.MuiPaper-elevation.MuiPaper-rounded.MuiPaper-elevation1.MuiCard-root'); LOG(`Found ${activeSessions.length} active session containers`); if (activeSessions.length === 0) { diff --git a/scripts/exclusiveElsewhere.js b/scripts/exclusiveElsewhere.js index 384abfe..df563b4 100644 --- a/scripts/exclusiveElsewhere.js +++ b/scripts/exclusiveElsewhere.js @@ -30,29 +30,70 @@ const config = window.KefinTweaksConfig?.exclusiveElsewhere || { hideServerName: false // Default fallback }; - - const observer = new MutationObserver(() => { + + let pollTimer = null; + + function stopPoll() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + function tryApplyExclusive() { const elsewhereContainer = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div'); if (elsewhereContainer && elsewhereContainer.children && elsewhereContainer.children.length > 2) { - return; + return true; } - const serverName = config.hideServerName ? '' : ApiClient.serverName(); - + const serverName = config.hideServerName ? '' : (typeof ApiClient !== 'undefined' ? ApiClient.serverName() : ''); const link = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div>div:first-child a'); if (link && !link.classList.contains('exclusive')) { LOG('Modifying elsewhere link to show exclusive branding'); link.innerHTML = `Only available on ${serverName}`; - link.classList.add("exclusive"); - link.title = "Exclusive"; + link.classList.add('exclusive'); + link.title = 'Exclusive'; link.disable = true; + return true; } - }); + return false; + } - observer.observe(document.body, { - childList: true, - subtree: true - }); - - LOG('Exclusive elsewhere functionality initialized'); -})(); \ No newline at end of file + function armItemDetailPoll() { + stopPoll(); + if (tryApplyExclusive()) return; + + const started = Date.now(); + const TTL_MS = 15000; + pollTimer = setInterval(() => { + if (tryApplyExclusive() || Date.now() - started >= TTL_MS) { + stopPoll(); + } + }, 300); + } + + function start() { + const utils = window.KefinTweaksUtils; + if (!utils?.onViewPage) { + setTimeout(start, 500); + return; + } + + utils.onViewPage((view) => { + stopPoll(); + // item / details views + if (view === 'item' || view === 'details' || /item\.html|details/i.test(window.location.hash || '')) { + armItemDetailPoll(); + } + }); + + // Direct load on item page + if (document.querySelector('.itemDetailPage:not(.hide)')) { + armItemDetailPoll(); + } + + LOG('Exclusive elsewhere functionality initialized (onViewPage + TTL poll)'); + } + + start(); +})(); diff --git a/scripts/externalList.js b/scripts/externalList.js new file mode 100644 index 0000000..7aab7d4 --- /dev/null +++ b/scripts/externalList.js @@ -0,0 +1,114 @@ +// KefinTweaks External List (MDBList) — match provider IDs against library caches +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks ExternalList]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks ExternalList]', ...args); + const ERR = (...args) => console.error('[KefinTweaks ExternalList]', ...args); + + function getMdblistApiKey() { + const settings = window.LibraryCacheUtils?.getLibraryCacheSettings?.() || {}; + return (settings.mdblistApiKey || '').trim(); + } + + function appendApiKey(url, apiKey) { + if (!apiKey) return url; + try { + const u = new URL(url); + if (!u.searchParams.has('apikey') && !u.searchParams.has('api_key')) { + u.searchParams.set('apikey', apiKey); + } + return u.toString(); + } catch (_) { + const join = url.includes('?') ? '&' : '?'; + return `${url}${join}apikey=${encodeURIComponent(apiKey)}`; + } + } + + /** + * Normalize mdblist (or similar) JSON into { mediaType, imdb?, tmdb? }[]. + */ + function normalizeExternalListPayload(data) { + const rows = []; + const list = Array.isArray(data) ? data + : Array.isArray(data?.movies) || Array.isArray(data?.shows) || Array.isArray(data?.items) + ? [...(data.movies || []), ...(data.shows || []), ...(data.items || [])] + : []; + + list.forEach((entry) => { + if (!entry || typeof entry !== 'object') return; + const mediaType = /show|series|tv/i.test(String(entry.mediatype || entry.media_type || entry.type || '')) + ? 'Series' + : 'Movie'; + const imdb = entry.imdb_id || entry.imdbid || entry.ids?.imdb || entry.Imdb || null; + const tmdb = entry.tmdb_id || entry.tmdbid || entry.ids?.tmdb || entry.Tmdb || null; + if (imdb || tmdb) { + rows.push({ mediaType, imdb: imdb ? String(imdb) : undefined, tmdb: tmdb ? String(tmdb) : undefined }); + } + }); + return rows; + } + + async function fetchExternalListUrl(url) { + const apiKey = getMdblistApiKey(); + const fetchUrl = appendApiKey(url, apiKey); + const response = await fetch(fetchUrl, { headers: { Accept: 'application/json' } }); + if (!response.ok) { + throw new Error(`External list HTTP ${response.status}`); + } + return response.json(); + } + + async function resolveExternalListItems(sectionConfig) { + const urls = sectionConfig?.externalListUrls || []; + if (!urls.length) return { Items: [] }; + + if (!getMdblistApiKey()) { + WARN('MDBList API key not configured'); + } + + const allRows = []; + for (const url of urls) { + if (!url) continue; + try { + const data = await fetchExternalListUrl(url); + allRows.push(...normalizeExternalListPayload(data)); + } catch (e) { + ERR('Failed to fetch external list:', url, e); + } + } + + const movieIds = []; + const seriesIds = []; + allRows.forEach((row) => { + const keys = []; + if (row.imdb) keys.push(row.imdb); + if (row.tmdb) keys.push(row.tmdb); + if (row.mediaType === 'Series') seriesIds.push(...keys); + else movieIds.push(...keys); + }); + + const movies = movieIds.length + ? await (window.LibraryCache?.getByProviderIds('Movie', movieIds) + || window.MoviesCache?.getMoviesByProviderIds?.(movieIds) + || []) + : []; + const series = seriesIds.length + ? await (window.LibraryCache?.getByProviderIds('Series', seriesIds) + || window.SeriesCache?.getSeriesByProviderIds?.(seriesIds) + || []) + : []; + + const items = [...movies, ...series]; + LOG(`External list matched ${items.length} library items from ${allRows.length} rows`); + return { Items: items }; + } + + window.KefinExternalList = { + getMdblistApiKey, + resolveExternalListItems, + normalizeExternalListPayload + }; + + LOG('Module loaded'); +})(); diff --git a/scripts/games.js b/scripts/games.js new file mode 100644 index 0000000..ecef93f --- /dev/null +++ b/scripts/games.js @@ -0,0 +1,126 @@ +// KefinTweaks Games +// Hosts Games hub + Movie LUT + 3x3 via addCustomPage +// Requires: utils.js + +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks Games]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks Games]', ...args); + const ERR = (...args) => console.error('[KefinTweaks Games]', ...args); + + LOG('Script loaded'); + + function getRoot() { + const raw = window.KefinTweaksConfig?.kefinTweaksRootResolved; + if (!raw) return null; + return raw.endsWith('/') ? raw : `${raw}/`; + } + + /** + * Build custom-page HTML from a full document: styles + head scripts + body + * (scripts kept). Rewrites `body {` to a scope class for non-full-page host. + */ + function buildContentHtmlFromDocument(htmlString, scopeClass) { + const doc = new DOMParser().parseFromString(htmlString, 'text/html'); + const chunks = []; + + doc.querySelectorAll('style').forEach((el) => { + let css = el.textContent || ''; + if (scopeClass) { + css = css.replace(/(^|})\s*body\s*\{/g, `$1.${scopeClass}{`); + } + chunks.push(``); + }); + + doc.head.querySelectorAll('script').forEach((el) => { + chunks.push(el.outerHTML); + }); + + const wrap = document.createElement('div'); + const bodyClass = (doc.body && doc.body.className) || ''; + wrap.className = [scopeClass, bodyClass].filter(Boolean).join(' ').trim(); + + Array.from(doc.body.childNodes).forEach((node) => { + if (node.nodeName === 'STYLE') return; + wrap.appendChild(node.cloneNode(true)); + }); + chunks.push(wrap.outerHTML); + + return chunks.join('\n'); + } + + async function fetchPageHtml(root, relativePath) { + const url = `${root}${relativePath.replace(/^\//, '')}`; + const res = await fetch(url, { cache: 'no-cache' }); + if (!res.ok) { + throw new Error(`Failed to fetch ${url}: ${res.status}`); + } + return res.text(); + } + + async function registerGamesPages() { + const utils = window.KefinTweaksUtils; + if (!utils?.addCustomMenuLink || !utils?.addCustomPage) { + ERR('addCustomMenuLink / addCustomPage not available'); + return false; + } + + const root = getRoot(); + if (!root) { + WARN('kefinTweaksRoot not configured; cannot register Games pages'); + return false; + } + + try { + const [gamesRaw, movieLutRaw, threeRaw] = await Promise.all([ + fetchPageHtml(root, 'pages/games.html'), + fetchPageHtml(root, 'pages/movieLut.html'), + fetchPageHtml(root, 'pages/3x3.html'), + ]); + + const gamesHtml = buildContentHtmlFromDocument(gamesRaw, 'kefin-custom-page-games'); + const movieLutHtml = buildContentHtmlFromDocument(movieLutRaw, 'kefin-custom-page-movielut'); + const threeByThreeHtml = buildContentHtmlFromDocument(threeRaw, 'kefin-custom-page-3x3'); + + utils.addCustomPage('#/games', 'Games', gamesHtml); + utils.addCustomPage('#/movieLut', 'Movie LUT', movieLutHtml); + utils.addCustomPage('#/3x3', '3x3', threeByThreeHtml); + + const ok = await utils.addCustomMenuLink('Games', 'sports_esports', '#/games', false); + if (ok) LOG('Games menu link + custom pages registered'); + else WARN('Custom pages registered but menu link failed'); + return !!ok; + } catch (e) { + ERR('Error registering Games pages:', e); + return false; + } + } + + function waitForUtilsAndInitialize() { + const ready = () => + window.KefinTweaksUtils?.addCustomMenuLink && window.KefinTweaksUtils?.addCustomPage; + + if (ready()) { + registerGamesPages(); + return; + } + + LOG('Waiting for KefinTweaksUtils…'); + const checkInterval = setInterval(() => { + if (ready()) { + clearInterval(checkInterval); + registerGamesPages(); + } + }, 100); + + setTimeout(() => { + clearInterval(checkInterval); + if (!ready()) { + WARN('KefinTweaksUtils not available after 10 seconds'); + } + }, 10000); + } + + waitForUtilsAndInitialize(); +})(); diff --git a/scripts/hamburgerMenu.js b/scripts/hamburgerMenu.js new file mode 100644 index 0000000..63a359e --- /dev/null +++ b/scripts/hamburgerMenu.js @@ -0,0 +1,758 @@ +// KefinTweaks Desktop Hamburger Menu (Jellyfin v12) +// Exposes a desktop Open Menu button + left drawer matching the native mobile nav. +// Requires: utils.js + +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks HamburgerMenu]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks HamburgerMenu]', ...args); + const ERR = (...args) => console.error('[KefinTweaks HamburgerMenu]', ...args); + + const FAVORITES_HREF = '#/home?tab=1'; + const STYLE_ID = 'kefin-hamburger-menu-styles'; + const BTN_ATTR = 'data-kefin-desktop-hamburger'; + const DRAWER_ATTR = 'data-kefin-desktop-drawer'; + + const FALLBACK_BTN_CLASS = + 'MuiButtonBase-root MuiIconButton-root MuiIconButton-colorInherit MuiIconButton-sizeLarge css-i2hxb6'; + const FALLBACK_SVG_CLASS = 'MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-iguwhy'; + const FALLBACK_LI_CLASS = 'MuiListItem-root MuiListItem-gutters css-1ohqk82'; + const FALLBACK_A_CLASS = + 'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters css-yknuxp'; + const FALLBACK_ICON_WRAP = 'MuiListItemIcon-root css-5pks8q'; + const FALLBACK_TEXT_WRAP = 'MuiListItemText-root css-t3p1a1'; + const FALLBACK_TEXT_PRIMARY = + 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-pl8nxc'; + const FALLBACK_BRAND_ICON_WRAP = 'MuiListItemIcon-root css-c9a5tz'; + const FALLBACK_BRAND_TEXT_WRAP = + 'MuiListItemText-root MuiListItemText-multiline css-9ac84v'; + const FALLBACK_BRAND_PRIMARY = + 'MuiTypography-root MuiTypography-h6 MuiListItemText-primary css-1y7q0ly'; + const FALLBACK_BRAND_SECONDARY = + 'MuiTypography-root MuiTypography-body2 MuiListItemText-secondary css-xxlzys'; + + let toolbarObserver = null; + let openDrawerEl = null; + let escapeHandler = null; + let viewsCache = null; + let viewsCacheAt = 0; + + LOG('Script loaded'); + + function ensureStyles() { + let style = document.getElementById(STYLE_ID); + if (!style) { + style = document.createElement('style'); + style.id = STYLE_ID; + (document.head || document.documentElement).appendChild(style); + } + style.textContent = ` +[${BTN_ATTR}] { + position: fixed !important; + left: 8px; + top: 1px; + z-index: 1200; +} +@media (max-width: 899px) { + [${BTN_ATTR}] { + display: none !important; + } +} +.MuiDrawer-root[${DRAWER_ATTR}] { + z-index: 1300; + position: fixed; + inset: 0px; + z-index: var(--jf-zIndex-drawer); +} +.MuiDrawer-root[${DRAWER_ATTR}] .MuiBackdrop-root { + opacity: 0; + transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1); +} +.MuiDrawer-root[${DRAWER_ATTR}][data-kefin-drawer-open="true"] .MuiBackdrop-root { + opacity: 1; + transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1); + position: fixed; + display: flex; + -moz-box-align: center; + align-items: center; + -moz-box-pack: center; + justify-content: center; + inset: 0px; + background-color: rgba(0, 0, 0, 0.5); + z-index: -1; +} +:where(.MuiDrawer-root[${DRAWER_ATTR}]) .MuiDrawer-paper { + transform: translateX(-100%); + transition: transform 225ms cubic-bezier(0, 0, 0.2, 1); + outline: 0px; + background-color: var(--jf-palette-background-paper); + color: var(--jf-palette-text-primary); + box-shadow: var(--Paper-shadow); + background-image: var(--Paper-overlay); + overflow-y: auto; + display: flex; + flex-direction: column; + height: 100%; + width: 280px; + max-width: 100%; + flex: 1 0 auto; + z-index: var(--jf-zIndex-drawer); + position: fixed; + top: 0px; + left: 0px; +} +.MuiDrawer-root[${DRAWER_ATTR}][data-kefin-drawer-open="true"] .MuiDrawer-paper { + transform: none; +} +[${DRAWER_ATTR}] .MuiListItem-root.MuiListItem-gutters.css-1ohqk82 { + display: flex; + -moz-box-pack: start; + justify-content: flex-start; + -moz-box-align: center; + align-items: center; + position: relative; + text-decoration: none; + width: 100%; + box-sizing: border-box; + text-align: left; +} +[${DRAWER_ATTR}] .MuiList-root { + list-style: none; + margin: 0px; + padding-right: 0px; + padding-left: 0px; + position: relative; + padding-bottom: 8px; + padding-top: calc(0 * var(--jf-spacing)); +} +[${DRAWER_ATTR}] .MuiTypography-root { + margin: 0px; + font-family: "Noto Sans", sans-serif; + font-weight: 400; + font-size: 1rem; + line-height: 1.5; + color: var(--jf-palette-text-primary); +} +[${DRAWER_ATTR}] .MuiButtonBase-root.MuiListItemButton-root.MuiListItemButton-gutters { + background-color: transparent; + outline: 0px; + border: 0px; + margin: 0px; + border-radius: 0px; + cursor: pointer; + user-select: none; + vertical-align: middle; + appearance: none; + color: inherit; + display: flex; + -moz-box-flex: 1; + flex-grow: 1; + -moz-box-pack: start; + justify-content: flex-start; + -moz-box-align: center; + align-items: center; + position: relative; + text-decoration: none; + min-width: 0px; + box-sizing: border-box; + text-align: left; + padding: 8px 16px; + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); +} +[${DRAWER_ATTR}] .MuiButtonBase-root.MuiListItemButton-root.Mui-selected { + background-color: var(--jf-palette-action-selected, rgba(255, 255, 255, 0.08)); +} +[${DRAWER_ATTR}] .MuiButtonBase-root:first-child { + color: var(--jf-palette-action-active); + flex-shrink: 0; + display: inline-flex; + min-width: 36px; +} +[${DRAWER_ATTR}] .MuiBox-root { + height: 2.5rem; +} +[${DRAWER_ATTR}] .MuiListItemIcon-root:last-child, +[${DRAWER_ATTR}] .MuiListItemIcon-root.css-c9a5tz { + color: var(--jf-palette-action-active); + flex-shrink: 0; + display: inline-flex; + min-width: 56px; +} +[${DRAWER_ATTR}] a:hover { + background: #4e4e4e !important; +} +`; + } + + function findDesktopToolbar() { + const toolbars = document.querySelectorAll('.MuiToolbar-root.MuiToolbar-dense'); + for (const toolbar of toolbars) { + if (toolbar.querySelector(`:scope > button[aria-label="Open Menu"]`)) continue; + if (toolbar.querySelector(`:scope > [${BTN_ATTR}]`)) return toolbar; + const hasFavorites = toolbar.querySelector(`.MuiStack-root a[href="${FAVORITES_HREF}"]`); + if (hasFavorites) return toolbar; + } + return null; + } + + function findNativeOpenMenuButton() { + return document.querySelector('button[aria-label="Open Menu"]:not([data-kefin-desktop-hamburger])'); + } + + function menuIconSvg(className) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', className || FALLBACK_SVG_CLASS); + svg.setAttribute('focusable', 'false'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('data-testid', 'MenuIcon'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M3 18h18v-2H3zm0-5h18v-2H3zm0-7v2h18V6z'); + svg.appendChild(path); + return svg; + } + + function materialSvg(testId, pathD, className) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', className || FALLBACK_SVG_CLASS); + svg.setAttribute('focusable', 'false'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('viewBox', '0 0 24 24'); + if (testId) svg.setAttribute('data-testid', testId); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', pathD); + svg.appendChild(path); + return svg; + } + + const ICONS = { + home: { + testId: 'HomeIcon', + d: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z' + }, + favorite: { + testId: 'FavoriteIcon', + d: 'm12 21.35-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54z' + }, + tv: { + testId: 'TvIcon', + d: 'M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2m0 14H3V5h18z' + }, + folder: { + testId: 'FolderIcon', + d: 'M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8z' + }, + queue: { + testId: 'QueueIcon', + d: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2m-1 9h-4v4h-2v-4H9V9h4V5h2v4h4z' + }, + movie: { + testId: 'MovieIcon', + d: 'm18 4 2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4zm-6.75 11.25L10 18l-1.25-2.75L6 14l2.75-1.25L10 10l1.25 2.75L14 14z' + }, + music: { + testId: 'LibraryMusicIcon', + d: 'M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2m-2 5h-3v5.5c0 1.38-1.12 2.5-2.5 2.5S10 13.88 10 12.5s1.12-2.5 2.5-2.5c.57 0 1.08.19 1.5.5V5h4zM2 6v14c0 1.1.9 2 2 2h14v-2H4V6z' + }, + livetv: { + testId: 'LiveTvIcon', + d: 'M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3c-1.1 0-2 .89-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.11-.9-2-2-2m0 14H3V8h18zM9 10v8l7-4z' + } + }; + + function createHamburgerButton() { + const native = findNativeOpenMenuButton(); + let btn; + if (native) { + btn = native.cloneNode(true); + btn.querySelectorAll('.MuiTouchRipple-root').forEach((el) => el.remove()); + } else { + btn = document.createElement('button'); + btn.type = 'button'; + btn.className = FALLBACK_BTN_CLASS; + btn.tabIndex = 0; + btn.setAttribute('aria-label', 'Open Menu'); + btn.appendChild(menuIconSvg()); + } + btn.setAttribute(BTN_ATTR, 'true'); + btn.type = 'button'; + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + openDrawer(); + }); + return btn; + } + + function ensureHamburgerButton() { + ensureStyles(); + const toolbar = findDesktopToolbar(); + if (!toolbar) return false; + + let btn = toolbar.querySelector(`[${BTN_ATTR}]`); + if (btn) return true; + + btn = createHamburgerButton(); + toolbar.insertBefore(btn, toolbar.firstChild); + return true; + } + + function getServerDisplayName() { + try { + const brand = document.querySelector( + `.MuiToolbar-root .MuiStack-root > a[href="#/"], .MuiToolbar-root .MuiStack-root > a[href="#/home"]` + ); + if (brand) { + const text = Array.from(brand.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent.trim()) + .filter(Boolean) + .join(' '); + if (text) return text; + } + } catch (_) { /* ignore */ } + try { + return window.ApiClient?.serverName?.() || window.ApiClient?._serverInfo?.Name || 'Jellyfin'; + } catch (_) { + return 'Jellyfin'; + } + } + + function getServerVersionLabel() { + try { + const v = window.ApiClient?._serverVersion || window.ApiClient?.serverVersion?.(); + if (v) { + const m = String(v).match(/^(\d+\.\d+)/); + return m ? m[1] : String(v); + } + } catch (_) { /* ignore */ } + return ''; + } + + function getBrandIconSrc() { + const img = document.querySelector( + `.MuiToolbar-root .MuiStack-root a[href="#/"] img, .MuiToolbar-root .MuiStack-root a[href="#/home"] img` + ); + return img?.getAttribute('src') || null; + } + + function libraryHref(item) { + const serverId = window.ApiClient?.serverId?.() || ''; + const id = item.Id; + const type = String(item.CollectionType || '').toLowerCase(); + switch (type) { + case 'movies': + return `#/movies?topParentId=${id}&collectionType=movies`; + case 'tvshows': + return `#/tv?topParentId=${id}&collectionType=tvshows`; + case 'music': + return `#/music?topParentId=${id}&collectionType=music`; + case 'playlists': + return `#/playlists?topParentId=${id}&collectionType=playlists`; + case 'livetv': + return `#/livetv?collectionType=livetv`; + case 'boxsets': + return `#/list?parentId=${id}&serverId=${serverId}`; + default: + return `#/list?parentId=${id}&serverId=${serverId}`; + } + } + + function libraryIconKey(item) { + switch (String(item.CollectionType || '').toLowerCase()) { + case 'movies': + return 'movie'; + case 'tvshows': + return 'tv'; + case 'music': + return 'music'; + case 'playlists': + return 'queue'; + case 'livetv': + return 'livetv'; + default: + return 'folder'; + } + } + + async function fetchUserViews() { + const now = Date.now(); + if (viewsCache && now - viewsCacheAt < 60000) return viewsCache; + if (!window.ApiClient?.getUserViews) { + WARN('ApiClient.getUserViews not available'); + return []; + } + try { + const userId = window.ApiClient.getCurrentUserId(); + const result = await window.ApiClient.getUserViews({}, userId); + viewsCache = Array.isArray(result?.Items) ? result.Items : []; + viewsCacheAt = now; + return viewsCache; + } catch (e) { + ERR('Failed to fetch UserViews', e); + return []; + } + } + + function getCurrentNavHash() { + const hash = window.location.hash || '#/'; + return hash.startsWith('#') ? hash : `#${hash}`; + } + + function parseHashParts(hash) { + const raw = String(hash || '#/').replace(/^#/, '') || '/'; + const qIndex = raw.indexOf('?'); + const path = (qIndex >= 0 ? raw.slice(0, qIndex) : raw) || '/'; + const query = qIndex >= 0 ? raw.slice(qIndex + 1) : ''; + const params = new URLSearchParams(query); + return { path: path.startsWith('/') ? path : `/${path}`, params }; + } + + function hrefMatchScore(href, currentHash) { + if (!href || /^https?:/i.test(href)) return -1; + const targetHash = href.startsWith('#') ? href : `#${href}`; + const current = parseHashParts(currentHash); + const target = parseHashParts(targetHash); + + if ( + targetHash === currentHash + || decodeURIComponent(targetHash) === decodeURIComponent(currentHash) + ) { + return 1000 + targetHash.length; + } + + if (target.path !== current.path) return -1; + + for (const [key, value] of target.params.entries()) { + if (current.params.get(key) !== value) return -1; + } + + // Prefer more specific links (more query params / longer href) + let score = 100 + targetHash.length; + score += [...target.params.keys()].length * 10; + + // Home without tab should not win over Favorites (?tab=1) + if (target.path === '/home' && !target.params.has('tab') && current.params.has('tab')) { + return -1; + } + + return score; + } + + function applySelectedState(drawerRoot) { + if (!drawerRoot) return; + const currentHash = getCurrentNavHash(); + const links = Array.from(drawerRoot.querySelectorAll('.MuiDrawer-paper a[href]')); + let best = null; + let bestScore = -1; + links.forEach((a) => { + a.classList.remove('Mui-selected'); + a.removeAttribute('aria-current'); + const score = hrefMatchScore(a.getAttribute('href') || '', currentHash); + if (score > bestScore) { + bestScore = score; + best = a; + } + }); + if (best && bestScore >= 0) { + best.classList.add('Mui-selected'); + best.setAttribute('aria-current', 'page'); + } + } + + function createListItem({ href, label, iconKey, brand, secondary, imgSrc }) { + const li = document.createElement('li'); + li.className = FALLBACK_LI_CLASS; + + const a = document.createElement('a'); + a.className = FALLBACK_A_CLASS; + a.tabIndex = 0; + a.href = href; + + const iconWrap = document.createElement('div'); + iconWrap.className = brand ? FALLBACK_BRAND_ICON_WRAP : FALLBACK_ICON_WRAP; + + if (imgSrc) { + const img = document.createElement('img'); + img.className = 'MuiBox-root css-1j3tjfb'; + img.src = imgSrc; + img.alt = ''; + iconWrap.appendChild(img); + } else if (iconKey && ICONS[iconKey]) { + iconWrap.appendChild(materialSvg(ICONS[iconKey].testId, ICONS[iconKey].d)); + } + + const textWrap = document.createElement('div'); + if (brand) { + textWrap.className = FALLBACK_BRAND_TEXT_WRAP; + const h6 = document.createElement('h6'); + h6.className = FALLBACK_BRAND_PRIMARY; + h6.textContent = label; + textWrap.appendChild(h6); + if (secondary) { + const p = document.createElement('p'); + p.className = FALLBACK_BRAND_SECONDARY; + p.textContent = secondary; + textWrap.appendChild(p); + } + } else { + textWrap.className = FALLBACK_TEXT_WRAP; + const span = document.createElement('span'); + span.className = FALLBACK_TEXT_PRIMARY; + span.textContent = label; + textWrap.appendChild(span); + } + + a.appendChild(iconWrap); + a.appendChild(textWrap); + a.addEventListener('click', () => { + // Allow navigation; close drawer shortly after + setTimeout(closeDrawer, 0); + }); + li.appendChild(a); + return li; + } + + function buildDrawerShell(mainList, librariesList) { + const root = document.createElement('div'); + root.setAttribute('role', 'presentation'); + root.className = 'MuiDrawer-root MuiDrawer-anchorLeft MuiDrawer-modal MuiModal-root css-k3j2mn'; + root.setAttribute(DRAWER_ATTR, 'true'); + + const backdrop = document.createElement('div'); + backdrop.setAttribute('aria-hidden', 'true'); + backdrop.className = 'MuiBackdrop-root MuiModal-backdrop css-14dl35y'; + backdrop.addEventListener('click', closeDrawer); + + const sentinelStart = document.createElement('div'); + sentinelStart.tabIndex = 0; + sentinelStart.setAttribute('data-testid', 'sentinelStart'); + + const paper = document.createElement('div'); + paper.className = + 'MuiPaper-root MuiPaper-elevation MuiPaper-elevation16 MuiDrawer-paper MuiDrawer-paperAnchorLeft css-epnusv'; + paper.tabIndex = -1; + paper.style.setProperty('--Paper-shadow', 'var(--jf-shadows-16)'); + paper.style.setProperty('--Paper-overlay', 'var(--jf-overlays-16)'); + + const box = document.createElement('div'); + box.className = 'MuiBox-root css-0'; + box.setAttribute('role', 'presentation'); + + box.appendChild(mainList); + + const hr = document.createElement('hr'); + hr.className = 'MuiDivider-root MuiDivider-fullWidth css-14093q0'; + box.appendChild(hr); + box.appendChild(librariesList); + + paper.appendChild(box); + + const sentinelEnd = document.createElement('div'); + sentinelEnd.tabIndex = 0; + sentinelEnd.setAttribute('data-testid', 'sentinelEnd'); + + root.appendChild(backdrop); + root.appendChild(sentinelStart); + root.appendChild(paper); + root.appendChild(sentinelEnd); + return root; + } + + async function buildDrawer() { + const mainList = document.createElement('ul'); + mainList.className = 'MuiList-root MuiList-padding css-hbss32'; + + const serverName = getServerDisplayName(); + const version = getServerVersionLabel(); + const brandSrc = getBrandIconSrc(); + + mainList.appendChild( + createListItem({ + href: '#/', + label: serverName, + brand: true, + secondary: version || undefined, + imgSrc: brandSrc + }) + ); + mainList.appendChild( + createListItem({ href: '#/home', label: 'Home', iconKey: 'home' }) + ); + mainList.appendChild( + createListItem({ href: FAVORITES_HREF, label: 'Favorites', iconKey: 'favorite' }) + ); + + const librariesList = document.createElement('ul'); + librariesList.className = 'MuiList-root MuiList-padding MuiList-subheader css-q9gyaw'; + librariesList.setAttribute('aria-labelledby', 'libraries-subheader'); + + const subheader = document.createElement('div'); + subheader.className = + 'MuiListSubheader-root MuiListSubheader-gutters MuiListSubheader-sticky css-18qy2xf'; + subheader.id = 'libraries-subheader'; + subheader.textContent = 'Libraries'; + librariesList.appendChild(subheader); + + const views = await fetchUserViews(); + views.forEach((item) => { + if (!item?.Id || !item?.Name) return; + librariesList.appendChild( + createListItem({ + href: libraryHref(item), + label: item.Name, + iconKey: libraryIconKey(item) + }) + ); + }); + + return buildDrawerShell(mainList, librariesList); + } + + function closeDrawer() { + if (escapeHandler) { + document.removeEventListener('keydown', escapeHandler); + escapeHandler = null; + } + const drawer = openDrawerEl; + openDrawerEl = null; + document.body.style.removeProperty('overflow'); + if (!drawer) return; + + drawer.removeAttribute('data-kefin-drawer-open'); + const paper = drawer.querySelector('.MuiDrawer-paper'); + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + drawer.remove(); + }; + if (paper) { + paper.addEventListener('transitionend', (e) => { + if (e.target === paper && e.propertyName === 'transform') finish(); + }, { once: true }); + } + setTimeout(finish, 280); + } + + async function openDrawer() { + if (openDrawerEl) { + closeDrawer(); + return; + } + try { + const drawer = await buildDrawer(); + drawer.removeAttribute('data-kefin-drawer-open'); + document.body.appendChild(drawer); + openDrawerEl = drawer; + + escapeHandler = (e) => { + if (e.key === 'Escape') closeDrawer(); + }; + document.addEventListener('keydown', escapeHandler); + + applySelectedState(drawer); + // Force closed paint, then open so translateX animates + drawer.offsetWidth; + requestAnimationFrame(() => { + drawer.setAttribute('data-kefin-drawer-open', 'true'); + applySelectedState(drawer); + setTimeout(() => applySelectedState(drawer), 100); + }); + } catch (e) { + ERR('Failed to open drawer', e); + closeDrawer(); + } + } + + function parseVersionParts(version) { + return String(version || '') + .split(/[.+-]/) + .filter(Boolean) + .map((p) => { + const n = parseInt(p, 10); + return Number.isFinite(n) ? n : 0; + }); + } + + /** @returns {boolean|null} true if >= 12.0.0, false if older, null if unknown */ + function isAppVersionAtLeast12() { + const version = window.ApiClient?._appVersion; + if (!version) return null; + const parts = parseVersionParts(version); + const major = parts[0] ?? 0; + const minor = parts[1] ?? 0; + const patch = parts[2] ?? 0; + if (major > 12) return true; + if (major < 12) return false; + if (minor > 0) return true; + if (minor < 0) return false; + return patch >= 0; + } + + async function waitForAppVersion(timeoutMs = 10000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const ready = isAppVersionAtLeast12(); + if (ready !== null) return ready; + await new Promise((r) => setTimeout(r, 250)); + } + return isAppVersionAtLeast12(); + } + + function armToolbarPoll() { + ensureHamburgerButton(); + let tries = 0; + const poll = setInterval(() => { + tries += 1; + if (ensureHamburgerButton() || tries >= 40) clearInterval(poll); + }, 250); + } + + function start() { + ensureStyles(); + armToolbarPoll(); + + const utils = window.KefinTweaksUtils; + if (utils?.onViewPage) { + // Toolbar remounts across navigations — short poll per view, no permanent body MO + utils.onViewPage(() => { + armToolbarPoll(); + }); + } else if (utils?.onBodyMutation) { + let scheduled = false; + toolbarObserver = utils.onBodyMutation('hamburger-toolbar', () => { + if (scheduled) return; + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + ensureHamburgerButton(); + }); + }); + } + + LOG('Desktop hamburger menu initialized'); + } + + async function boot() { + const ok = await waitForAppVersion(); + // v12+ modern desktop UI only + if (ok !== true) { + LOG( + 'Skipping desktop hamburger menu; requires ApiClient._appVersion >= 12.0.0', + window.ApiClient?._appVersion || '(unavailable)' + ); + return; + } + start(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + boot(); + }); + } else { + boot(); + } +})(); diff --git a/scripts/headerTabs.js b/scripts/headerTabs.js index 4bef6d2..b4312f2 100644 --- a/scripts/headerTabs.js +++ b/scripts/headerTabs.js @@ -93,7 +93,7 @@ `${serverAddress}/DisplayPreferences/usersettings?userId=${userId}&client=emby`, { headers: { - 'Authorization': apiHelper.getAuthHeader() + 'Authorization': window.apiHelper.getAuthHeader() } } ); @@ -233,9 +233,67 @@ LOG('Tab clicked:', currentPage, tabIndex); } } - + + + async function pollForElement(selectorFn, maxAttempts = 50, pollInterval = 100) { + let attempts = 0; + while (attempts < maxAttempts) { + const element = selectorFn(); + if (element) { + return element; + } + attempts++; + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + WARN('Element not found after', attempts, 'attempts'); + return null; + } + // Synchronize active tab state with URL parameter - async function syncActiveTabState() { + async function syncActiveTabState(currentTabIndex) { + const headerTabButtons = document.querySelectorAll('.headerTabs button'); + let targetTabButton = document.querySelector(`.headerTabs button[data-index="${currentTabIndex}"]`); + + // If the target tab is a custom home tab, we may need to wait for it to be rendered + if (!targetTabButton && currentTabIndex > 1) { + // Wait for the target tab button to be rendered + targetTabButton = await pollForElement(() => document.querySelector(`.headerTabs button[data-index="${currentTabIndex}"]`), 50, 100); + } + + if (targetTabButton && targetTabButton.classList.contains('emby-tab-button-active')) { + LOG('Target tab is already active'); + return; + } + + const pageTabContents = document.querySelectorAll('.libraryPage:not(.hide) .pageTabContent'); + const targetPageTabContent = document.querySelector(`.libraryPage:not(.hide) .pageTabContent[data-index="${currentTabIndex}"]`); + + const sectionsContainer = targetPageTabContent?.querySelector('.sections'); + if (sectionsContainer && sectionsContainer.children.length === 0) { + LOG('Sections container is empty, waiting for sections to be rendered'); + return; + } + + if (headerTabButtons && targetTabButton && pageTabContents && targetPageTabContent) { + // Remove is-active class from all tab buttons + headerTabButtons.forEach(button => { + button.classList.remove('emby-tab-button-active'); + }); + // Add is-active class to the active tab button + targetTabButton.classList.add('emby-tab-button-active'); + LOG('Set active tab button:', currentTabIndex); + + // Remove is-active class from all page tab contents + pageTabContents.forEach(content => { + content.classList.remove('is-active'); + }); + // Add is-active class to the target page tab content + targetPageTabContent.classList.add('is-active'); + LOG('Set active page tab content:', currentTabIndex); + } + + + return; const headerTabs = document.querySelector('.headerTabs'); if (!headerTabs) return; @@ -339,17 +397,27 @@ const view = window.KefinTweaksUtils.getCurrentView(); if (view === 'home' || view === 'home.html') { - // Check for custom tabs - - const response = await fetch(`${ApiClient._serverAddress}/CustomTabs/Config`, { - method: "GET", - headers: { - "Content-Type": "application/json", - "Authorization": apiHelper.getAuthHeader(), - }, - }); - const customTabs = await response.json(); - const customTabsCount = customTabs?.length || 0; + // Check for custom tabs (plugin may be missing / return empty body) + let customTabs = []; + try { + const response = await fetch(`${ApiClient._serverAddress}/CustomTabs/Config`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": window.apiHelper.getAuthHeader(), + }, + }); + if (response.ok) { + const text = await response.text(); + if (text && text.trim()) { + const parsed = JSON.parse(text); + customTabs = Array.isArray(parsed) ? parsed : []; + } + } + } catch (e) { + WARN('CustomTabs/Config unavailable, skipping custom tab listeners:', e?.message || e); + } + const customTabsCount = customTabs.length; totalButtons += customTabsCount; if (totalButtons > buttons.length && customTabsCount > 0) { @@ -385,7 +453,7 @@ // All custom tabs are now present obs.disconnect(); LOG('All custom tabs rendered, adding click listeners'); - syncActiveTabState(); + //syncActiveTabState(); // Add click listeners to all buttons including the new custom tabs const allButtons = headerTabs.querySelectorAll('.emby-tab-button'); @@ -435,13 +503,22 @@ } // Register onViewPage handler to sync active tab state - window.KefinTweaksUtils.onViewPage((view, element) => { + window.KefinTweaksUtils.onViewPage((view, element, hash) => { LOG('onViewPage handler triggered for view:', view); addClickListeners(); + // Get selected tab from hash + const hashParams = hash.includes('?') ? hash.split('?')[1] : ''; + const urlParams = new URLSearchParams(hashParams); + const currentTab = urlParams.get('tab'); + const currentTabIndex = currentTab ? parseInt(currentTab, 10) : 0; + // Sync active tab state when page view changes - syncActiveTabState().catch(err => { - ERR('Error in syncActiveTabState:', err); - }); + // This is only necessary for the home page with custom tabs + if ((view === 'home' || view === 'home.html')) { + syncActiveTabState(currentTabIndex).catch(err => { + ERR('Error in syncActiveTabState:', err); + }); + } }, { pages: SUPPORTED_PAGES // Only trigger for supported pages }); diff --git a/scripts/homeScreen.css b/scripts/homeScreen.css index 3ffb133..c6738d7 100644 --- a/scripts/homeScreen.css +++ b/scripts/homeScreen.css @@ -1,102 +1,20 @@ -/* Please use the CSS commented below to customize the order of KefinTweaks custom home sections */ - -/* -[data-custom-section-id="watchlist"] { - order: 60 !important; -} - -[data-custom-section-id="popular-tv-networks"] { - order: 61 !important; -} - -[data-custom-section-id="new-movies"] { - order: 30 !important; -} - -[data-custom-section-id="new-episodes"] { - order: 31 !important; -} -*/ +@import url("https://fonts.googleapis.com/css2?family=Alfa+Slab+One&family=Anton&family=Changa+One:ital@0;1&family=Exo:ital,wght@0,100..900;1,100..900&family=Orbitron:wght@400..900&family=Oswald:wght@200..700&family=Prata&family=Roboto:ital,wght@0,100..900;1,100..900&family=Unbounded:wght@200..900&family=VT323&display=swap"); .sections.homeSectionsContainer { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; + gap: 1rem; } -.homeSectionsContainer > .section0 { - order: 1; -} -.homeSectionsContainer > .section1 { - order: 2; -} -.homeSectionsContainer > .section2 { - order: 3; -} -.homeSectionsContainer > .section3 { - order: 4; -} -.homeSectionsContainer > .section4 { - order: 5; -} -.homeSectionsContainer > .section5 { - order: 6; -} -.homeSectionsContainer > .section6 { - order: 7; -} -.homeSectionsContainer > .section7 { - order: 8; -} -.homeSectionsContainer > .section8 { - order: 9; -} -.homeSectionsContainer > .section9 { - order: 10; -} -.homeSectionsContainer > .section10 { - order: 11; +.homePage [data-section-id] h2.sectionTitle, +.homePage [data-section-id] .sectionTitleTextButton > .material-icons { + margin-bottom: 0 !important; + margin-top: 0 !important; + padding-bottom: 0 !important; + padding-top: 0 !important; } -/* Halloween theme */ -.home-screen.halloween-theme.with-background #reactRoot:has(.libraryPage:not(.hide) .pageTabContent.is-active[data-index="0"])>.backgroundContainer::after { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: -25%; - background: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), url(https://ranaldsgift.github.io/KefinTweaks/pages/images/halloween.jpg); - background-position: 0px -250px; - background-size: auto 102%; -} - -/* Christmas theme */ -.home-screen.christmas-theme.with-background #reactRoot:has(.libraryPage:not(.hide) .pageTabContent.is-active[data-index="0"])>.backgroundContainer::after { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0%; - background: linear-gradient(rgba(0, 0, 0, 0.65), rgba(0, 0, 0, 0.85)), url('../pages/images/christmas.jpg'); - background-position: center; - background-size: cover; -} - -.snowverlay { - display: none; -} - -.home-screen.christmas-theme .snowverlay { - display: block; -} - -.home-screen h2.sectionTitle { - margin-bottom: .35em; - margin-top: 0; -} - -div#discovery-loading-indicator:not(.show)::before { +/* div#discovery-loading-indicator:not(.show)::before { content: 'Loading...'; position: absolute; top: 50%; @@ -107,41 +25,678 @@ div#discovery-loading-indicator:not(.show)::before { visibility: visible; text-align: center; opacity: 1; +} */ + +#discovery-loading-indicator { + order: 999999999 !important; + width: fit-content; + margin: 0 auto; } -#indexPage[data-discovery-ready] div#discovery-loading-indicator:not(.show)::before { - content: 'Discover More'; +#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show)::before { + content: "Discover More"; } -div#discovery-loading-indicator:not(.show)::after { - content: '\E313'; - font-family: 'Material Icons'; - position: absolute; - top: calc(50% + 0.4em); - left: 50%; - transform: translateX(-50%) translateY(-50%); - font-size: 1.5em; - color: #ffffff; - visibility: visible; - opacity: 1; +#indexPage[data-discovery-exhausted] #discovery-loading-indicator:not(.show)::before { + content: "We've run out of suggestions!"; } -.libraryPage:has(.load-more-discovery-btn) div#discovery-loading-indicator { - opacity: 0; +#discovery-loading-indicator:not(.show)::after { + content: "\E313"; + font-family: "Material Icons"; + font-size: 1.5em; + color: #ffffff; + visibility: visible; + opacity: 1; } -#indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator { +#indexPage[data-discovery-exhausted] #discovery-loading-indicator:not(.show)::after { + content: ""; +} + +[data-loading-discovery="true"] #discovery-loading-indicator, +.homeSectionsContainer[data-loading-discovery="true"] #discovery-loading-indicator, +.sections[data-loading-discovery="true"] #discovery-loading-indicator { + display: flex; +} + +/* Idle Discover More / exhausted prompt (chevron mode) */ +#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) { + display: flex !important; + visibility: visible !important; + opacity: 1 !important; + gap: 0; +} + +/* Hide Discover More / loading affordance outside Home + Discover categories */ +#indexPage .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) #discovery-loading-indicator, +.homePage .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) #discovery-loading-indicator, +.libraryPage + .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) + #discovery-loading-indicator, +#indexPage .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) .discovery-loading-indicator, +.homePage .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) .discovery-loading-indicator, +.libraryPage + .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) + .discovery-loading-indicator { + display: none !important; + visibility: hidden !important; + min-height: 0 !important; + height: 0 !important; + overflow: hidden !important; + padding: 0 !important; + margin: 0 !important; + opacity: 0 !important; +} + +#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) .spinner { + visibility: hidden; + opacity: 0; + display: none; +} + +/* #indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator { display: none; -} +} */ -#indexPage:not([data-discovery-ready]) div#discovery-loading-indicator:not(.show)::after { - content: ''; +#indexPage:not([data-discovery-ready]) #discovery-loading-indicator:not(.show)::after { + content: ""; } /* Ensure Editor's Choice Banner appears at top of home screen if enabled */ .editorsChoiceContainer { - order: 1 !important; + order: 1 !important; } .spotlight-section { - width: calc(100% - (2 * max(env(safe-area-inset-left),3.3%))); + width: calc(100% - (2 * max(env(safe-area-inset-left), 3.3%))); +} + +.load-more-discovery-btn { + display: block; + visibility: visible; + order: 99998 !important; + margin: 2em auto; +} + +.sections.loading-discovery .load-more-discovery-btn { + visibility: hidden !important; +} + +/* Discovery section loading indicator */ +.sections.loading-discovery .discovery-loading-indicator { + visibility: visible !important; + opacity: 1 !important; +} + +.discovery-loading-indicator { + display: none; + appearance: none; + -webkit-appearance: none; + background: transparent; + border: none; + margin: 0; + padding: 0; + width: 100%; + font: inherit; + text-align: center; + color: inherit; + cursor: default; + opacity: 1; + flex-direction: column; + gap: 10px; + justify-content: center; + align-items: center; + border-radius: 8px; + font-size: 14px; + color: #666; + transition: opacity 0.3s ease; + position: relative; + justify-content: start; +} + +/* Chevron mode: clickable Discover More (desktop optional; TV focus stop) */ +#indexPage[data-discovery-ready]:not([data-discovery-exhausted]) #discovery-loading-indicator:not(.show) { + cursor: pointer; +} + +/* TV: real button + emby-button for spatial nav; keep Discover More chrome unstyled */ +.layout-tv button#discovery-loading-indicator.emby-button, +.layout-tv #discovery-loading-indicator.discovery-load-more-focusable { + outline: none; + min-height: 3em; + padding: 0.5em 1em; + box-sizing: border-box; + background: transparent; + border: 0; + box-shadow: none; + transform: none; + -webkit-appearance: none; + appearance: none; + font: inherit; + color: inherit; + cursor: pointer; +} + +.layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus, +.layout-tv button#discovery-loading-indicator.emby-button.show-focus:focus { + background-color: var(--theme-primary-color, #00a4dc) !important; + background: var(--theme-primary-color, #00a4dc) !important; + color: #fff !important; + outline: none; + box-shadow: none; + border-radius: 8px; +} + +.layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus::before, +.layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus::after, +.layout-tv button#discovery-loading-indicator.emby-button.show-focus:focus::before, +.layout-tv button#discovery-loading-indicator.emby-button.show-focus:focus::after { + color: #fff !important; +} + +.discovery-loading-indicator .spinner { + width: 30px; + height: 30px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: var(--theme-primary-color, #3498db); + border-radius: 50%; + animation: discovery-spin 1s linear infinite; +} + +@keyframes discovery-spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* Hide scroll buttons on mobile using Jellyfin's layout classes */ +.layout-mobile .emby-scrollbuttons, +.layout-tv .emby-scrollbuttons { + display: none !important; +} + +.load-more-discovery-btn { + display: block; +} + +.sections[data-loading-discovery="true"] .load-more-discovery-btn { + display: none !important; +} + +.sections[data-loading-discovery="true"] .discovery-loading-indicator, +.sections[data-loading-discovery="true"] #discovery-loading-indicator, +.homeSectionsContainer[data-loading-discovery="true"] #discovery-loading-indicator { + display: flex !important; + opacity: 1 !important; +} + +.discovery-loading-indicator { + display: none; + opacity: 0; + flex-direction: column; + gap: 10px; + justify-content: center; + align-items: center; + padding: 20px; + border-radius: 8px; + font-size: 14px; + color: #666; + transition: opacity 0.3s ease; + position: relative; + justify-content: start; + min-height: 200px; +} + +.discovery-loading-indicator .spinner { + width: 30px; + height: 30px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: var(--theme-primary-color, #3498db); + border-radius: 50%; + animation: discovery-spin 1s linear infinite; +} + +@keyframes discovery-spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.homeSectionsContainer [data-section-id="popularTVNetworks"] .card .cardText { + visibility: hidden; +} + +/* .homeSectionsContainer:not(.kefinHomeSectionsContainer) { + display: none !important; +} */ + +.homeSectionsContainer [data-section-id*="thematic-"] .card[data-type="Folder"] .cardScalable > a.cardImageContainer::before { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: linear-gradient(#000000, rgb(0 0 0 / 0%) 50%); + box-shadow: inset 0px 0px 20px 5px black; +} + +.layout-mobile .homeSectionsContainer [data-section-id*="thematic-"] .card[data-type="Folder"] a.cardImageContainer::after { + top: 0.5rem; + left: 0.5rem; + font-size: 1.5rem !important; + line-height: 1.8rem !important; +} + +.homeSectionsContainer .card[data-type="TvChannel"] a.cardImageContainer:not([style])::after { + content: attr(aria-label); + font-size: 2rem; + text-wrap: auto; + text-align: center; +} + +.emby-scroller-container[data-section-id="liveTv-channels"] .itemsContainer { + gap: 4rem; +} +.layout-mobile .emby-scroller-container[data-section-id="liveTv-channels"] .itemsContainer { + gap: 2rem; +} + +[data-section-id="upcoming"] .missingIndicator { + display: none; +} + +.homePage[data-discovery-ready] #discovery-loading-indicator:not(.show) { + min-height: 50px; +} + +@keyframes fadeInDelayed { + 0%, + 99% { + opacity: 0; + visibility: hidden; + } + 100% { + opacity: 1; + visibility: visible; + } +} + +[data-section-id="browse-by-studio"] .card { + width: 8vw !important; +} + +[data-section-id="browse-by-studio"] .card, +[data-section-id="browse-by-studio"] .card .cardScalable, +[data-section-id="browse-by-studio"] .card .cardImageContainer, +[data-section-id="browse-by-studio"] .card .cardPadder { + border-radius: 0px !important; +} + +/* Hide scroll buttons on mobile and tablet layouts */ +@media (max-width: 768px) { + .emby-scrollbuttons { + display: none !important; + } + + [data-section-id="browse-by-studio"] .card { + width: 30vw !important; + } +} + +/* Home screen chrome: create section + category rail */ +.kefin-home-chrome { + position: fixed; + z-index: 12; + pointer-events: none; + opacity: 0.18; + transition: opacity 0.2s ease; +} +@media (hover: hover) and (pointer: fine) { + .kefin-home-chrome:hover, + .kefin-home-chrome:focus-within { + opacity: 1; + } +} +/* TV: rail must be fully visible while focused (no hover) */ +.layout-tv #kefin-home-category-rail:focus-within, +.layout-tv #kefin-home-category-rail:focus-within .kefin-home-category-rail { + opacity: 1; +} +.kefin-home-chrome.hide, +#kefin-home-category-rail.hide, +#kefin-home-category-rail.hide .kefin-home-category-rail { + display: none !important; +} + +/* Hide home chrome off-home via root page key (no :has; JMP-safe) */ +html:not([data-active-page="home"]) #kefin-home-category-rail, +html:not([data-active-page="home"]) #kefin-home-create-section-btn { + display: none !important; +} + +.kefin-home-chrome button, +#kefin-home-category-rail button { + pointer-events: auto; +} +.kefin-home-create-section { + left: 0; + bottom: 0.25em; +} +.kefin-home-create-section-btn { + padding: 0.25em; +} +.kefin-home-create-section-btn .material-icons { + font-size: 1.75rem; +} + +/* Outer sectionTabs wrapper — no native header layout */ +#kefin-home-category-rail.sectionTabs { + position: static; + display: contents; +} + +/* Inner visual bar — keep our layout over skinHeader / MuiAppBar positioning */ +:where(.kefin-home-category-rail) { + --AppBar-background: var(--jf-palette-AppBar-defaultBg); + --AppBar-color: var(--jf-palette-text-primary); + background-color: var(--jf-palette-AppBar-darkBg, var(--AppBar-background)); + color: var(--jf-palette-AppBar-darkColor, var(--AppBar-color)); + z-index: 10; + gap: 0.25rem; + pointer-events: auto; + display: flex; +} +@media (hover: hover) and (pointer: fine) { + #kefin-home-category-rail .kefin-home-category-rail:hover, + #kefin-home-category-rail .kefin-home-category-rail:focus-within { + opacity: 1; + background: #0f0f0f !important; + border-radius: 0 25px 25px 0; + box-shadow: 1px 0px 5px -1px black; + } +} +.kefin-home-category-item { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.15rem; + min-width: 0; +} +.kefin-home-category-btn { + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0.75; + -webkit-tap-highlight-color: transparent; +} +.kefin-home-category-btn.is-active, +.kefin-home-category-item.is-active .kefin-home-category-btn { + opacity: 1; + color: var(--theme-primary-color, #00a4dc); + background: rgba(0, 164, 220, 0.18); +} +.kefin-home-category-btn .material-icons { + font-size: 1.35rem; + top: unset !important; +} +.kefin-home-category-btn-label { + display: none; + font-size: 0.65rem; + line-height: 1.1; + font-weight: 500; + letter-spacing: 0.01em; + max-width: 4.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + text-align: center; +} +.kefin-home-category-item.is-active .kefin-home-category-btn-label { + color: var(--theme-primary-color, #00a4dc); +} + +@media (max-width: 899px) { + #kefin-home-category-rail .kefin-home-category-rail { + top: auto; + left: 0; + right: 0; + bottom: 0; + flex-direction: row; + justify-content: space-evenly; + align-items: center; + gap: 0.15rem; + padding: 0.4rem 0.5rem calc(0.4rem + env(safe-area-inset-bottom, 0px)) !important; + box-shadow: 1px 1px 1px 1px #fff !important; + border-radius: 0; + opacity: 1; + pointer-events: auto; + margin: 0 !important; + border: none !important; + transition: + transform 0.25s ease, + opacity 0.25s ease; + } + :where(#kefin-home-category-rail) .kefin-home-category-rail:hover, + :where(#kefin-home-category-rail) .kefin-home-category-rail:focus-within { + opacity: 1; + } + .kefin-home-category-btn-label { + display: block; + } + .kefin-home-category-btn:hover, + .kefin-home-category-btn:focus, + .kefin-home-category-btn:focus-visible, + .kefin-home-category-btn:active { + opacity: 0.75; + background: transparent; + color: inherit; + } + .kefin-home-category-btn.is-active, + .kefin-home-category-btn.is-active:hover, + .kefin-home-category-btn.is-active:focus, + .kefin-home-category-btn.is-active:focus-visible, + .kefin-home-category-btn.is-active:active, + .kefin-home-category-item.is-active .kefin-home-category-btn, + .kefin-home-category-item.is-active .kefin-home-category-btn:hover, + .kefin-home-category-item.is-active .kefin-home-category-btn:focus, + .kefin-home-category-item.is-active .kefin-home-category-btn:focus-visible, + .kefin-home-category-item.is-active .kefin-home-category-btn:active { + opacity: 1; + color: var(--theme-primary-color, #00a4dc); + background: rgba(0, 164, 220, 0.18); + } + #kefin-home-category-rail.is-scroll-hidden .kefin-home-category-rail { + transform: translateY(100%); + pointer-events: none; + opacity: 0; + } + .kefin-home-create-section, + #kefin-home-create-section-btn { + display: none !important; + } + .homePage:not(.hide) { + padding-bottom: calc(4.25rem + env(safe-area-inset-bottom, 0px)); + } +} + +.kefin-section-more-toggle { + margin-left: auto !important; +} + +@media (min-width: 900px) { + #kefin-home-category-rail .kefin-home-category-rail, + #kefin-home-category-rail .kefin-home-category-rail::before { + background: none !important; + background-color: transparent !important; + } + #kefin-home-category-rail .kefin-home-category-rail { + position: fixed !important; + z-index: 10; + top: 50vh !important; + left: 0 !important; + right: auto !important; + bottom: auto !important; + width: auto !important; + height: auto !important; + max-width: none !important; + flex-direction: column !important; + transform: translateY(-50%); + overflow: visible !important; + padding: 5px 0 !important; + margin: 0 !important; + backdrop-filter: none; + border: none !important; + display: grid !important; + grid-auto-rows: 1fr; + } +} + +#kefin-home-category-rail .kefin-home-category-rail { + height: auto !important; +} + +.sections.homeSectionsContainer[data-category="discovery"] { + position: relative; +} + +.sections.homeSectionsContainer[data-category="discovery"]::before { + content: "\E87A"; + font-family: "Material Icons"; + padding-left: max(env(safe-area-inset-left), 3.3%); + font-size: 1.5rem; + margin: 0; + padding-bottom: .2em; + padding-top: 1em; + display: block; + line-height: 1.2; +} + +.sections.homeSectionsContainer[data-category="discovery"]::after { + content: "Discover Something To Watch"; + font-family: inherit; + font-size: 1.5rem; + position: absolute; + left: calc(max(env(safe-area-inset-left), 3.3%) + 1.7em); + top: 1em; + line-height: 1.2; + pointer-events: none; +} + +html:not([data-active-page="home"]) .snowverlay { + display: none; +} + +// Hide the seasonal animations on mobile by default +@media (max-width: 899px) { + .snowverlay { + display: none !important; + } +} + +html[data-active-page="home"] body[data-seasonal-background="true"] #reactRoot > .backgroundContainer::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0%; + background: linear-gradient(rgba(0, 0, 0, 0.65), rgba(0, 0, 0, 0.85)), var(--seasonal-background-image); + background-position: center; + background-size: cover; +} + +/* Figure out a better way to do this without requiring :has() */ +/* .homeSectionsContainer:has(> .verticalSection.section0:empty):has(> .verticalSection.section1:empty):has( + > .verticalSection.section2:empty + ):has(> .verticalSection.section3:empty):has(> .verticalSection.section4:empty):has(> .verticalSection.section5:empty):has( + > .verticalSection.section6:empty + ):has(> .verticalSection.section7:empty):has(> .verticalSection.section8:empty):has(> .verticalSection.section9:empty):not( + :has(> .section9 ~ :not(.discovery-loading-indicator)) + )::after { + content: "Customize your Home Screen from your User Settings > Home in the top right corner."; + text-align: center; + margin: 3em auto; + display: block; + animation: fadeInDelayed 0.1s linear 1s forwards; + opacity: 0; + visibility: hidden; +} + */ + +.kefin-home-category-btn span:is(.emby-button-foreground) { + top: 0px !important; +} + +#kefin-home-category-rail[data-category]:not([data-category="none"]) ~ #slides-container { + display: none !important; +} + +.sections.homeSectionsContainer:not([data-category="none"]), +html.layout-mobile .homePage .sections.homeSectionsContainer:not([data-category="none"]) { + margin-top: 0 !important; + top: 0 !important; +} + +html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] header.MuiAppBar-root, +html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] .skinHeader:not(.osdHeader) .headerTop { + box-shadow: 0 3px 12px rgba(180, 0, 0, 0.7) !important; + border-bottom: 2px solid #8b0000; +} +html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] .skinHeader:not(.osdHeader) .headerTop { + position: relative; +} + +html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] header.MuiAppBar-root::after, +html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] .skinHeader:not(.osdHeader) .headerTop::after { + content: ""; + position: absolute; + top: 100%; + left: 0; + width: 100%; + height: 14px; + pointer-events: none; + z-index: 9998; + background-color: #8b0000; + -webkit-mask-image: url('data:image/svg+xml;utf8,'); + mask-image: url('data:image/svg+xml;utf8,'); + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +/* Procedural blood edge (SVG via --halloween-blood-splatter from homeScreen3.js) */ +html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] header.MuiAppBar-root::before, +html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] .skinHeader:not(.osdHeader) .headerTop::before { + content: ""; + position: absolute; + top: auto; + bottom: 0; + left: 0; + width: 100%; + height: var(--halloween-blood-band, 10px); + pointer-events: none; + opacity: var(--halloween-blood-opacity, 0.95); + z-index: 1; + background-image: var(--halloween-blood-splatter, none); + background-repeat: no-repeat !important; + background-size: 100% 100%; + background-position: bottom center; +} + +html[data-active-page="home"] body[data-seasonal-background="true"] .backdropContainer .backdropImage { + display: none !important; +} + +@media (hover: hover) and (pointer: fine) and (min-width: 900px) { + #kefin-home-category-rail .kefin-home-category-rail.kefin-home-chrome { + pointer-events: auto; + } } \ No newline at end of file diff --git a/scripts/homeScreen.js b/scripts/homeScreen.js index 0f7e147..0059926 100644 --- a/scripts/homeScreen.js +++ b/scripts/homeScreen.js @@ -1112,7 +1112,7 @@ try { const response = await fetch(url, { headers: { - "Authorization": apiHelper.getAuthHeader() + "Authorization": `MediaBrowser Token="${token}"` } }); @@ -1552,7 +1552,7 @@ try { const response = await fetch(url, { headers: { - 'Authorization': apiHelper.getAuthHeader() + 'Authorization': `MediaBrowser Token="${ApiClient.accessToken()}"` } }); @@ -1649,7 +1649,7 @@ try { const response = await fetch(`${serverUrl}/Genres?IncludeItemTypes=Movie`, { headers: { - "Authorization": apiHelper.getAuthHeader() + "Authorization": `MediaBrowser Token="${token}"` } }); @@ -1745,7 +1745,7 @@ queries.map(async (query) => { const response = await fetch(query.url, { headers: { - "Authorization": apiHelper.getAuthHeader() + "Authorization": `MediaBrowser Token="${token}"` } }); @@ -1854,7 +1854,7 @@ const response = await fetch(url, { headers: { - "Authorization": apiHelper.getAuthHeader() + "Authorization": `MediaBrowser Token="${token}"` } }); @@ -2214,7 +2214,7 @@ try { const response = await fetch(url, { headers: { - "Authorization": apiHelper.getAuthHeader() + "Authorization": `MediaBrowser Token="${token}"` } }); @@ -2251,7 +2251,7 @@ try { const response = await fetch(url, { headers: { - "Authorization": apiHelper.getAuthHeader() + "Authorization": `MediaBrowser Token="${token}"` } }); @@ -2565,7 +2565,7 @@ const studiosUrl = `${serverUrl}/Studios?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series&Recursive=true&Fields=DateCreated%2CPrimaryImageAspectRatio&StartIndex=0&ParentId=${library.Id}&userId=${userId}${filterParam}`; const response = await fetch(studiosUrl, { headers: { - "Authorization": apiHelper.getAuthHeader() + "Authorization": `MediaBrowser Token="${token}"` } }); @@ -2646,7 +2646,7 @@ const response = await fetch(url, { headers: { - "Authorization": apiHelper.getAuthHeader() + "Authorization": `MediaBrowser Token="${token}"` } }); @@ -2680,7 +2680,7 @@ const response = await fetch(url, { headers: { - "Authorization": apiHelper.getAuthHeader() + "Authorization": `MediaBrowser Token="${token}"` } }); @@ -3828,7 +3828,7 @@ const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`; const movieResponse = await fetch(movieUrl, { headers: { - "Authorization": apiHelper.getAuthHeader() + "Authorization": `MediaBrowser Token="${token}"` } }); @@ -3890,7 +3890,7 @@ const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`; const movieResponse = await fetch(movieUrl, { headers: { - "Authorization": apiHelper.getAuthHeader() + "Authorization": `MediaBrowser Token="${token}"` } }); @@ -3958,7 +3958,7 @@ const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`; const movieResponse = await fetch(movieUrl, { headers: { - "Authorization": apiHelper.getAuthHeader() + "Authorization": `MediaBrowser Token="${token}"` } }); @@ -4708,7 +4708,7 @@ const response = await fetch(url, { headers: { - "Authorization": apiHelper.getAuthHeader() + "Authorization": `MediaBrowser Token="${token}"` } }); @@ -5391,7 +5391,7 @@ const serverAddress = ApiClient.serverAddress(); const token = ApiClient.accessToken(); const genresResponse = await fetch(`${serverAddress}/Genres?IncludeItemTypes=Movie`, { - headers: { "Authorization": apiHelper.getAuthHeader() } + headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!genresResponse.ok) { diff --git a/scripts/homeScreen3.js b/scripts/homeScreen3.js new file mode 100644 index 0000000..352fa4f --- /dev/null +++ b/scripts/homeScreen3.js @@ -0,0 +1,3091 @@ +// KefinTweaks Home Screen v3 (Config-Driven & Streamlined) +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks HomeScreen3]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks HomeScreen3]', ...args); + const ERR = (...args) => console.error('[KefinTweaks HomeScreen3]', ...args); + + // Configuration + const PRE_FETCH_DISCOVERY_DATA = false; // If true, fetches first discovery group immediately. If false, waits for scroll/click. + /** Max sections resolved/rendered per discovery group (infinite/chevron pages). */ + const DISCOVERY_SECTIONS_PER_GROUP = 100; + /** IntersectionObserver rootMargin for discovery section progressive enhance (batches + any discovery enhance). */ + const DISCOVERY_ENHANCE_ROOT_MARGIN = '30% 0px 30% 0px'; + /** How many viewports of content to keep below the fold before idling infinite discovery. */ + const DISCOVERY_LEAD_VIEWPORTS = 1.5; + /** Max consecutive auto-fill groups in one wave (empty/tiny sections). */ + const DISCOVERY_LEAD_FILL_MAX_GROUPS = 8; + /** Consecutive all-empty discovery groups before marking exhausted. */ + const DISCOVERY_EMPTY_GROUP_STREAK_MAX = 6; + /** Settle delay after paint before counting surviving sections (enhance may remove empties). */ + const DISCOVERY_EMPTY_GROUP_SETTLE_MS = 400; + + // Dependencies + const ApiHelper = window.apiHelper; + const Config = window.KefinHomeConfig2; // Updated to use Config2 + const PeopleCache = window.PeopleCache; + const StudiosCache = window.StudiosCache; + + if (!Config) { + ERR('Configuration not found! Ensure homeScreenConfig2.js is loaded.'); + return; + } + + /** + * Hide native Jellyfin .sectionN rows only once homeScreen3 has successfully + * started (so clients that cannot parse this file keep the native home). + * Persist for the session via a single injected stylesheet. + */ + function ensureNativeHomeSectionsHidden() { + const styleId = 'kefin-hide-native-homesections'; + if (document.getElementById(styleId)) return; + const style = document.createElement('style'); + style.id = styleId; + style.textContent = [ + '.section0:not([data-page]),', + '.section1:not([data-page]),', + '.section2:not([data-page]),', + '.section3:not([data-page]),', + '.section4:not([data-page]),', + '.section5:not([data-page]),', + '.section6:not([data-page]),', + '.section7:not([data-page]),', + '.section8:not([data-page]),', + '.section9:not([data-page]) {', + ' display: none !important;', + '}' + ].join('\n'); + (document.head || document.documentElement).appendChild(style); + } + ensureNativeHomeSectionsHidden(); + + /** + * Flatten section groups into a flat array of sections + * @param {Array} groups - Array of HomeScreenSectionGroup objects + * @returns {Array} Flat array of all sections + */ + function flattenSectionGroups(groups) { + if (!Array.isArray(groups)) return []; + const flattened = []; + groups.forEach(group => { + if (group && Array.isArray(group.sections)) { + flattened.push(...group.sections); + } + }); + return flattened; + } + + // State + const state = { + renderedSections: new Set(), + renderedDiscoveryIds: { + genres: new Set(), + genresSeries: new Set(), + seriesStudios: new Set(), + seriesStudiosTopRated: new Set(), + collections: new Set(), + people: new Set(), + similar: new Set(), + watchlist: new Set(), + customDiscoverySections: new Set() + }, + currentDiscoveryGenre: null, + currentDiscoveryStudio: null, + isPeopleCacheComplete: false, + discoveryGroupIndex: 0, + isRenderingDiscovery: false, + discoveryEnabled: true, + discoveryBufferPromise: null, + discoveryBuffer: [], + /** Unresolved section configs left after capping a discovery group render. */ + discoveryGroupRemainder: [], + discoverySectionOrder: 100000000, + discoverySectionsRemain: true, + ensuringDiscoveryBuffer: false, + discoveryNeedsMoreScroll: false, + /** Consecutive auto-fill loads while lead margin is short (reset when lead OK or user scroll loads). */ + discoveryLeadFillCount: 0, + /** Consecutive discovery groups where every section enhanced to empty / was removed. */ + discoveryEmptyGroupStreak: 0, + discoveryMode: null, // 'infinite' | 'chevron' + infiniteScrollHandler: null, + discoveryWheelHandler: null, + discoveryTouchStartHandler: null, + discoveryTouchMoveHandler: null, + discoveryScrollDebounceTimer: null, + userDisplayPreferences: null, + firstDiscoveryBuffer: null, + getDisplayPrefernces: null, + kefinNextUp: false, + kefinContinueWatching: false, + kefinLatestMedia: false, + jellyfinOrders: {}, + homePaintedEventFired: false, + isRenderingHome: false + }; + + async function fetchDisplayPreferences() { + if (!window.userHelper?.getUserDisplayPreferences) { + WARN('userHelper.getUserDisplayPreferences unavailable'); + return null; + } + const { promise, cached } = await window.userHelper.getUserDisplayPreferences({ cacheOnly: true }); + const prefs = cached || await promise; + state.userDisplayPreferences = prefs; + return prefs; + } + state.getDisplayPrefernces = fetchDisplayPreferences; + + // Cache used to support re-rendering our sections after Jellyfin overwrites the container + const sectionCache = { + renderedSections: [], // Array of section configs + results + fragmentCache: null, // Reserved for future use if we cache DOM fragments + isInitialRender: true, // Track if this is first render for the current view + jellyfinHasRendered: false, // Track if Jellyfin has rendered native home sections + pendingJellyfinSectionObservers: [] + }; + + // Create loading indicator as last child of the sections container (keeps height during load; TV Down focus) + function createDiscoveryLoadingIndicator(container) { + // Prefer the provided container, but fall back to the standard sections container + if (!container) { + container = document.querySelector('.homePage:not(.hide) #homeTab .sections') + || document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); + } + + let loadingEl = document.querySelector('.homePage:not(.hide) #discovery-loading-indicator') + || document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); + + // Migrate legacy div indicator to a real button (Jellyfin TV spatial nav targets buttons) + if (loadingEl && loadingEl.tagName !== 'BUTTON') { + const replacement = document.createElement('button'); + replacement.type = 'button'; + replacement.id = loadingEl.id; + replacement.className = loadingEl.className; + replacement.innerHTML = loadingEl.innerHTML || '
'; + loadingEl.replaceWith(replacement); + loadingEl = replacement; + } + + if (loadingEl) { + if (container) { + container.appendChild(loadingEl); + } + bindDiscoveryLoadMoreControl(loadingEl); + syncDiscoveryLoadMoreFocus(loadingEl); + return loadingEl; + } + + if (!container) { + WARN('Discovery loading indicator: sections container not found'); + return null; + } + + loadingEl = document.createElement('button'); + loadingEl.type = 'button'; + loadingEl.className = 'discovery-loading-indicator'; + loadingEl.id = 'discovery-loading-indicator'; + loadingEl.innerHTML = ` +
+ `; + + container.appendChild(loadingEl); + LOG('Created discovery loading indicator as last child of sections container'); + bindDiscoveryLoadMoreControl(loadingEl); + syncDiscoveryLoadMoreFocus(loadingEl); + return loadingEl; + } + + function removeDiscoveryLoadingIndicator() { + document.querySelectorAll('#discovery-loading-indicator').forEach((el) => el.remove()); + } + + function isDiscoveryLoadMoreInteractive() { + if (state.discoveryMode !== 'chevron') return false; + if (state.discoverySectionsRemain === false) return false; + if (state.isRenderingDiscovery) return false; + const page = getDiscoveryHomePageEl(); + if (page?.dataset?.discoveryExhausted === 'true') return false; + const indicator = document.querySelector('#discovery-loading-indicator'); + if (indicator?.classList.contains('show')) return false; + return true; + } + + function syncDiscoveryLoadMoreFocus(indicator) { + const el = indicator + || document.querySelector('.homePage:not(.hide) #discovery-loading-indicator') + || document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator') + || document.querySelector('#discovery-loading-indicator'); + if (!el) return; + + const tv = document.documentElement.classList.contains('layout-tv'); + const interactive = isDiscoveryLoadMoreInteractive(); + + if (tv && interactive) { + el.disabled = false; + el.removeAttribute('disabled'); + el.setAttribute('tabindex', '0'); + el.setAttribute('aria-label', 'Discover More'); + // emby-button: Jellyfin TV spatial nav treats plain controls as Tab-only otherwise + el.classList.add('emby-button'); + el.classList.add('show-focus'); + el.classList.add('discovery-load-more-focusable'); + } else { + el.setAttribute('tabindex', '-1'); + el.classList.remove('emby-button'); + el.classList.remove('show-focus'); + el.classList.remove('discovery-load-more-focusable'); + if (state.discoverySectionsRemain === false || getDiscoveryHomePageEl()?.dataset?.discoveryExhausted === 'true') { + el.setAttribute('aria-label', "We've run out of suggestions!"); + } else { + el.removeAttribute('aria-label'); + } + } + } + + function activateDiscoveryLoadMore() { + if (!isDiscoveryLoadMoreInteractive()) return false; + if (!isDiscoveryHomeContext()) return false; + LOG('Discover More activated; loading next discovery group...'); + renderNextDiscoveryGroup(); + return true; + } + + function bindDiscoveryLoadMoreControl(indicator) { + if (!indicator || indicator.dataset.loadMoreBound === '1') return; + indicator.dataset.loadMoreBound = '1'; + + indicator.addEventListener('click', (e) => { + if (!isDiscoveryLoadMoreInteractive()) return; + e.preventDefault(); + e.stopPropagation(); + activateDiscoveryLoadMore(); + }); + + indicator.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + if (!isDiscoveryLoadMoreInteractive()) return; + e.preventDefault(); + e.stopPropagation(); + activateDiscoveryLoadMore(); + }); + } + + function getDiscoveryHomePageEl() { + return document.querySelector('#indexPage') + || document.querySelector('.homePage:not(.hide)') + || document.querySelector('.libraryPage:not(.hide)'); + } + + function setDiscoveryPageAttrs({ ready = false, infinite = false, exhausted = false } = {}) { + const page = getDiscoveryHomePageEl(); + if (!page) return; + if (ready) page.dataset.discoveryReady = 'true'; + else delete page.dataset.discoveryReady; + if (infinite) page.dataset.infiniteScroll = 'true'; + else delete page.dataset.infiniteScroll; + if (exhausted) page.dataset.discoveryExhausted = 'true'; + else delete page.dataset.discoveryExhausted; + syncDiscoveryLoadMoreFocus(); + } + + function showDiscoveryLoadingIndicator() { + const loadingIndicator = createDiscoveryLoadingIndicator( + document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer') + ); + if (!loadingIndicator) return; + loadingIndicator.classList.add('show'); + loadingIndicator.style.visibility = 'visible'; + loadingIndicator.style.display = 'flex'; + syncDiscoveryLoadMoreFocus(loadingIndicator); + } + + function hideDiscoveryLoadingIndicator() { + const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); + if (!loadingIndicator) return; + loadingIndicator.classList.remove('show'); + // Chevron / exhausted idle UI is shown via data-discovery-ready CSS + if (state.discoveryMode === 'chevron' || state.discoverySectionsRemain === false) { + loadingIndicator.style.visibility = ''; + loadingIndicator.style.display = ''; + } else { + loadingIndicator.style.visibility = 'hidden'; + loadingIndicator.style.display = 'none'; + } + syncDiscoveryLoadMoreFocus(loadingIndicator); + } + + function isDiscoveryHomeContext() { + const currentView = window.KefinTweaksUtils?.getCurrentView(); + const isHomePage = currentView === 'home' || currentView === 'home.html'; + if (!isHomePage) return false; + const activeTab = document.querySelector('.headerTabs .emby-tab-button-active'); + if (!activeTab || activeTab.getAttribute('data-index') !== '0') return false; + return true; + } + + function removeDiscoveryScrollListeners() { + if (state.discoveryScrollDebounceTimer) { + clearTimeout(state.discoveryScrollDebounceTimer); + state.discoveryScrollDebounceTimer = null; + } + if (state.infiniteScrollHandler) { + window.removeEventListener('scroll', state.infiniteScrollHandler); + state.infiniteScrollHandler = null; + } + if (state.discoveryWheelHandler) { + window.removeEventListener('wheel', state.discoveryWheelHandler); + state.discoveryWheelHandler = null; + } + if (state.discoveryTouchStartHandler) { + window.removeEventListener('touchstart', state.discoveryTouchStartHandler); + state.discoveryTouchStartHandler = null; + } + if (state.discoveryTouchMoveHandler) { + window.removeEventListener('touchmove', state.discoveryTouchMoveHandler); + state.discoveryTouchMoveHandler = null; + } + const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); + if (container) delete container.dataset.discoveryHandler; + } + + function markDiscoveryExhausted() { + if (state.discoverySectionsRemain === false + && getDiscoveryHomePageEl()?.dataset?.discoveryExhausted === 'true') { + return; + } + state.discoverySectionsRemain = false; + state.discoveryBuffer = null; + state.discoveryGroupRemainder = []; + state.discoveryEmptyGroupStreak = 0; + state.discoveryLeadFillCount = 0; + removeDiscoveryScrollListeners(); + setDiscoveryPageAttrs({ ready: true, infinite: false, exhausted: true }); + const loadingIndicator = createDiscoveryLoadingIndicator( + document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer') + ); + if (loadingIndicator) { + loadingIndicator.classList.remove('show'); + loadingIndicator.style.visibility = ''; + loadingIndicator.style.display = ''; + syncDiscoveryLoadMoreFocus(loadingIndicator); + } + LOG("Discovery exhausted — We've run out of suggestions!"); + } + + /** + * After a group paints, wait for enhance/empty dismiss, then update empty-group streak. + * @param {Element} container + * @param {Array<{ config?: { id?: string } }>} bufferedSections + */ + function scheduleDiscoveryEmptyGroupStreakCheck(container, bufferedSections) { + const ids = (bufferedSections || []) + .map((s) => s?.config?.id) + .filter((id) => id != null) + .map((id) => String(id)); + if (!ids.length || !container) return; + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setTimeout(() => { + if (state.discoverySectionsRemain === false) return; + if (!isDiscoveryHomeContext()) return; + + const survivors = ids.filter((id) => { + const escaped = + typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape(id) + : id.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const el = container.querySelector(`[data-section-id="${escaped}"]`); + return !!(el && el.isConnected); + }); + + if (survivors.length === 0) { + state.discoveryEmptyGroupStreak += 1; + LOG('Discovery group left no surviving sections', { + streak: state.discoveryEmptyGroupStreak, + groupSize: ids.length, + }); + if (state.discoveryEmptyGroupStreak >= DISCOVERY_EMPTY_GROUP_STREAK_MAX) { + markDiscoveryExhausted(); + } + } else { + state.discoveryEmptyGroupStreak = 0; + } + }, DISCOVERY_EMPTY_GROUP_SETTLE_MS); + }); + }); + } + + // Legacy helper kept for compatibility – no longer used in the main flow + async function waitForHomeSectionsContainer() { + + const maxAttempts = 50; + let attempts = 0; + let homeSectionsContainer = null; + while (attempts < maxAttempts && !homeSectionsContainer) { + homeSectionsContainer = document.querySelector('.homePage:not(.hide) #homeTab .homeSectionsContainer'); + if (homeSectionsContainer) { + break; + } + attempts++; + await new Promise(resolve => setTimeout(resolve, 50)); + } + + if (!homeSectionsContainer) { + LOG('Home sections container not found'); + return false; + } + + return homeSectionsContainer; + } + + const HOME_SECTIONS_SELECTOR = '.homePage:not(.hide) #homeTab.is-active .sections'; + let homeSectionsReadyWaitGeneration = 0; + + function disconnectHomeSectionsReadyObserver() { + // Cancels in-flight waitForElement/poll by bumping generation + homeSectionsReadyWaitGeneration += 1; + LOG('Cancelled home sections ready wait'); + } + + function connectHomeSectionsReadyObserver() { + const generation = ++homeSectionsReadyWaitGeneration; + + const existing = document.querySelector(HOME_SECTIONS_SELECTOR); + if (existing) { + LOG('Home sections container already present; enhancing'); + enqueueHomeViewJob({ type: 'home', hash: window.location.hash || '' }); + return; + } + + const onFound = () => { + if (generation !== homeSectionsReadyWaitGeneration) return; + if (!document.querySelector(HOME_SECTIONS_SELECTOR)) return; + LOG('Home sections container appeared; enhancing'); + enqueueHomeViewJob({ type: 'home', hash: window.location.hash || '' }); + }; + + const utils = window.KefinTweaksUtils; + if (utils?.waitForElement) { + LOG('Waiting for home sections via waitForElement'); + utils.waitForElement(HOME_SECTIONS_SELECTOR, { timeout: 20000, quiet: true }).then((el) => { + if (el) onFound(); + }); + return; + } + if (utils?.pollUntil) { + LOG('Waiting for home sections via pollUntil'); + utils.pollUntil( + () => (generation === homeSectionsReadyWaitGeneration + ? document.querySelector(HOME_SECTIONS_SELECTOR) + : true), + { intervalMs: 200, timeoutMs: 20000 } + ).then((el) => { + if (el && el !== true) onFound(); + }); + return; + } + WARN('KefinTweaksUtils wait helpers unavailable; cannot wait for home sections container'); + } + + function mulberry32(seed) { + let t = seed >>> 0; + return () => { + t += 0x6D2B79F5; + let r = Math.imul(t ^ (t >>> 15), 1 | t); + r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); + return ((r ^ (r >>> 14)) >>> 0) / 4294967296; + }; + } + + /** + * Dense procedural blood edge for Halloween header (mostly filled, tiny gaps, + * uneven height up to 10px). Returns a CSS url(...) data SVG. + */ + function generateHalloweenBloodSplatter({ + width = 1400, + height = 10, + dropletCount = 420, + clusterCount = 5, + gapCount = 4, + seed = (Date.now() ^ (Math.random() * 1e9)) >>> 0, + } = {}) { + const rand = mulberry32(seed); + + const clusters = Array.from({ length: clusterCount }, () => ({ + x: rand() * width, + r: 60 + rand() * 120, + weight: 1.4 + rand() * 1.8, + tall: rand() < 0.55, + })); + + const gaps = Array.from({ length: gapCount }, () => ({ + x: rand() * width, + r: 14 + rand() * 28, + })); + + const inGap = (x) => gaps.some((g) => Math.abs(x - g.x) < g.r); + + const sampleCluster = (x) => { + let boost = 0; + let tall = false; + for (const c of clusters) { + const d = Math.abs(x - c.x) / c.r; + if (d < 1) { + const w = c.weight * (1 - d) * (1 - d); + boost += w; + if (c.tall && w > 0.35) tall = true; + } + } + return { boost, tall }; + }; + + const drops = []; + let attempts = 0; + while (drops.length < dropletCount && attempts < dropletCount * 16) { + attempts += 1; + const x = rand() * width; + if (inGap(x)) continue; + + const { boost, tall } = sampleCluster(x); + const accept = 0.82 + Math.min(0.18, boost * 0.08); + if (rand() > accept) continue; + + let maxRise = 3.5 + rand() * 2.5; + if (tall) maxRise = height * (0.75 + rand() * 0.25); + else if (rand() < 0.06) maxRise = height * (0.7 + rand() * 0.3); + + const y = height - rand() * maxRise; + const maxR = tall ? (0.9 + rand() * 1.6) : (0.45 + rand() * 1.1); + const r = 0.3 + rand() * maxR; + const fill = rand() < 0.3 ? '#990000' : (rand() < 0.55 ? '#8b0000' : '#7a0000'); + + if (rand() < 0.16) { + drops.push( + ``, + ); + } else { + drops.push(``); + } + } + + const svg = + `` + + drops.join('') + + ``; + + return { + seed, + cssUrl: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`, + }; + } + + const HALLOWEEN_BLOOD_STYLE_ID = 'kefin-halloween-blood-styles'; + + function clearHalloweenBloodInlinePollution(body = document.body) { + if (!body) return; + body.style.removeProperty('--halloween-blood-band'); + body.style.removeProperty('--halloween-blood-opacity'); + body.style.removeProperty('--halloween-blood-splatter'); + delete body.dataset.halloweenBloodSeed; + try { + sessionStorage.removeItem('kefin-halloween-blood-seed'); + } catch (_) { /* private mode */ } + } + + /** Inject procedural blood SVG via a